Repository: privacyidea/privacyidea

Issue:
/auth endpoint allows specifying a push_wait parameter
This is a follow-up to #1583 which introduced the ``push_wait`` policy action. This action allows to trigger a challenge and wait for the confirmation by the user in a single request to ``/validate/check``. Internally, the policy decorator function ``pushtoken_wait`` sets a ``push_wait`` option, either to a number of seconds:
https://github.com/privacyidea/privacyidea/blob/a2174a0260a3ec4484caacfe0bc6a0f8a67a2937/privacyidea/api/lib/prepolicy.py#L1353
or False:
https://github.com/privacyidea/privacyidea/blob/a2174a0260a3ec4484caacfe0bc6a0f8a67a2937/privacyidea/api/lib/prepolicy.py#L1355
The push token later reads the ``push_wait`` option value to find out whether to wait for the user confirmation or not:
https://github.com/privacyidea/privacyidea/blob/a2174a0260a3ec4484caacfe0bc6a0f8a67a2937/privacyidea/lib/tokens/pushtoken.py#L481

As it turns out, the same code location is hit by an ``/auth`` request. But as this endpoint is not decorated with ``pushtoken_wait``, we never explicitly set the ``push_wait`` option. As a result, the user can manually pass a ``push_wait`` option in the request body.

As the documentation explicitly states that ``push_wait`` only takes effect for ``/validate/check``, the ``push_wait`` option should be ignored in the ``/auth`` request. 


Discussion:

Resolving patch:
diff --git a/privacyidea/api/auth.py b/privacyidea/api/auth.py
--- a/privacyidea/api/auth.py
+++ b/privacyidea/api/auth.py
@@ -60,7 +60,7 @@
 from privacyidea.lib.policy import PolicyClass
 from privacyidea.lib.realm import get_default_realm
 from privacyidea.api.lib.postpolicy import postpolicy, get_webui_settings
-from privacyidea.api.lib.prepolicy import is_remote_user_allowed
+from privacyidea.api.lib.prepolicy import is_remote_user_allowed, prepolicy, pushtoken_disable_wait
 from privacyidea.api.lib.utils import (send_result, get_all_params,
                                        verify_auth_token, getParam)
 from privacyidea.lib.utils import get_client_ip, hexlify_and_unicode
@@ -100,6 +100,7 @@ def before_request():
 
 
 @jwtauth.route('', methods=['POST'])
+@prepolicy(pushtoken_disable_wait, request)
 @postpolicy(get_webui_settings)
 @event("auth", request, g)
 def get_auth_token():
diff --git a/privacyidea/api/lib/prepolicy.py b/privacyidea/api/lib/prepolicy.py
--- a/privacyidea/api/lib/prepolicy.py
+++ b/privacyidea/api/lib/prepolicy.py
@@ -1322,6 +1322,18 @@ def save_client_application_type(request, action):
     return True
 
 
+def pushtoken_disable_wait(request, action):
+    """
+    This is used for the /auth endpoint and sets the
+    PUSH_ACTION.WAIT parameter to False.
+
+    :param request:
+    :param action:
+    :return:
+    """
+    request.all_data[PUSH_ACTION.WAIT] = False
+
+
 def pushtoken_wait(request, action):
     """
     This is a auth specific wrapper to decorate /validate/check


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -27,7 +27,7 @@
                                            u2ftoken_verify_cert,
                                            tantoken_count, sms_identifiers,
                                            pushtoken_add_config, pushtoken_wait,
-                                           check_admin_tokenlist)
+                                           check_admin_tokenlist, pushtoken_disable_wait)
 from privacyidea.lib.realm import set_realm as create_realm
 from privacyidea.lib.realm import delete_realm
 from privacyidea.api.lib.postpolicy import (check_serial, check_tokentype,
@@ -1523,6 +1523,24 @@ def test_24_push_wait_policy(self):
 
         delete_policy("push1")
 
+    def test_24b_push_disable_wait_policy(self):
+        # We send a fake push_wait that is not in the policies
+        class RequestMock(object):
+            pass
+        req = RequestMock()
+        req.all_data = {"push_wait": "120"}
+        pushtoken_disable_wait(req, None)
+        self.assertEqual(req.all_data.get(PUSH_ACTION.WAIT), False)
+
+        # But even with a policy, the function still sets PUSH_ACTION.WAIT to False
+        set_policy(name="push1", scope=SCOPE.AUTH, action="{0!s}=10".format(PUSH_ACTION.WAIT))
+        req = RequestMock()
+        req.all_data = {"push_wait": "120"}
+        pushtoken_disable_wait(req, None)
+        self.assertEqual(req.all_data.get(PUSH_ACTION.WAIT), False)
+
+        delete_policy("push1")
+
     def test_25_admin_token_list(self):
         # The tokenlist policy can result in a None filter, an empty [] filter or
         # a filter with realms ["realm1", "realm2"].
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
@@ -13,7 +13,7 @@
 from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib.crypto import geturandom
 from privacyidea.models import Token
-from privacyidea.lib.policy import (SCOPE, set_policy, delete_policy)
+from privacyidea.lib.policy import (SCOPE, set_policy, delete_policy, ACTION, LOGINMODE)
 from privacyidea.lib.utils import to_bytes, b32encode_and_unicode, to_unicode
 from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway, SMSError
 from privacyidea.lib.error import ConfigAdminError
@@ -642,3 +642,95 @@ def test_05_strip_key(self):
         self.assertNotIn("-", stripped_pubkey)
         self.assertEqual(strip_key(stripped_pubkey), stripped_pubkey)
         self.assertEqual(strip_key("\n\n" + stripped_pubkey + "\n\n"), stripped_pubkey)
+
+    @responses.activate
+    def test_06_api_auth(self):
+        self.setUp_user_realms()
+
+        # 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))
+
+        # Set a loginmode policy
+        set_policy("webui", scope=SCOPE.WEBUI,
+                   action="{}={}".format(ACTION.LOGINMODE, LOGINMODE.PRIVACYIDEA))
+        # Set a PUSH_WAIT action which will be ignored by privacyIDEA
+        set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=20".format(PUSH_ACTION.WAIT))
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+            # alternative: side_effect instead of return_value
+            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
+
+            # add responses, to simulate the communication to firebase
+            responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
+                          body="""{}""",
+                          content_type="application/json")
+
+            with self.app.test_request_context('/auth',
+                                               method='POST',
+                                               data={"username": "cornelius",
+                                                     "realm": self.realm1,
+                                                     # this will be overwritted by pushtoken_disable_wait
+                                                     PUSH_ACTION.WAIT: "10",
+                                                     "password": "pushpin"}):
+                res = self.app.full_dispatch_request()
+                self.assertEqual(res.status_code, 401)
+                jsonresp = json.loads(res.data.decode('utf8'))
+                self.assertFalse(jsonresp.get("result").get("value"))
+                self.assertFalse(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
+                self.assertIn("transaction_id", jsonresp.get("detail"))
+                transaction_id = jsonresp.get("detail").get("transaction_id")
+                self.assertEqual(jsonresp.get("detail").get("message"), DEFAULT_CHALLENGE_TEXT)
+
+        # Get the challenge from the database
+        challengeobject_list = get_challenges(serial=tokenobj.token.serial,
+                                              transaction_id=transaction_id)
+        challenge = challengeobject_list[0].challenge
+        # This is what the smartphone answers.
+        # create the signature:
+        sign_data = "{0!s}|{1!s}".format(challenge, tokenobj.token.serial)
+        signature = b32encode_and_unicode(
+            self.smartphone_private_key.sign(sign_data.encode("utf-8"),
+                                             padding.PKCS1v15(),
+                                             hashes.SHA256()))
+
+        # We still cannot log in
+        with self.app.test_request_context('/auth',
+                                           method='POST',
+                                           data={"username": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 401)
+            self.assertFalse(res.json['result']['status'])
+
+        # Answer the challenge
+        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)
+            self.assertTrue(res.json['result']['status'])
+            self.assertTrue(res.json['result']['value'])
+
+        # We can now log in
+        with self.app.test_request_context('/auth',
+                                           method='POST',
+                                           data={"username": "cornelius",
+                                                 "realm": self.realm1,
+                                                 "pass": "",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            self.assertTrue(res.json['result']['status'])
+
+        delete_policy("push1")
+        delete_policy("webui")
\ No newline at end of file