Repository: privacyidea/privacyidea

Issue:
push tokens get triggered when disabled
### Top-level intent

A handful of our users were having issues with push tokens and were temporarily switched to SMS tokens.  Their push tokens were set to be disabled.   The users were also using the same PIN for their SMS and Push tokens out of convenience.

### Steps to reproduce

1.  Assign a user a push token and set the PIN
2.  Assing a user a second token with the same PIN which could be SMS/Email or any other challenge/response token
3.  Disable the push token

### Expected outcome

Push token would be ignored and SMS token would be used for challenge/response.  The push_wait policy would be skipped and the push tokens would not be triggered as they are in a disabled state.

### Actual outcome

Push token is still triggered and the user must wait until the push_wait timeout expires before receiving/being prompted to enter the tokencode from their SMS/Email/etc

### Configuration

* **privacyIDEA version**: 3.5.2
* **Installation method**: Ubuntu Packages
* **Python version**: 3.5.2
* **Operating system**: Ubuntu 18.04 LTS
* **Webserver**: Apache
* **Token database**: MySQL




Discussion:
This happens in `check_token_list`.

https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2144

We enter this function actually also with disabled tokens. We do this on purpose, so that - if a token is disabled on the server side - but a user uses e.g. the valud OTP value of the disabled token, this OTP value is compromised and must be validated by the server. For this reason the check for disabled tokens is only done *after* authentication happens.

It looks like that in case of a push token this means, that the check blocks the token loop. 
So we would have to additionally check, whether the push token is disabled.

We could also add a class attribute to tokentypes that says: "This tokentype must be checked, even if it is disabled".
We could then in `check_token_list` clean the to-be-checked token list of these tokens, where such attribute would be false, just like we do for revoked tokens:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2186

~~~~python
tokenobject_list = [token for token in tokenobject_list if (token.is_active() or token.check_if_disabled)]
~~~~

We need to verify if there are other tokentypes, that do not need to be checked like SMS or Email tokens.

All other disabled challenge-response tokens are already handled here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2382

Resolving patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2190,6 +2190,11 @@ 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)
 
+    # Remove certain disabled tokens from tokenobject_list
+    if len(tokenobject_list) > 0:
+        tokenobject_list = [token for token in tokenobject_list if (token.is_active()
+                                                                    or token.check_if_disabled)]
+
     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
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -138,6 +138,8 @@ class TokenClass(object):
     using_pin = True
     hKeyRequired = False
     mode = [TOKENMODE.AUTHENTICATE, TOKENMODE.CHALLENGE]
+    # Usually a token will be checked in the lib:check_token_list, even if it is disabled
+    check_if_disabled = True
 
     @log_with(log)
     def __init__(self, db_token):
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
@@ -256,6 +256,8 @@ class PushTokenClass(TokenClass):
     - https://github.com/privacyidea/privacyidea/wiki/concept%3A-PushToken
     """
     mode = [TOKENMODE.AUTHENTICATE, TOKENMODE.CHALLENGE, TOKENMODE.OUTOFBAND]
+    # A disabled PUSH token has to be removed from the list of checked tokens.
+    check_if_disabled = False
 
     def __init__(self, db_token):
         TokenClass.__init__(self, db_token)


Test patch:
diff --git a/tests/test_api_push_validate.py b/tests/test_api_push_validate.py
--- a/tests/test_api_push_validate.py
+++ b/tests/test_api_push_validate.py
@@ -11,6 +11,7 @@
 from cryptography.hazmat.backends import default_backend
 from privacyidea.lib.utils import to_bytes, to_unicode
 from cryptography.hazmat.primitives.asymmetric import rsa
+from privacyidea.lib.policy import ACTION
 
 PWFILE = "tests/testdata/passwords"
 HOSTSFILE = "tests/testdata/hosts"
@@ -168,3 +169,123 @@ def test_01_push_token_reorder_list(self):
         remove_token("spass01")
         delete_policy("push1")
         delete_policy("push2")
+
+    def test_02_push_token_do_not_wait_if_disabled(self):
+        """
+        * Policy push_wait
+        * The user has two tokens. HOTP chal-resp and Push with the same PIN.
+        * But in this case, the push token is disabled.
+        * The user should get the response immediately.
+
+        A /validate/check request is sent with this PIN.
+        The PIN will only trigger the HOTP, push will not wait, since it is disabled.
+        """
+        # 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))
+        set_policy("chalresp", action="{0!s}=hotp".format(ACTION.CHALLENGERESPONSE), scope=SCOPE.AUTH)
+        # 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 HOTP token for user
+        init_token({"serial": "hotp01", "type": "hotp", "pin": "otppin",
+                    "otpkey": self.otpkey},
+                   user=User("selfservice", self.realm1))
+
+        from privacyidea.lib.token import enable_token
+        # disable the push token
+        enable_token(self.serial_push, False)
+        # 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.assertFalse(toks[0].is_active())
+        self.assertEqual("hotp", toks[1].type)
+
+        # authenticate with hotp
+        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.assertFalse(data.get("result").get("value"))
+            # This is the auth-request for the HOTP token
+            detail = data.get("detail")
+            multi_challenge = detail.get("multi_challenge")
+            self.assertEqual(multi_challenge[0].get("type"), "hotp")
+            self.assertEqual(multi_challenge[0].get("serial"), "hotp01")
+
+        remove_token(self.serial_push)
+        remove_token("hotp01")
+        delete_policy("push1")
+        delete_policy("push2")
+        delete_policy("chalresp")