Repository: privacyidea/privacyidea

Issue:
Triggering incomplete push token
1. Start pushtoken rollout
2. Do NOT scan the QR
3. Go to the token and activate it
4. Trigger it

This will result in the following stacktrace:
~~~~
Traceback (mostrecent call last):
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/app.py", line
2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/app.py", line
1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/app.py", line
1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/_compat.py",
line 39, in reraise
    raise value
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/app.py", line
1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/opt/privacyidea/lib/python3.6/site-packages/flask/app.py", line
1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/auth.py",
line 393, in decorated_function
    return f(*args, **kwargs)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/postpolicy.py",
line 108, in policy_wrapper
    response = wrapped_function(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/postpolicy.py",
line 108, in policy_wrapper
    response = wrapped_function(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/decorators.py",
line 41, in function_wrapper
    response = wrapped_function(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/lib/decorators.py",
line 100, in check_user_or_serial_in_request_wrapper
    f_result = func(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/prepolicy.py",
line 154, in policy_wrapper
    return wrapped_function(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/prepolicy.py",
line 154, in policy_wrapper
    return wrapped_function(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/lib/prepolicy.py",
line 154, in policy_wrapper
    return wrapped_function(*args, **kwds)
  [Previous line repeated 1 more time]
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/lib/event.py",
line 99, in event_wrapper
    f_result = func(*args, **kwds)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/api/validate.py",
line 521, in trigger_challenge
    create_challenges_from_tokens(chal_resp_tokens, details, options)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/lib/token.py",
line 2090, in create_challenges_from_tokens
    transactionid=transaction_id, options=options)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/lib/tokens/pushtoken.py",
line 836, in create_challenge
    pem_privkey, options)
  File
"/opt/privacyidea/lib/python3.6/site-packages/privacyidea/lib/tokens/pushtoken.py",
line 202, in _build_smartphone_data
    None, default_backend())
  File
"/opt/privacyidea/lib/python3.6/site-packages/cryptography/hazmat/primitives/serialization/base.py",
line 18, in load_pem_private_key
    return backend.load_pem_private_key(data, password)
  File
"/opt/privacyidea/lib/python3.6/site-packages/cryptography/hazmat/backends/openssl/backend.py",
line 1248, in load_pem_private_key
    password,
  File
"/opt/privacyidea/lib/python3.6/site-packages/cryptography/hazmat/backends/openssl/backend.py",
line 1443, in _load_key
    mem_bio = self._bytes_to_bio(data)
  File
"/opt/privacyidea/lib/python3.6/site-packages/cryptography/hazmat/backends/openssl/backend.py",
line 663, in _bytes_to_bio
    data_ptr = self._ffi.from_buffer(data)
TypeError: a bytes-like object is required, not 'NoneType'
~~~~


and the response will be a 500.
Obviously this is kind of forced but i think it could be prevented easily. It also prevents the triggering of the other token of that user.


Discussion:
It looks like we need to ignore a not enrolled push token (client wait) during authentication.

This incomplete token needs to be excluded `check_token_list`.

The not-ready enrolled token can be identified by rollout_state or missing tokeninfo entries.

Resolving patch:
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -668,6 +668,10 @@ def get_otp_count(self):
     def is_active(self):
         return self.token.active
 
+    @property
+    def rollout_state(self):
+        return self.token.rollout_state
+
     def is_fit_for_challenge(self, messages, options=None):
         """
         This method is called if a cryptographically matching response to a challenge was found.
@@ -1216,6 +1220,8 @@ def check_all(self, message_list):
             message_list.append("Failcounter exceeded")
         elif not self.check_validity_period():
             message_list.append("Outside validity period")
+        elif self.rollout_state in [ROLLOUTSTATE.CLIENTWAIT]:
+            message_list.append("Token is not yet enrolled")
         else:
             r = True
         if not r:


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
@@ -12,6 +12,7 @@
 from privacyidea.lib.utils import to_bytes, to_unicode
 from cryptography.hazmat.primitives.asymmetric import rsa
 from privacyidea.lib.policy import ACTION
+from privacyidea.lib.token import enable_token
 
 PWFILE = "tests/testdata/passwords"
 HOSTSFILE = "tests/testdata/hosts"
@@ -261,7 +262,6 @@ def test_02_push_token_do_not_wait_if_disabled(self):
                     "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
@@ -293,3 +293,65 @@ def test_02_push_token_do_not_wait_if_disabled(self):
         delete_policy("push1")
         delete_policy("push2")
         delete_policy("chalresp")
+
+    def test_03_unfinished_enrolled_push_token(self):
+        """
+        * The user has a push token where the enrollment process was not completed
+
+        A /validate/check request is sent with this PIN.
+        """
+        # set policy
+        set_policy("push2", scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s},{2!s}={3!s}".format(
+                       PUSH_ACTION.FIREBASE_CONFIG, self.firebase_config_name,
+                       PUSH_ACTION.REGISTRATION_URL, REGISTRATION_URL))
+        # 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")
+
+        # We skip the 2nd step of the enrollment!
+        # But we activate the token on purpose!
+        enable_token(self.serial_push)
+
+        # authenticate with push
+        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"))
+            detail = data.get("detail")
+            self.assertEqual(detail.get("message"), "Token is not yet enrolled")
+
+        remove_token(self.serial_push)
+        delete_policy("push2")