Repository: privacyidea/privacyidea

Issue:
Add verify_enrollment for indexed secret token
Similar to SMS and Email we should allow verify-enrollment for the indexed secret token.

See:
https://privacyidea.readthedocs.io/en/master/policies/enrollment.html?highlight=verify_enrollment#verify-enrollment

In the tokenclass we then need to implement `verify_enrollment` and `prepare_verify_enrollment`.

After the user has entered a secret for enrollment the first challenge should be started.

* [x] Add indexed secret token to list of allowed tokens in policy `verify_enrollment`.
* [x] implement both method
* [x] add documentation
* [x] add test


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokens/indexedsecrettoken.py b/privacyidea/lib/tokens/indexedsecrettoken.py
--- a/privacyidea/lib/tokens/indexedsecrettoken.py
+++ b/privacyidea/lib/tokens/indexedsecrettoken.py
@@ -64,6 +64,9 @@ class IndexedSecretTokenClass(TokenClass):
     positions in a shared secret.
     """
 
+    # The token type provides means to verify the enrollment
+    can_verify_enrollment = True
+
     def __init__(self, aToken):
         TokenClass.__init__(self, aToken)
         self.set_type("indexedsecret")
@@ -337,3 +340,38 @@ def has_further_challenge(self, options=None):
                     return True
 
         return False
+
+    def prepare_verify_enrollment(self):
+        """
+        This is called, if the token should be enrolled in a way, that the user
+        needs to provide a proof, that the server can verify, that the token
+        was successfully enrolled. A challenge for the indexed secret token is created
+        and the user will later have to answer this.
+
+        The returned dictionary is added to the response in "detail" -> "verify".
+
+        :return: A dictionary with information that is needed to trigger the verification.
+        """
+        _, return_message, transaction_id, reply_dict = self.create_challenge()
+        return {"message": return_message}
+
+    def verify_enrollment(self, verify):
+        """
+        This is called during the 2nd step of the verified enrollment.
+        This method verifies the actual response from the user.
+        Returns true, if the verification was successful.
+
+        :param verify: The response given by the user
+        :return: True
+        """
+        # During the enrollment one challenge has been created. The token is not fit
+        # for authentication, yet. So there should only be one challenge for this token
+        # in the challenge table. Find it!
+        chals = get_challenges(serial=self.token.serial)
+        if len(chals) != 1:  # pragma: no cover
+            log.error("Something is wrong. There is more than one challenge!")
+        transaction_id = chals[0].transaction_id
+        r = self.check_challenge_response(passw=verify,
+                                          options={"transaction_id": transaction_id})
+        log.debug("Enrollment verified: {0!s}".format(r))
+        return r >= 0
\ No newline at end of file


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
@@ -2697,6 +2697,55 @@ def test_42_init_verify_sms_token(self):
 
         delete_policy("verify_toks1")
 
+    def test_43_init_verify_index_token(self):
+        set_policy("verify_toks1", scope=SCOPE.ENROLL, action="{0!s}=indexedsecret".format(ACTION.VERIFY_ENROLLMENT))
+        # Enroll an indexed secret token
+        SECRET = "ABCDEFGHIJHK"
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"otpkey": SECRET,
+                                                 "type": "indexedsecret"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            detail = res.json.get("detail")
+            secret = detail.get("otpkey").get("value").split("/")[2]
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+            self.assertEqual(SECRET, secret)
+            self.assertEqual(detail.get("rollout_state"), ROLLOUTSTATE.VERIFYPENDING)
+            message = detail.get("verify").get("message")
+            self.assertTrue(message.startswith("Please enter the positions"))
+            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)
+            s_pos = message.strip("Please enter the positions ").strip(" from your secret.")
+            positions = [int(x) for x in s_pos.split(",")]
+
+        # Now run the second step: verify enrollment and test again
+        otp = ""
+        for x in positions:
+            otp += secret[x-1]
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "type": "indexedsecret",
+                                                 "verify": otp},
+                                           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"))
+            self.assertTrue(result.get("value"))
+            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_toks1")
+
 
 class API00TokenPerformance(MyApiTestCase):