Repository: privacyidea/privacyidea

Issue:
rollover does not work with tokens which are in clientwait state
A token rollover is an token/init request using the serial of an existing token.

In case of a token with tokeninfo "clientwait", this will fail with "ERR905: 2stepinit is only to be used in the first initialization step."

This affects hotp, totp and push tokens.


Discussion:
This is probably due to the `tokeninfo`, that contains the information that the token is in the 2nd enrollment step.
This info is not deleted/removed after the token is enrolled.

So before rolling over such a token, this `tokeninfo` has to be deleted.
Hm, I *was* actually able to rollover an HOTP token with 2step enrollment (via "allow", did not test "force", yet).
It also makes sense to me. Since while investigating for #2784 I saw in the code and checked in the database, that the `rollout_state` is reset after the token is successfully enrolled.
So I am not sure, what the problem actually is.

@laclaro can you please explain, what you are trying to do here?
(Or course you can not rollover a token, where the 2step enrollment was not finished in the first place, since the rollover starts at the beginning of the enrollment process. It is not ment to finalize an interrupted rollout process - also see #2158 - we currently do not allow/support pending enrollments).
I think what we might try to do here is, to rollover a token that is actually in "client_wait", since it was not readily enrolled.

Like: The user forgot to enter the client component and now has a not-working token. 
Thus the user goes to "rollover" to reenroll the token. 
This would actually be a scenario, which also makes sense.

In `tokenclass.py` in line [545](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/tokenclass.py#L545) we have the check 

~~~~python
        if twostep_init:
            if self.token.rollout_state == ROLLOUTSTATE.CLIENTWAIT:
                # We do not do 2stepinit in the second step
                raise ParameterError("2stepinit is only to be used in the "
                                     "first initialization step.")
~~~~
that acts as a failsafe to avoid running the first 2step step two times. 

We could either

* remove this check and allow running the 1st step two times (for rollover) or
* add a parameter for rollover and only allow the 1st step with the clientwait rolloutstate, in case of rollover.

# Add a parameter "rollover=1"

By adding such a parameter we can signal a 2step token, that it is ok, to do 2stepinit altouth the token is in rolloutstate "clientwait".

~~~~python
    rollover = getParam(param, "rollover", optional)
    if twostep_init:
        if self.token.rollout_state == ROLLOUTSTATE.CLIENTWAIT and not rollver:
            # Only raise the exception, if we do NOT do rollover
~~~~

In the [tokenEnrollControler in the UI](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/static/components/token/controllers/tokenControllers.js#L120) we need to add the rollover parameter when the "rollover"-button is pressed.



Resolving patch:
diff --git a/privacyidea/api/token.py b/privacyidea/api/token.py
--- a/privacyidea/api/token.py
+++ b/privacyidea/api/token.py
@@ -174,6 +174,8 @@ def init():
                     see :ref:`2step_enrollment`.
     :jsonparam otpkeyformat: used to supply the OTP key in alternate formats, currently
                             hex or base32check (see :ref:`2step_enrollment`)
+    :jsonparam rollover: Set this to 1 or true to indicate, that you want to rollover a token.
+                    This is mandatory to rollover tokens, that are in the clientwait state.
 
     :return: a json result with a boolean "result": true
 
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -518,6 +518,7 @@ def update(self, param, reset_failcount=True):
         :type: param: dict
         """
         tdesc = getParam(param, "description", optional)
+        rollover = getParam(param, "rollover", optional)
         if tdesc is not None:
             self.token.set_description(tdesc)
 
@@ -543,6 +544,9 @@ def update(self, param, reset_failcount=True):
             otpKey = self.decode_otpkey(otpKey, otpkeyformat)
 
         if twostep_init:
+            if is_true(rollover):
+                # We reset the rollout state
+                self.token.rollout_state = None
             if self.token.rollout_state == ROLLOUTSTATE.CLIENTWAIT:
                 # We do not do 2stepinit in the second step
                 raise ParameterError("2stepinit is only to be used in the "


Test patch:
diff --git a/tests/test_api_2stepinit.py b/tests/test_api_2stepinit.py
--- a/tests/test_api_2stepinit.py
+++ b/tests/test_api_2stepinit.py
@@ -51,11 +51,37 @@ def test_01_init_token(self):
             self.assertEqual(detail['2step_salt'], 8)
             self.assertEqual(detail['2step_output'], 20)
 
+        # Do a 2step 1st step on an already existing token using the rollover parameter will succeed
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"type": "hotp",
+                                                 "genkey": "1",
+                                                 "rollover": "1",
+                                                 "serial": serial,
+                                                 "2stepinit": "1"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status") is True, result)
+            self.assertTrue(result.get("value") is True, result)
+            detail = res.json.get("detail")
+            serial = detail.get("serial")
+            otpkey_url = detail.get("otpkey", {}).get("value")
+            server_component = binascii.unhexlify(otpkey_url.split("/")[2])
+            google_url = detail["googleurl"]["value"]
+            self.assertIn('2step_difficulty=10000', google_url)
+            self.assertIn('2step_salt=8', google_url)
+            self.assertIn('2step_output=20', google_url)
+            self.assertEqual(detail['2step_difficulty'], 10000)
+            self.assertEqual(detail['2step_salt'], 8)
+            self.assertEqual(detail['2step_output'], 20)
+
         client_component = b"VRYSECRT"
         checksum = hashlib.sha1(client_component).digest()[:4]
         base32check_client_component = b32encode_and_unicode(checksum + client_component).strip("=")
 
-        # Try to do a 2stepinit on a second step will raise an error
+        # Try to do a 2stepinit on a second step without rollover parameter will raise an error
         with self.app.test_request_context('/token/init',
                                            method='POST',
                                            data={"type": "hotp",