Repository: privacyidea/privacyidea

Issue:
Allow push token to delay the response
When authenticating with a push token, the response of the API request to ``validate/check`` is directly returned - for good reasons.
Applications then should [poll for the confirmation of the push token](https://github.com/privacyidea/privacyidea/wiki/concept%3A-PushToken#polling-endpoint-for-application).
Although this is a cleaner design, we can offer an alternative.

The response of the ``validate/check`` request is held back until the push token was confirmed.

Some problems: If the user has several tokens, how should we react:
1. another token successfully authenticated: return directly?
2. all other tokens failed to authenticate: wait?

We need at least the following settings:
1. activate this behaviour
2. timeout, how long privacyIDEA should wait for an answer of the push token


Discussion:
Sounds handy! However, we should keep in mind that simply delaying the response will block a worker thread, which won't be able to handle other requests in the meantime. In a productive environment, it could happen that privacyIDEA becomes unresponsive for some time because all worker threads are waiting for a push token response.

Friedrich suggests an implementation with a polling twisted proxy server.

Resolving patch:
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
@@ -1295,6 +1295,39 @@ def save_client_application_type(request, action):
     return True
 
 
+def pushtoken_wait(request, action):
+    """
+    This is a auth specific wrapper to decorate /validate/check
+    According to the policy scope=SCOPE.AUTH, action=push_wait
+
+    :param request:
+    :param action:
+    :return:
+    """
+    user_object = request.User
+    if user_object:
+        token_user = user_object.login
+        token_realm = user_object.realm
+        token_resolver = user_object.resolver
+    else:
+        token_realm = token_resolver = token_user = None
+
+    waiting = g.policy_object.get_action_values(
+        action=PUSH_ACTION.WAIT,
+        scope=SCOPE.AUTH,
+        realm=token_realm,
+        user=token_user,
+        resolver=token_resolver,
+        client=g.client_ip,
+        audit_data=g.audit_object.audit_data,
+        allow_white_space_in_action=True,
+        unique=True)
+    if len(waiting) >= 1:
+        request.all_data[PUSH_ACTION.WAIT] = int(list(waiting)[0])
+    else:
+        request.all_data[PUSH_ACTION.WAIT] = False
+
+
 def pushtoken_add_config(request, action):
     """
     This is a token specific wrapper for push token for the endpoint
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -80,7 +80,7 @@
 from privacyidea.api.lib.prepolicy import (prepolicy, set_realm,
                                            api_key_required, mangle,
                                            save_client_application_type,
-                                           check_base_action)
+                                           check_base_action, pushtoken_wait)
 from privacyidea.api.lib.postpolicy import (postpolicy,
                                             check_tokentype, check_serial,
                                             check_tokeninfo,
@@ -218,6 +218,7 @@ def offlinerefill():
 @postpolicy(check_tokentype, request=request)
 @postpolicy(check_serial, request=request)
 @postpolicy(autoassign, request=request)
+@prepolicy(pushtoken_wait, request=request)
 @prepolicy(set_realm, request=request)
 @prepolicy(mangle, request=request)
 @prepolicy(save_client_application_type, request=request)
@@ -367,6 +368,7 @@ def check():
 @postpolicy(check_tokentype, request=request)
 @postpolicy(check_serial, request=request)
 @postpolicy(autoassign, request=request)
+@prepolicy(pushtoken_wait, request=request)
 @prepolicy(set_realm, request=request)
 @prepolicy(mangle, request=request)
 @prepolicy(save_client_application_type, request=request)
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
@@ -41,7 +41,7 @@
 from privacyidea.lib import _
 
 from privacyidea.lib.tokenclass import TokenClass
-from privacyidea.models import Challenge
+from privacyidea.models import Challenge, db
 from privacyidea.lib.decorators import check_token_locked
 import logging
 from privacyidea.lib.utils import create_img, b32encode_and_unicode
@@ -57,7 +57,7 @@
 from cryptography.hazmat.primitives import hashes
 from cryptography.hazmat.primitives.asymmetric import padding
 from cryptography.exceptions import InvalidSignature
-
+import time
 
 log = logging.getLogger(__name__)
 
@@ -68,12 +68,14 @@
 PUBLIC_KEY_SMARTPHONE = "public_key_smartphone"
 GWTYPE = u'privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider'
 
+DELAY = 1.0
 
 class PUSH_ACTION(object):
     FIREBASE_CONFIG = "push_firebase_configuration"
     MOBILE_TEXT = "push_text_on_mobile"
     MOBILE_TITLE = "push_title_on_mobile"
     SSL_VERIFY = "push_ssl_verify"
+    WAIT = "push_wait"
 
 
 def strip_key(key):
@@ -174,7 +176,7 @@ class PushTokenClass(TokenClass):
     def __init__(self, db_token):
         TokenClass.__init__(self, db_token)
         self.set_type(u"push")
-        self.mode = ['challenge']
+        self.mode = ['challenge', 'authenticate']
         self.hKeyRequired = False
 
 
@@ -241,8 +243,13 @@ def get_class_info(key=None, ret='all'):
                            'desc': _('The smartphone needs to verify SSL during authentication. (default 1)'),
                            'group': "PUSH",
                            'value': ["0", "1"]
-
+                       },
+                       PUSH_ACTION.WAIT: {
+                           'type': 'int',
+                           'desc': _('Wait for number of seconds for the user to confirm the challenge in the first request.'),
+                           'group': "PUSH"
                        }
+
                    }
                },
         }
@@ -469,6 +476,9 @@ def is_challenge_request(self, passw, user=None, options=None):
 
         :return: returns true or false
         """
+        if options.get(PUSH_ACTION.WAIT):
+            # We have a push_wait in the parameters
+            return False
         return self.check_pin(passw, user=user, options=options)
 
     def create_challenge(self, transactionid=None, options=None):
@@ -561,6 +571,48 @@ def create_challenge(self, transactionid=None, options=None):
         self.challenge_janitor()
         return True, message, db_challenge.transaction_id, attributes
 
+    @check_token_locked
+    def authenticate(self, passw, user=None, options=None):
+        """
+        High level interface which covers the check_pin and check_otp
+        This is the method that verifies single shot authentication.
+        The challenge is send to the smartphone app and privacyIDEA
+        waits for the response to arrive.
+
+        :param passw: the password which could be pin+otp value
+        :type passw: string
+        :param user: The authenticating user
+        :type user: User object
+        :param options: dictionary of additional request parameters
+        :type options: dict
+
+        :return: returns tuple of
+                 1. true or false for the pin match,
+                 2. the otpcounter (int) and the
+                 3. reply (dict) that will be added as
+                    additional information in the JSON response
+                    of ``/validate/check``.
+        :rtype: tuple
+        """
+        otp_counter = -1
+        reply = None
+        pin_match = self.check_pin(passw, user=user, options=options)
+        if pin_match:
+            waiting = int(options.get(PUSH_ACTION.WAIT, 20))
+            # Trigger the challenge
+            _t, _m, transaction_id, _attr = self.create_challenge(options=options)
+            # now we need to check and wait for the response to be answered in the challenge table
+            starttime = time.time()
+            while True:
+                db.session.commit()
+                otp_counter = self.check_challenge_response(options={"transaction_id": transaction_id})
+                elapsed_time = time.time() - starttime
+                if otp_counter >= 0 or elapsed_time > waiting or elapsed_time < 0:
+                    break
+                time.sleep(DELAY - (elapsed_time % DELAY))
+
+        return pin_match, otp_counter, reply
+
     @check_token_locked
     def check_challenge_response(self, user=None, passw=None, options=None):
         """


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
@@ -26,7 +26,7 @@
                                            papertoken_count, allowed_audit_realm,
                                            u2ftoken_verify_cert,
                                            tantoken_count, sms_identifiers,
-                                           pushtoken_add_config)
+                                           pushtoken_add_config, pushtoken_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,
@@ -45,6 +45,7 @@
 from privacyidea.lib.tokens.papertoken import PAPERACTION
 from privacyidea.lib.tokens.tantoken import TANACTION
 from privacyidea.lib.tokens.smstoken import SMSACTION
+from privacyidea.lib.tokens.pushtoken import PUSH_ACTION
 
 from flask import Response, Request, g, current_app, jsonify
 from werkzeug.test import EnvironBuilder
@@ -1493,6 +1494,34 @@ def test_23_enroll_different_tokentypes_in_different_resolvers(self):
         delete_policy("reso1pol")
         delete_realm(realm)
 
+    def test_24_push_wait_policy(self):
+
+        # We send a fake push_wait, that is not in the policies
+        builder = EnvironBuilder(method='POST',
+                                 data={'user': "hans",
+                                       'pass': "pin",
+                                       'push_wait': "120"},
+                                 headers={})
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.User = User()
+        req.all_data = {"push_wait": "120"}
+        g.policy_object = PolicyClass()
+        pushtoken_wait(req, None)
+        self.assertEqual(req.all_data.get(PUSH_ACTION.WAIT), False)
+
+        # Now we use the policy, to set the push_wait seconds
+        set_policy(name="push1", scope=SCOPE.AUTH, action="{0!s}=10".format(PUSH_ACTION.WAIT))
+        req.all_data = {}
+        g.policy_object = PolicyClass()
+        pushtoken_wait(req, None)
+        self.assertEqual(req.all_data.get(PUSH_ACTION.WAIT), 10)
+
+        delete_policy("push1")
+
 
 class PostPolicyDecoratorTestCase(MyApiTestCase):
 
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)
+from privacyidea.lib.policy import (SCOPE, set_policy, delete_policy)
 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
@@ -28,7 +28,8 @@
 from cryptography.hazmat.primitives.asymmetric import padding
 from cryptography.hazmat.primitives.asymmetric import rsa
 from cryptography.hazmat.primitives import serialization
-
+from threading import Timer
+import time
 
 class myAccessTokenInfo(object):
     def __init__(self, access_token):
@@ -392,6 +393,70 @@ def test_03_api_authenticate_client(self):
             # Result-Value is True, since the challenge is marked resolved in the DB
         self.assertTrue(jsonresp.get("result").get("value"))
 
+        # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
+        # Do single shot auth with waiting
+        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")
+
+            # In two seconds we need to run an update on the challenge table.
+            Timer(2, self.mark_challenge_as_accepted).start()
+
+            set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=20".format(PUSH_ACTION.WAIT))
+            # 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 = json.loads(res.data.decode('utf8'))
+                # We successfully authenticated! YEAH!
+                self.assertTrue(jsonresp.get("result").get("value"))
+                self.assertTrue(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
+            delete_policy("push1")
+
+        # Authentication fails, if the push notification is not accepted within the configured time
+        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")
+
+            set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=1".format(PUSH_ACTION.WAIT))
+            # 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 = json.loads(res.data.decode('utf8'))
+                # We fail to authenticate! Oh No!
+                self.assertFalse(jsonresp.get("result").get("value"))
+                self.assertTrue(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
+            delete_policy("push1")
+
+    def mark_challenge_as_accepted(self):
+        # We simply mark all challenges as successfully answered!
+        with self.app.test_request_context():
+            challenges = get_challenges()
+            for chal in challenges:
+                chal.set_otp_status(True)
+                chal.save()
+
     @responses.activate
     def test_04_api_authenticate_smartphone(self):
         # Test the /validate/check endpoints and the smartphone endpoint /ttype/push