Repository: privacyidea/privacyidea

Issue:
push_wait does not break, if the user presses decline on the smartphone.
The push_wait only exits the waiting loop, if the challenge was answered successfully, but not if the authentication request was declined on the smartphone:

https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/tokens/pushtoken.py#L916

(see https://community.privacyidea.org/t/connection-reject-does-not-work-as-expected-when-running-freeradius-as-a-service/2094/6?u=cornelinux)

We need to check, if the user presses decline on the smartphone, if this is only a smartphone internal functionality or if the challenge on the server gets modified/deleted. If so, we need to extend the if-statement in line 916, so that the loop also breaks if the user presses decline.

(I am not sure, if the decline button really communicates to the server, how the smartphone app is designed.
@nilsbehlen @timosturm  could you shed some light on this?)


Discussion:
No the decline button does not communicate with privacyIDEA. I do not know if there is an endpoint that allowes to drop challenges? If there is, we could implement it.

Currently, pressing decline only removes the authentication request from the smartphone.
You are right. We do not have an endpoint, that could be used *without* a service account.
Actually *answering the challenge* works via `/validate/check` and the `nonce`. (see https://github.com/privacyidea/privacyidea/wiki/concept%3A-PushToken#smartphone-response)

There is actually no way to *decline* or *drop* the challenge. However, this should probably also be done via `/validate/check`, too.

Maybe by sending an additional parameter like `decline`, to still indicate, that this is a valid response from the smartphon:
~~~~http
POST https://privacyideaserver/validate/check
     nonce=<nonce_from_request>
     serial=<serial>
     signature=<signature>
     decline=1
~~~~
Sounds good to me.
Hi @nilsbehlen 
how do you like this? https://github.com/privacyidea/privacyidea/wiki/concept:-PushToken#decline
i like it, please add it to the next version of the server =)
Hi @nilsbehlen 
I am currently looking at the implementation in the server.
I think the wiki is not quite accurate, the endpoint is /ttype/push and not /validate/check.
I will fix it in the wiki.

Resolving patch:
diff --git a/privacyidea/lib/tokens/pushtoken.py b/privacyidea/lib/tokens/pushtoken.py
--- a/privacyidea/lib/tokens/pushtoken.py
+++ b/privacyidea/lib/tokens/pushtoken.py
@@ -567,6 +567,7 @@ def _api_endpoint_post(cls, request_data):
             log.debug("Handling the authentication response from the smartphone.")
             challenge = getParam(request_data, "nonce")
             signature = getParam(request_data, "signature")
+            decline = is_true(getParam(request_data, "decline", default=False))
 
             # get the token_obj for the given serial:
             token_obj = get_one_token(serial=serial, tokentype="push")
@@ -580,6 +581,8 @@ def _api_endpoint_post(cls, request_data):
                 for chal in challengeobject_list:
                     # verify the signature of the nonce
                     sign_data = u"{0!s}|{1!s}".format(challenge, serial)
+                    if decline:
+                        sign_data += u"|decline"
                     try:
                         pubkey_obj.verify(b32decode(signature),
                                           sign_data.encode("utf8"),
@@ -587,8 +590,12 @@ def _api_endpoint_post(cls, request_data):
                                           hashes.SHA256())
                         # The signature was valid
                         log.debug("Found matching challenge {0!s}.".format(chal))
-                        chal.set_otp_status(True)
-                        chal.save()
+                        if decline:
+                            # If the challenge is decline, we delete it from the DB
+                            chal.delete()
+                        else:
+                            chal.set_otp_status(True)
+                            chal.save()
                         result = True
                     except InvalidSignature as _e:
                         pass
@@ -732,7 +739,20 @@ def api_endpoint(cls, request, g):
 
               serial=<token serial>
               nonce=<the actual challenge>
-              signature=<the signed nonce>
+              signature=<signature over {nonce}|{serial}>
+
+        - The smartphone can also decline the authentication request, by sending
+          a response to the server:
+
+            .. sourcecode:: http
+
+              POST /ttype/push HTTP/1.1
+              Host: https://yourprivacyideaserver
+
+              serial=<token serial>
+              nonce=<the actual challenge>
+              decline=1
+              signature=<signature over {nonce}|{serial}|decline
 
         - In some cases the Firebase service changes the token of a device. This
           needs to be communicated to privacyIDEA through this endpoint


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -3277,7 +3277,7 @@ def test_01_password_tokens(self):
         with self.app.test_request_context('/validate/check',
                                            method='POST',
                                            data={"user": "cornelius",
-                                                 "pass": "test{0!s}".format(password)}):
+                                                 "pass": quote(u"test{0!s}".format(password))}):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
             data = res.json
diff --git a/tests/test_lib_tokens_push.py b/tests/test_lib_tokens_push.py
--- a/tests/test_lib_tokens_push.py
+++ b/tests/test_lib_tokens_push.py
@@ -58,6 +58,24 @@ def _create_credential_mock():
                           access_token='my_new_bearer_token')
 
 
+def _check_firebase_params(request):
+    payload = json.loads(request.body)
+    # check the signature in the payload!
+    data = payload.get("message").get("data")
+
+    sign_string = u"{nonce}|{url}|{serial}|{question}|{title}|{sslverify}".format(**data)
+    token_obj = get_tokens(serial=data.get("serial"))[0]
+    pem_pubkey = token_obj.get_tokeninfo(PUBLIC_KEY_SERVER)
+    pubkey_obj = load_pem_public_key(to_bytes(pem_pubkey), backend=default_backend())
+    signature = b32decode(data.get("signature"))
+    # If signature does not match it will raise InvalidSignature exception
+    pubkey_obj.verify(signature, sign_string.encode("utf8"),
+                      padding.PKCS1v15(),
+                      hashes.SHA256())
+    headers = {'request-id': '728d329e-0e86-11e4-a748-0c84dc037c13'}
+    return (200, headers, json.dumps({}))
+
+
 class PushTokenTestCase(MyTestCase):
 
     serial1 = "PUSH00001"
@@ -592,23 +610,6 @@ def test_04_api_authenticate_smartphone(self):
         tokenobj.set_pin("pushpin")
         tokenobj.add_user(User("cornelius", self.realm1))
 
-        def check_firebase_params(request):
-            payload = json.loads(request.body)
-            # check the signature in the payload!
-            data = payload.get("message").get("data")
-
-            sign_string = u"{nonce}|{url}|{serial}|{question}|{title}|{sslverify}".format(**data)
-            token_obj = get_tokens(serial=data.get("serial"))[0]
-            pem_pubkey = token_obj.get_tokeninfo(PUBLIC_KEY_SERVER)
-            pubkey_obj = load_pem_public_key(to_bytes(pem_pubkey), backend=default_backend())
-            signature = b32decode(data.get("signature"))
-            # If signature does not match it will raise InvalidSignature exception
-            pubkey_obj.verify(signature, sign_string.encode("utf8"),
-                              padding.PKCS1v15(),
-                              hashes.SHA256())
-            headers = {'request-id': '728d329e-0e86-11e4-a748-0c84dc037c13'}
-            return (200, headers, json.dumps({}))
-
         # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
         with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
                         '.from_service_account_file') as mySA:
@@ -617,7 +618,7 @@ def check_firebase_params(request):
 
             # add responses, to simulate the communication to firebase
             responses.add_callback(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
-                          callback=check_firebase_params,
+                          callback=_check_firebase_params,
                           content_type="application/json")
 
             # Send the first authentication request to trigger the challenge
@@ -761,6 +762,90 @@ def check_firebase_params(request):
             # Result-Value is True
             self.assertTrue(jsonresp.get("result").get("value"))
 
+    def test_04_decline_auth_request(self):
+        # get enrolled push token
+        toks = get_tokens(tokentype="push")
+        self.assertEqual(len(toks), 1)
+        tokenobj = toks[0]
+
+        # set PIN
+        tokenobj.set_pin("pushpin")
+        tokenobj.add_user(User("cornelius", self.realm1))
+
+        # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                        '.from_service_account_file') as mySA:
+            # alternative: side_effect instead of return_value
+            mySA.from_json_keyfile_name.return_value = _create_credential_mock()
+
+            # add responses, to simulate the communication to firebase
+            responses.add_callback(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
+                                   callback=_check_firebase_params,
+                                   content_type="application/json")
+
+            # Send the first authentication request to trigger the challenge
+            with self.app.test_request_context('/validate/check',
+                                               method='POST',
+                                               data={"user": "cornelius",
+                                                     "realm": self.realm1,
+                                                     "pass": "pushpin"}):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 200, res)
+                jsonresp = res.json
+                self.assertFalse(jsonresp.get("result").get("value"))
+                self.assertTrue(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
+                self.assertTrue("transaction_id" in jsonresp.get("detail"))
+                transaction_id = jsonresp.get("detail").get("transaction_id")
+
+            # Our ServiceAccountCredentials mock has not been called because we use a cached token
+            self.assertEqual(len(mySA.from_json_keyfile_name.mock_calls), 0)
+            self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
+
+        # The challenge is sent to the smartphone via the Firebase service, so we do not know
+        # the challenge from the /validate/check API.
+        # So lets read the challenge from the database!
+
+        challengeobject_list = get_challenges(serial=tokenobj.token.serial,
+                                              transaction_id=transaction_id)
+        challenge = challengeobject_list[0].challenge
+
+        sign_data = "{0!s}|{1!s}|decline".format(challenge, tokenobj.token.serial)
+        signature = b32encode_and_unicode(
+            self.smartphone_private_key.sign(sign_data.encode("utf-8"),
+                                             padding.PKCS1v15(),
+                                             hashes.SHA256()))
+
+        # Simulate the decline request to a pre privacyIDEA 3.8 system.
+        # It will ignore the "decline" parameter, so we do not add it in the
+        # request. The request should fail due to an invalid signature
+        with self.app.test_request_context('/ttype/push',
+                                           method='POST',
+                                           data={"serial": tokenobj.token.serial,
+                                                 "nonce": challenge,
+                                                 "signature": signature}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            rj = res.json
+            self.assertTrue(rj['result']['status'])
+            self.assertFalse(rj['result']['value'])
+
+        # Now decline the auth request for real
+        with self.app.test_request_context('/ttype/push',
+                                           method='POST',
+                                           data={"serial": tokenobj.token.serial,
+                                                 "nonce": challenge,
+                                                 "decline": 1,
+                                                 "signature": signature}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            self.assertTrue(res.json['result']['status'])
+            self.assertTrue(res.json['result']['value'])
+        # check, that the challenge does not exist anymore.
+        challengeobject_list = get_challenges(serial=tokenobj.token.serial,
+                                              transaction_id=transaction_id)
+        self.assertEqual(0, len(challengeobject_list))
+
     def test_05_strip_key(self):
         stripped_pubkey = strip_key(self.smartphone_public_key_pem)
         self.assertIn("-BEGIN PUBLIC KEY-", self.smartphone_public_key_pem)