Repository: privacyidea/privacyidea

Issue:
Verify enroll is generating a second TAN list
privacyidea 3.8.1
ubuntu 20
Customer: 3858

If we roll out a TAN or a PPR list via the PI GUI, the following happens:

- A TAN list is displayed including the dialog box for entering the verification TAN.
- I save the TAN list and enter the first TAN in the dialog box.
- The TAN is accepted, the token is also successfully set to: enrolled_state = enrolled.
- 
- BUT: now I am shown a completely new TAN list that does not match the first.
This new TAN list is then also the valid list that I have to use for authentication.

![BBA2](https://github.com/privacyidea/privacyidea/assets/95854213/125ad326-296a-4c5e-a906-56b9fec34342)




Discussion:
@sepp67  How do you policies look like? Why would you verify-enroll a TAN list?
Verify-ernoll is not implemented in tantoken.

While Paper and TAN inherits functionality from HOTP, each token class itself defines, whether it can do verify-enrollment like this:

https://github.com/privacyidea/privacyidea/blob/8ed64eddefdc126062d7a3e5e7e01c032d2a3f0f/privacyidea/lib/tokens/hotptoken.py#L96

In case of paper and TAN the `cls.can_verify_enrollment` is `False` by default.
It might be as easy as setting these tokens tok `True`.

@plettich Would you mind adding a test for these two tokentypes?

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
@@ -763,6 +763,8 @@ def verify_enrollment(request=None, action=None):
                 r = tokenobj.verify_enrollment(verify)
                 log.info("Result of enrollment verification for token {0!s}: {1!s}".format(serial, r))
                 if r:
+                    # TODO: we need to add the tokentype here or the second init_token() call fails
+                    request.all_data.update(type=tokenobj.get_tokentype())
                     tokenobj.token.rollout_state = ROLLOUTSTATE.ENROLLED
                 else:
                     from privacyidea.lib.error import ParameterError
diff --git a/privacyidea/lib/tokens/papertoken.py b/privacyidea/lib/tokens/papertoken.py
--- a/privacyidea/lib/tokens/papertoken.py
+++ b/privacyidea/lib/tokens/papertoken.py
@@ -132,11 +132,15 @@ def get_class_info(key=None, ret='all'):
         return ret
 
     def update(self, param, reset_failcount=True):
-        if "otpkey" not in param:
+        if "otpkey" not in param and "verify" not in param:
             param["genkey"] = 1
         HotpTokenClass.update(self, param, reset_failcount=reset_failcount)
         papertoken_count = int(param.get("papertoken_count", DEFAULT_COUNT))
         # Now we calculate all the OTP values and add them to the
-        # init_details. Thus they will be returned by token/init.
-        otps = self.get_multi_otp(count=papertoken_count)
-        self.add_init_details("otps", otps[2].get("otp", {}))
+        # init_details. Thus, they will be returned by token/init.
+        # But only if this is not in verify state.
+        # TODO: check if can finish the request after verifying the token in the
+        #  prepolicy. Thus we would not need to go through all the token updates
+        if "verify" not in param:
+            otps = self.get_multi_otp(count=papertoken_count)
+            self.add_init_details("otps", otps[2].get("otp", {}))


Test patch:
diff --git a/tests/test_api_token.py b/tests/test_api_token.py
--- a/tests/test_api_token.py
+++ b/tests/test_api_token.py
@@ -2759,7 +2759,149 @@ def test_43_init_verify_index_token(self):
 
         delete_policy("verify_toks1")
 
-    def test_44_init_token_with_required_description(self):
+    def test_44_init_verify_paper_token(self):
+        set_policy("verify_paper_toks", scope=SCOPE.ENROLL,
+                   action="{0!s}=paper".format(ACTION.VERIFY_ENROLLMENT))
+        # Enroll a PAPER token
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={
+                                               "otpkey": self.otpkey,
+                                               "type": "paper"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"), result)
+            self.assertTrue(result.get("value"), result)
+            self.assertEqual(detail.get("rollout_state"), ROLLOUTSTATE.VERIFYPENDING, detail)
+            self.assertEqual(detail.get("verify").get("message"), VERIFY_ENROLLMENT_MESSAGE, detail)
+            otps = detail.get("otps")
+            serial = detail.get("serial")
+            tokenobj_list = get_tokens(serial=serial)
+            # Check the token rollout state
+            self.assertEqual(tokenobj_list[0].token.rollout_state, ROLLOUTSTATE.VERIFYPENDING)
+
+        # Try to authenticate with this not readily enrolled token and fail
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={'serial': serial,
+                                                 'pass': otps['0']}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            # we fail to authenticate with a token, that is in state verify_pending
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertFalse(result.get("value"), result)
+            self.assertEqual(detail.get("message"),
+                             "matching 1 tokens, Token is not yet enrolled", detail)
+
+        # Now run the second step: verify enrollment, but fail with a wrong OTP value
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "verify": "111111"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(400, res.status_code)
+            result = res.json.get("result")
+            self.assertFalse(result.get("status"), result)
+            self.assertEqual(result.get("error").get("code"), 905, result)
+            self.assertEqual(result.get("error").get("message"),
+                             "ERR905: Verification of the new token failed.", result)
+
+        # Now run the second step: verify enrollment and test again
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "verify": otps['1']},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertTrue(result.get("status"), result)
+            self.assertTrue(result.get("value"), result)
+            tokenobj_list = get_tokens(serial=serial)
+            # Check the token rollout state, it is empty now.
+            self.assertEqual(ROLLOUTSTATE.ENROLLED, tokenobj_list[0].token.rollout_state)
+
+        delete_policy("verify_paper_toks")
+
+    def test_45_init_verify_tan_token(self):
+        set_policy("verify_tan_toks", scope=SCOPE.ENROLL,
+                   action="{0!s}=tan".format(ACTION.VERIFY_ENROLLMENT))
+        # Enroll a PAPER token
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={
+                                               "otpkey": self.otpkey,
+                                               "type": "tan"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"), result)
+            self.assertTrue(result.get("value"), result)
+            self.assertEqual(detail.get("rollout_state"), ROLLOUTSTATE.VERIFYPENDING, detail)
+            self.assertEqual(detail.get("verify").get("message"), VERIFY_ENROLLMENT_MESSAGE, detail)
+            otps = detail.get("otps")
+            serial = detail.get("serial")
+            tokenobj_list = get_tokens(serial=serial)
+            # Check the token rollout state
+            self.assertEqual(tokenobj_list[0].token.rollout_state, ROLLOUTSTATE.VERIFYPENDING)
+
+        # Try to authenticate with this not readily enrolled token and fail
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={'serial': serial,
+                                                 'pass': otps['0']}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            # we fail to authenticate with a token, that is in state verify_pending
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertFalse(result.get("value"), result)
+            self.assertEqual(detail.get("message"),
+                             "matching 1 tokens, Token is not yet enrolled", detail)
+
+        # Now run the second step: verify enrollment, but fail with a wrong OTP value
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "verify": "111111"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(400, res.status_code)
+            result = res.json.get("result")
+            self.assertFalse(result.get("status"), result)
+            self.assertEqual(result.get("error").get("code"), 905, result)
+            self.assertEqual(result.get("error").get("message"),
+                             "ERR905: Verification of the new token failed.", result)
+
+        # Now run the second step: verify enrollment and test again
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "verify": otps['1']},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertTrue(result.get("status"), result)
+            self.assertTrue(result.get("value"), result)
+            tokenobj_list = get_tokens(serial=serial)
+            # Check the token rollout state, it is empty now.
+            self.assertEqual(ROLLOUTSTATE.ENROLLED, tokenobj_list[0].token.rollout_state)
+
+        delete_policy("verify_tan_toks")
+
+    # TODO: Add verify enrollment tests for DayPassword, TOTP and Daplug Token
+
+    def test_50_init_token_with_required_description(self):
         # set require_description policy with value = 'hotp'
         set_policy(name="require_description",
                    scope=SCOPE.ENROLL,
@@ -3454,4 +3596,3 @@ def test_02_non_existing_groups(self):
             self.assertEqual(result.get("error").get("message"), "The tokengroup does not exist.")
 
         remove_token(serial)
-