Repository: privacyidea/privacyidea

Issue:
OTP+PIN does not work offline
<!--
Thank you for filing a bug within privacyIDEA. The more information you provide, the easier it is to identify the problem!

Issues are not meant for asking questions! You're welcome to ask questions in our community forum: https://community.privacyidea.org

privacyIDEA is licensed under the AGPL and comes without warranty. If you require reliable, professional help, please consider getting either
- consultancy 
  (https://netknights.it/en/leistungen/one-time-services/)
- or the privacyIDEA Enterprise Edition 
  (https://netknights.it/en/leistungen/service-level-agreements/)
-->

### Top-level intent

Do offline with an OTP pin that is used *behind* the OTP value: OTP+PIN.

### Expected outcome

Be able to authenticate offline with OTP+PIN, 

### Actual outcome

The online authentication works out, all offline authentications fail.

### Solution

The offline data for HOTP is created by in a fixed manner concatenate PIN+OTP here:

https://github.com/privacyidea/privacyidea/blob/1eb6c8ff6b283ae75d62e5d2c2635e55276d5515/privacyidea/lib/applications/offline.py#L86

This means the user will try to authenticate with OTP+PIN and will always fail.
We will have to get the config value of "prepend PIN" and use it to either concatenate PIN+OTP or OTP+PIN.



Discussion:

Resolving patch:
diff --git a/privacyidea/lib/applications/offline.py b/privacyidea/lib/applications/offline.py
--- a/privacyidea/lib/applications/offline.py
+++ b/privacyidea/lib/applications/offline.py
@@ -29,6 +29,7 @@
 import logging
 from passlib.hash import pbkdf2_sha512
 from privacyidea.lib.token import get_tokens
+from privacyidea.lib.config import get_prepend_pin
 from privacyidea.lib.policy import TYPE
 log = logging.getLogger(__name__)
 ROUNDS = 6549
@@ -80,10 +81,12 @@ def get_offline_otps(token_obj, otppin, amount, rounds=ROUNDS):
             raise ParameterError("Invalid refill amount: {!r}".format(amount))
         (res, err, otp_dict) = token_obj.get_multi_otp(count=amount, counter_index=True)
         otps = otp_dict.get("otp")
-        for key in otps.keys():
+        prepend_pin = get_prepend_pin()
+        for key, otp in otps.items():
             # Return the hash of OTP PIN and OTP values
+            otppw = otppin + otp if prepend_pin else otp + otppin
             otps[key] = pbkdf2_sha512.using(
-                rounds=rounds, salt_size=10).hash(otppin + otps.get(key))
+                rounds=rounds, salt_size=10).hash(otppw)
         # We do not disable the token, so if all offline OTP values
         # are used, the token can be used the authenticate online again.
         # token_obj.enable(False)


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -3914,8 +3914,7 @@ def test_06_offline_auth(self):
         self.setUp_user_realms()
         serial = "offline01"
         tokenobject = init_token({"serial": serial, "type": "hotp",
-                                  "otpkey": "3132333435363738393031"
-                                            "323334353637383930",
+                                  "otpkey": "3132333435363738393031323334353637383930",
                                   "pin": "offline",
                                   "user": "cornelius"})
 
@@ -3973,6 +3972,56 @@ def test_06_offline_auth(self):
         res = tokenobject.check_otp("295165")  # count = 100
         self.assertEqual(res, 100)
 
+    def test_06a_offline_auth_postpend_pin(self):
+        serial = "offline01"
+        # Do prepend_pin == False
+        set_privacyidea_config(SYSCONF.PREPENDPIN, False)
+        # The request with an OTP value and a PIN of a user, who has not
+        # token assigned
+        builder = EnvironBuilder(method='POST',
+                                 data={},
+                                 headers={})
+        env = builder.get_environ()
+        env["REMOTE_ADDR"] = "192.168.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        # Use: % oathtool -c 101 3132333435363738393031323334353637383930
+        # 329376
+        # Send the PIN behind the OTP value
+        req.all_data = {"user": "cornelius",
+                        "pass": "329376offline"}
+
+        res = {"jsonrpc": "2.0",
+               "result": {"status": True,
+                          "value": True},
+               "version": "privacyIDEA test",
+               "detail": {"serial": serial},
+               "id": 1}
+        resp = jsonify(res)
+        new_response = offline_info(req, resp)
+        jresult = new_response.json
+        self.assertTrue(jresult.get("result").get("value"), jresult)
+        self.assertEqual(jresult.get("detail").get("serial"), serial)
+
+        # Check the hashvalues in the offline tree
+        auth_items = jresult.get("auth_items")
+        self.assertEqual(len(auth_items), 1)
+        response = auth_items.get("offline")[0].get("response")
+        self.assertEqual(len(response), 100)
+        # Check that the token counter has now increased to 201
+        tokenobject = get_tokens(serial=serial)[0]
+        self.assertEqual(tokenobject.token.count, 201)
+        # check that we cannot authenticate with an offline value
+        self.assertTrue(pbkdf2_sha512.verify("629694offline", response.get('102')))
+        self.assertTrue(pbkdf2_sha512.verify("492354offline", response.get('199')))
+        res = tokenobject.check_otp("492354")  # count = 199
+        self.assertEqual(res, -1)
+        # check that we can authenticate online with the correct value
+        res = tokenobject.check_otp("462985")  # count = 201
+        self.assertEqual(res, 201)
+        # Revert
+        set_privacyidea_config(SYSCONF.PREPENDPIN, True)
+
     def test_07_sign_response(self):
         builder = EnvironBuilder(method='POST',
                                  data={},