Repository: privacyidea/privacyidea

Issue:
2step enrollment: Second step overwrites custom token parameters
In the first step, the user may pass custom parameters, e.g. the hashlib or the TOTP time window.
As the second step does not re-send all of these parameters, the custom values stored in the database are overwritten with default values.
Example:
```
$ http POST 'http://localhost:5000/token/init' authorization:$TOKEN type=hotp genkey=1 2stepinit=1 hashlib=sha512                                     
$ http POST 'http://localhost:5000/token/init' authorization:$TOKEN type=hotp serial=OATH00012C69 otpkeyformat=base32check otpkey=C75Z4234AFEJ7UNT6BPQ
$ http GET 'http://localhost:5000/token/' authorization:$TOKEN serial=OATH00012C69
```
gives:
```
...
                    "info": {
...
                        "hashlib": "sha1"
                    },
...
```
which causes authentication to fail, because the app uses sha512 and the server uses sha1.
2step enrollment: Second step overwrites custom token parameters
In the first step, the user may pass custom parameters, e.g. the hashlib or the TOTP time window.
As the second step does not re-send all of these parameters, the custom values stored in the database are overwritten with default values.
Example:
```
$ http POST 'http://localhost:5000/token/init' authorization:$TOKEN type=hotp genkey=1 2stepinit=1 hashlib=sha512                                     
$ http POST 'http://localhost:5000/token/init' authorization:$TOKEN type=hotp serial=OATH00012C69 otpkeyformat=base32check otpkey=C75Z4234AFEJ7UNT6BPQ
$ http GET 'http://localhost:5000/token/' authorization:$TOKEN serial=OATH00012C69
```
gives:
```
...
                    "info": {
...
                        "hashlib": "sha1"
                    },
...
```
which causes authentication to fail, because the app uses sha512 and the server uses sha1.


Discussion:
... or does the server work as expected and the WebUI should actually re-send all parameters in the second step?
... or does the server work as expected and the WebUI should actually re-send all parameters in the second step?

Resolving patch:
diff --git a/privacyidea/lib/tokens/hotptoken.py b/privacyidea/lib/tokens/hotptoken.py
--- a/privacyidea/lib/tokens/hotptoken.py
+++ b/privacyidea/lib/tokens/hotptoken.py
@@ -295,7 +295,7 @@ def update(self, param, reset_failcount=True):
         if val is not None:
             hashlibStr = val
         else:
-            hashlibStr = 'sha1'
+            hashlibStr = self.hashlib
 
         # check if the key_size is provided
         # if not, we could derive it from the hashlib
@@ -315,13 +315,7 @@ def update(self, param, reset_failcount=True):
         # raised here.
         TokenClass.update(self, upd_param, reset_failcount)
 
-        # add_tokeninfo and set_otplen save the token object to the database.
         self.add_tokeninfo("hashlib", hashlibStr)
-        val = getParam(upd_param, "otplen", optional)
-        if val is not None:
-            self.set_otplen(int(val))
-        else:
-            self.set_otplen(get_from_config("DefaultOtpLen", 6))
 
     @property
     def hashlib(self):
diff --git a/privacyidea/lib/tokens/totptoken.py b/privacyidea/lib/tokens/totptoken.py
--- a/privacyidea/lib/tokens/totptoken.py
+++ b/privacyidea/lib/tokens/totptoken.py
@@ -181,18 +181,12 @@ def update(self, param, reset_failcount=True):
         """
         HotpTokenClass.update(self, param, reset_failcount=reset_failcount)
 
-        timeStep = param.get("timeStep",
-                             get_from_config("totp.timeStep") or 30)
-
-        timeWindow = param.get("timeWindow",
-                               get_from_config("totp.timeWindow") or 180)
-
-        timeShift = param.get("timeShift",
-                              get_from_config("totp.timeShift") or 0)
+        timeStep = param.get("timeStep", self.timestep)
+        timeWindow = param.get("timeWindow", self.timewindow)
+        timeShift = param.get("timeShift", self.timeshift)
         # we support various hashlib methods, but only on create
         # which is effectively set in the update
-        hashlibStr = param.get("hashlib", get_from_config("totp.hashlib",
-                                                          u'sha1'))
+        hashlibStr = param.get("hashlib", self.hashlib)
 
         self.add_tokeninfo("timeWindow", timeWindow)
         self.add_tokeninfo("timeShift", timeShift)


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
@@ -143,7 +143,7 @@ def test_01_init_token(self):
 
     def test_02_force_parameters(self):
         set_policy(
-            name="force_twostep",
+            name="force_2step",
             action=["hotp_2step=force", "enrollHOTP=1", "delete"],
             scope=SCOPE.ADMIN,
         )
@@ -255,10 +255,10 @@ def test_02_force_parameters(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
 
-        delete_policy("force_twostep")
-        delete_policy("twostep_params")
+        delete_policy("force_2step")
+        delete_policy("2step_params")
 
-    def test_02_custom_parameters(self):
+    def test_03_custom_parameters(self):
         set_policy(
             name="enrollhotp",
             action=["enrollHOTP=1", "delete", "hotp_2step=allow"],
@@ -272,7 +272,8 @@ def test_02_custom_parameters(self):
                                                  "2step_serversize": "5",
                                                  "2step_clientsize": "16",
                                                  "2step_difficulty": "17898",
-                                                 "hashlib": "sha512" # force 64-byte secret
+                                                 "hashlib": "sha512", # force 64-byte secret
+                                                 "otplen": "8",
                                                  },
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -326,7 +327,7 @@ def test_02_custom_parameters(self):
 
         # Now try to authenticate
         otpkey_bin = binascii.unhexlify(otpkey)
-        otp_value = HmacOtp().generate(key=otpkey_bin, counter=1)
+        otp_value = HmacOtp(digits=8, hashfunc=hashlib.sha512).generate(key=otpkey_bin, counter=1)
         with self.app.test_request_context('/validate/check',
                                            method='POST',
                                            data={"serial": serial,
@@ -351,9 +352,9 @@ def test_02_custom_parameters(self):
 
         delete_policy("enrollhotp")
 
-    def test_03_no_2stepinit(self):
+    def test_04_no_2stepinit(self):
         set_policy(
-            name="disallow_twostep",
+            name="disallow_2step",
             action=["enrollHOTP=1", "delete"], # no 2step policy => disallow by default
             scope=SCOPE.ADMIN,
         )
@@ -377,6 +378,7 @@ def test_03_no_2stepinit(self):
             serial = detail.get("serial")
             otpkey_url = detail.get("otpkey", {}).get("value")
             otpkey_bin = binascii.unhexlify(otpkey_url.split("/")[2])
+            self.assertEqual(detail.get("rollout_state"), "")
 
         # Now try to authenticate
         otp_value = HmacOtp().generate(key=otpkey_bin, counter=1)
@@ -390,9 +392,9 @@ def test_03_no_2stepinit(self):
             self.assertEqual(result.get("status"), True)
             self.assertEqual(result.get("value"), True)
 
-        delete_policy("disallow_twostep")
+        delete_policy("disallow_2step")
 
-    def test_04_init_totp_token(self):
+    def test_05_init_totp_token(self):
         set_policy(
             name="allow_2step",
             action=["totp_2step=allow", "enrollTOTP=1", "delete"],
@@ -511,9 +513,9 @@ def test_04_init_totp_token(self):
             self.assertTrue(res.status_code == 200, res)
         delete_policy("allow_2step")
 
-    def test_05_force_totp_parameters(self):
+    def test_06_force_totp_parameters(self):
         set_policy(
-            name="force_twostep",
+            name="force_2step",
             action=["totp_2step=force", "enrollTOTP=1", "delete"],
             scope=SCOPE.ADMIN,
         )
@@ -531,7 +533,10 @@ def test_05_force_totp_parameters(self):
                                                  "2stepinit": "0", # will be forced nevertheless
                                                  "2step_serversize": "3",
                                                  "2step_clientsize": "4",
-                                                 "2step_difficulty": "33333"
+                                                 "2step_difficulty": "33333",
+                                                 "timeStep": "60",
+                                                 "hashlib": "sha512",
+                                                 "otplen": "8",
                                                  },
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
@@ -546,9 +551,9 @@ def test_05_force_totp_parameters(self):
             google_url = detail["googleurl"]["value"]
             self.assertIn('2step_difficulty=12345', google_url)
             self.assertIn('2step_salt=11', google_url)
-            self.assertIn('2step_output=20', google_url)
+            self.assertIn('2step_output=64', google_url)
         # Authentication does not work yet!
-        wrong_otp_value = HmacOtp().generate(key=server_component, counter=int(time.time()/30))
+        wrong_otp_value = HmacOtp(digits=8, hashfunc=hashlib.sha512).generate(key=server_component, counter=int(time.time() / 60))
         with self.app.test_request_context('/validate/check',
                                            method='POST',
                                            data={"serial": serial,
@@ -602,7 +607,7 @@ def test_05_force_totp_parameters(self):
 
         # Now try to authenticate
         otpkey_bin = binascii.unhexlify(otpkey)
-        otp_value = HmacOtp().generate(key=otpkey_bin, counter=int(time.time()/30))
+        otp_value = HmacOtp(digits=8, hashfunc=hashlib.sha512).generate(key=otpkey_bin, counter=int(time.time()/60))
         with self.app.test_request_context('/validate/check',
                                            method='POST',
                                            data={"serial": serial,
@@ -616,7 +621,7 @@ def test_05_force_totp_parameters(self):
         # Check serversize
         self.assertEqual(len(server_component), 33)
         # Check that the OTP key is what we expected it to be
-        expected_secret = pbkdf2(binascii.hexlify(server_component), client_component, 12345, 20)
+        expected_secret = pbkdf2(binascii.hexlify(server_component), client_component, 12345, 64)
         self.assertEqual(otpkey_bin, expected_secret)
 
         with self.app.test_request_context('/token/'+ serial,
@@ -625,5 +630,5 @@ def test_05_force_totp_parameters(self):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
 
-        delete_policy("force_twostep")
-        delete_policy("twostep_params")
+        delete_policy("force_2step")
+        delete_policy("2step_params")
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
@@ -10,6 +10,7 @@
 from urllib import urlencode
 from privacyidea.lib.token import check_serial_pass
 from privacyidea.lib.tokenclass import DATE_FORMAT
+from privacyidea.lib.config import set_privacyidea_config, delete_privacyidea_config
 from dateutil.tz import tzlocal
 
 PWFILE = "tests/testdata/passwords"
@@ -1358,8 +1359,59 @@ def test_25_user_init_defaults(self):
             serial = detail.get("serial")
             token = get_tokens(serial=serial)[0]
             self.assertEqual(token.hashlib, "sha256")
+            self.assertEqual(token.token.otplen, 8)
 
         delete_policy("init_details")
+        remove_token(serial)
+
+        # Set OTP len using the system wide default
+        set_privacyidea_config("DefaultOtpLen", 8)
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={
+                                               "type": "totp",
+                                               "totp.hashlib": "sha1",
+                                               "hashlib": "sha1",
+                                               "genkey": 1,
+                                               "user": "selfservice",
+                                               "realm": "realm1"},
+                                           headers={'Authorization':
+                                                        self.at_user}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"))
+            detail = json.loads(res.data).get("detail")
+            serial = detail.get("serial")
+            token = get_tokens(serial=serial)[0]
+            self.assertEqual(token.token.otplen, 8)
+
+        remove_token(serial)
+
+        # override the DefaultOtpLen
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={
+                                               "type": "totp",
+                                               "otplen": 6,
+                                               "totp.hashlib": "sha1",
+                                               "hashlib": "sha1",
+                                               "genkey": 1,
+                                               "user": "selfservice",
+                                               "realm": "realm1"},
+                                           headers={'Authorization':
+                                                        self.at_user}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"))
+            detail = json.loads(res.data).get("detail")
+            serial = detail.get("serial")
+            token = get_tokens(serial=serial)[0]
+            self.assertEqual(token.token.otplen, 6)
+
+        remove_token(serial)
+        delete_privacyidea_config("DefaultOtpLen")
 
     def test_26_supply_key_size(self):
         with self.app.test_request_context('/token/init',
diff --git a/tests/test_lib_tokens_totp.py b/tests/test_lib_tokens_totp.py
--- a/tests/test_lib_tokens_totp.py
+++ b/tests/test_lib_tokens_totp.py
@@ -457,7 +457,8 @@ def test_19_pin_otp_functions(self):
         res = token.get_multi_otp()
         self.assertTrue(res[0] is False, res)
         token.update({"otpkey": self.otpkey,
-                      "otplen": 6})
+                      "otplen": 6,
+                      "timeShift": 0})
         token.token.count = 0
         res = token.get_multi_otp(count=5)
         self.assertTrue(res[0], res)