Repository: privacyidea/privacyidea

Issue:
move push token at the end of the token list so that it will not block
It looks like this is absolutely not possible in the current design, if both push tokens have the same PIN! 
due to the linear code of the ``check_token_list``.

A bit more simpler thing we could do is:

* [x] sort the tokenlist and put the push token at the end

  If we sort the token list this way, that push tokens are processed at the end of this list [here](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2163) we can simply implement the below mentioned 2nd step. Sorting could be done by weighing the tokens, to know which tokens to put at the beginning of the list and which at the end of this list. For now we want to put push tokens at the end of the list, but this might be different in other cases.

  This way we will be able to run through the token list with the push tokens being last, all other tokens sorted in alpabetical order:
  ~~~~python
  def tokentype_weighing(tok):
     if tok.type.upper() == "PUSH":
         return 1000
     else:
         return ord(tok.type[0])
  
  for tokenobject in (sorted(tokenobject_list, tokentype_weighing):
     ....
  ~~~~

* [x] If a token in the list authenticates successfully it is stored in ``valid_token_list`` [here](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2196). The token is ``authenticate``d [here](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2186). 
At this point (``authenticate``) we could also pass the ``validate_token_list`` or the length of the list to the push token. If there are already successful authentication, the push token can decide in case of *push_wait* to not authenticate anymore (since it would block the successful authentication of the previous token). 

  We do not need to enhance the interface of ``tokenclass.authenticate``, we could add the length of the valid_token_list in the ``options``.

  [here](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/tokens/pushtoken.py#L873) we need to add s.th. like

  ~~~~python
  if int(options.get("valid_token_numbers", 0)) > 0:
      # We already have a successful authentication, so we pass
      return False, -1, None
  ~~~~



Discussion:

Resolving patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2108,6 +2108,23 @@ def create_challenges_from_tokens(token_list, reply_dict, options=None):
     reply_dict["transaction_ids"] = [chal.get("transaction_id") for chal in reply_dict.get("multi_challenge", [])]
 
 
+def weigh_token_type(token_obj):
+    """
+    This method returns a weight of a token type, which is used
+    to sort the tokentype list. Other weighing functions can be implemented.
+
+    The Push token weighs the most, so that it will be sorted to the end.
+
+    :param token_obj: token object
+    :return: weight of the tokentype
+    :rtype: int
+    """
+    if token_obj.type.upper() == "PUSH":
+        return 1000
+    else:
+        return ord(token_obj.type[0])
+
+
 @log_with(log)
 @libpolicy(reset_all_user_tokens)
 @libpolicy(generic_challenge_response_reset_pin)
@@ -2160,7 +2177,7 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
             raise TokenAdminError(_("This action is not possible, since the "
                                     "token is locked"), id=1007)
 
-    for tokenobject in tokenobject_list:
+    for tokenobject in sorted(tokenobject_list, key=weigh_token_type):
         if log.isEnabledFor(logging.DEBUG):
             # Avoid a SQL query triggered by ``tokenobject.user`` in case
             # the log level is not DEBUG
@@ -2182,6 +2199,9 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
         else:
             # This is a normal authentication attempt
             try:
+                # pass the length of the valid_token_list to ``authenticate`` so that
+                # the push token can react accordingly
+                options["valid_token_num"] = len(valid_token_list)
                 pin_match, otp_count, repl = \
                     tokenobject.authenticate(passw, user, options=options)
             except TokenAdminError as tae:
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
@@ -873,18 +873,20 @@ def authenticate(self, passw, user=None, options=None):
         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))
+            if not options.get("valid_token_num"):
+                # We should only do push_wait, if we do not already have successfully authenticated tokens!
+                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
 


Test patch:
diff --git a/tests/test_api_push_validate.py b/tests/test_api_push_validate.py
new file mode 100644
--- /dev/null
+++ b/tests/test_api_push_validate.py
@@ -0,0 +1,170 @@
+# -*- coding: utf-8 -*-
+from .base import MyApiTestCase
+from privacyidea.lib.user import User
+from privacyidea.lib.token import get_tokens, init_token, remove_token
+from privacyidea.lib.policy import SCOPE, set_policy, delete_policy
+from privacyidea.lib.tokens.pushtoken import PUSH_ACTION, strip_key
+from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+from privacyidea.lib.smsprovider.FirebaseProvider import FIREBASE_CONFIG
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
+from cryptography.hazmat.backends import default_backend
+from privacyidea.lib.utils import to_bytes, to_unicode
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+PWFILE = "tests/testdata/passwords"
+HOSTSFILE = "tests/testdata/hosts"
+DICT_FILE = "tests/testdata/dictionary"
+FIREBASE_FILE = "tests/testdata/firebase-test.json"
+CLIENT_FILE = "tests/testdata/google-services.json"
+FB_CONFIG_VALS = {
+    FIREBASE_CONFIG.REGISTRATION_URL: "http://test/ttype/push",
+    FIREBASE_CONFIG.TTL: 10,
+    FIREBASE_CONFIG.API_KEY: "1",
+    FIREBASE_CONFIG.APP_ID: "2",
+    FIREBASE_CONFIG.PROJECT_NUMBER: "3",
+    FIREBASE_CONFIG.PROJECT_ID: "test-123456",
+    FIREBASE_CONFIG.JSON_CONFIG: FIREBASE_FILE}
+
+
+class PushAPITestCase(MyApiTestCase):
+    """
+    test the api.validate endpoints
+    """
+
+    server_private_key = rsa.generate_private_key(public_exponent=65537,
+                                                  key_size=4096,
+                                                  backend=default_backend())
+    server_private_key_pem = to_unicode(server_private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.TraditionalOpenSSL,
+        encryption_algorithm=serialization.NoEncryption()))
+    server_public_key_pem = to_unicode(server_private_key.public_key().public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo))
+
+    # We now allow white spaces in the firebase config name
+    firebase_config_name = "my firebase config"
+
+    smartphone_private_key = rsa.generate_private_key(public_exponent=65537,
+                                                      key_size=4096,
+                                                      backend=default_backend())
+    smartphone_public_key = smartphone_private_key.public_key()
+    smartphone_public_key_pem = to_unicode(smartphone_public_key.public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo))
+    # The smartphone sends the public key in URLsafe and without the ----BEGIN header
+    smartphone_public_key_pem_urlsafe = strip_key(smartphone_public_key_pem).replace("+", "-").replace("/", "_")
+    serial_push = "PIPU001"
+
+    def test_00_create_realms(self):
+        self.setUp_user_realms()
+
+    def test_01_push_token_reorder_list(self):
+        """
+        * Policy push_wait
+        * The user has two tokens. SPASS and Push with the same PIN.
+
+        A /validate/check request is sent with this PIN.
+        The PIN could trigger a challenge response with the Push, but since the
+        token list is reordered and the Spass token already successfully authenticates,
+        the push token is not evaluated anymore.
+        """
+        # set policy
+        set_policy("push1", action="{0!s}=20".format(PUSH_ACTION.WAIT), scope=SCOPE.AUTH)
+        set_policy("push2", scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s}".format(PUSH_ACTION.FIREBASE_CONFIG,
+                                               self.firebase_config_name))
+        # Create push config
+        r = set_smsgateway(self.firebase_config_name,
+                           u'privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider',
+                           "myFB", FB_CONFIG_VALS)
+        self.assertTrue(r > 0)
+
+        # create push token for user
+        # 1st step
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"type": "push",
+                                                 "pin": "otppin",
+                                                 "user": "selfservice",
+                                                 "realm": self.realm1,
+                                                 "serial": self.serial_push,
+                                                 "genkey": 1},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            detail = res.json.get("detail")
+            serial = detail.get("serial")
+            self.assertEqual(detail.get("rollout_state"), "clientwait")
+            self.assertTrue("pushurl" in detail)
+            # check that the new URL contains the serial number
+            self.assertTrue("&serial=PIPU" in detail.get("pushurl").get("value"))
+            self.assertTrue("appid=" in detail.get("pushurl").get("value"))
+            self.assertTrue("appidios=" in detail.get("pushurl").get("value"))
+            self.assertTrue("apikeyios=" in detail.get("pushurl").get("value"))
+            self.assertFalse("otpkey" in detail)
+            enrollment_credential = detail.get("enrollment_credential")
+
+        # 2nd step: as performed by the smartphone
+        with self.app.test_request_context('/ttype/push',
+                                           method='POST',
+                                           data={"enrollment_credential": enrollment_credential,
+                                                 "serial": serial,
+                                                 "pubkey": self.smartphone_public_key_pem_urlsafe,
+                                                 "fbtoken": "firebaseT"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            # still the same serial number
+            self.assertEqual(serial, detail.get("serial"))
+            self.assertEqual(detail.get("rollout_state"), "enrolled")
+            # Now the smartphone gets a public key from the server
+            augmented_pubkey = "-----BEGIN RSA PUBLIC KEY-----\n{}\n-----END RSA PUBLIC KEY-----\n".format(
+                detail.get("public_key"))
+            parsed_server_pubkey = serialization.load_pem_public_key(
+                to_bytes(augmented_pubkey),
+                default_backend())
+            self.assertIsInstance(parsed_server_pubkey, RSAPublicKey)
+            pubkey = detail.get("public_key")
+
+            # Now check, what is in the token in the database
+            toks = get_tokens(serial=serial)
+            self.assertEqual(len(toks), 1)
+            token_obj = toks[0]
+            self.assertEqual(token_obj.token.rollout_state, u"enrolled")
+            self.assertTrue(token_obj.token.active)
+            tokeninfo = token_obj.get_tokeninfo()
+            self.assertEqual(tokeninfo.get("public_key_smartphone"), self.smartphone_public_key_pem_urlsafe)
+            self.assertEqual(tokeninfo.get("firebase_token"), u"firebaseT")
+            self.assertEqual(tokeninfo.get("public_key_server").strip().strip("-BEGIN END RSA PUBLIC KEY-").strip(),
+                             pubkey)
+            # The token should also contain the firebase config
+            self.assertEqual(tokeninfo.get(PUSH_ACTION.FIREBASE_CONFIG), self.firebase_config_name)
+
+        # create spass token for user
+        init_token({"serial": "spass01", "type": "spass", "pin": "otppin"},
+                   user=User("selfservice", self.realm1))
+
+        # check, if the user has two tokens, now
+        toks = get_tokens(user=User("selfservice", self.realm1))
+        self.assertEqual(2, len(toks))
+        self.assertEqual("push", toks[0].type)
+        self.assertEqual("spass", toks[1].type)
+        # authenticate with spass
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "realm": self.realm1,
+                                                 "pass": "otppin"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            data = res.json
+            self.assertTrue(data.get("result").get("value"))
+            # successful auth with spass
+            self.assertEqual("spass", data.get("detail").get("type"))
+
+        remove_token(self.serial_push)
+        remove_token("spass01")
+        delete_policy("push1")
+        delete_policy("push2")
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
@@ -2546,7 +2546,8 @@ def test_30_challenge_text(self):
             res = self.app.full_dispatch_request()
             self.assertEqual(res.status_code, 200)
             resp = res.json
-            self.assertEqual(resp.get("detail").get("message"), "check your sms, check your email")
+            self.assertIn("check your sms", resp.get("detail").get("message"))
+            self.assertIn("check your email", resp.get("detail").get("message"))
 
         delete_policy("chalsms")
         delete_policy("chalemail")
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -16,6 +16,7 @@
 from .base import MyTestCase, FakeAudit, FakeFlaskG
 from privacyidea.lib.user import (User)
 from privacyidea.lib.tokenclass import TokenClass, TOKENKIND, FAILCOUNTER_EXCEEDED, FAILCOUNTER_CLEAR_TIMEOUT
+from privacyidea.lib.token import weigh_token_type
 from privacyidea.lib.tokens.totptoken import TotpTokenClass
 from privacyidea.models import (Token, Challenge, TokenRealm)
 from privacyidea.lib.config import (set_privacyidea_config, get_token_types, delete_privacyidea_config, SYSCONF)
@@ -1556,6 +1557,16 @@ def test_58_check_old_pin(self):
         self.assertTrue(r)
         remove_token(serial)
 
+    def test_59_weigh_token_types(self):
+
+        class dummy_token(object):
+            def __init__(self, type):
+                self.type = type
+        self.assertEqual(1000, weigh_token_type(dummy_token("push")))
+        self.assertTrue(weigh_token_type(dummy_token("push")) > weigh_token_type(dummy_token("hotp")))
+        self.assertTrue(weigh_token_type(dummy_token("push")) > weigh_token_type(dummy_token("HOTP")))
+        self.assertTrue(weigh_token_type(dummy_token("PUSH")) > weigh_token_type(dummy_token("hotp")))
+
 
 class TokenOutOfBandTestCase(MyTestCase):