Repository: privacyidea/privacyidea

Issue:
OTP counter of offline token is set wrong
The otp counter of an offline token is set wrong.
It is set to 1 + offlinecount.

It should be set to currentcount + offlinecount.



Discussion:
Problem is the strange named function ``inc_otp_counter``.

The problem is, that the functionality is not quite consistent and does not fit the naming of the function:

* without parameter the function increases the couter by one
* with the parameter ``counter``, the counter is not increased but the counter is set to this very parameter.

I think we should make the naming of the function consistent. 
Maybe we have a function ``set_counter``, that really sets the counter to a specific value.
A function ``inc_otp_counter`` should realy **increase** the counter.

But when changing the function, we need to check the complete code for all occurrences.
I checked the occurrences and it seems like the interface with ``counter=None`` was only used in tests. Because of that, I renamed the function to ``advance_otp_counter`` and removed the without-parameter case.

I was hesitant to call it ``set_counter`` (1) because there already is a function ``set_otp_counter`` and (2) because I find it counterintuitive, as ``set_counter(count)`` actually sets the token counter to ``count + 1``.  So I went with ``advance_otp_counter``, but of course I am open to suggestions!

Alternatively, we could think about unifying ``set_otp_counter`` and ``advance_otp_counter``.

There is one edge case: ``inc_otp_counter(0)`` used to increment the counter by 1 (because ``0`` is Falsey), but ``advance_otp_counter(0)`` now sets the counter to 1. We should check that this change does not cause unwanted behavior.

And in general, we should check this change for off-by-one errors ... I already caught one :-)
I know that this counter function is counter-intuitve. 
But honestly, I also think that the name "advance" sounds a bit strange.

I think we probalby need to redo the complete counter setting to something, that is more clear. I think there are a lot of hidden conditions or logics in it.

Yes, I agree that "advance" also sounds weird. Maybe it would be better to unify ``set_counter`` and ``advance_otp_counter`` then.
Just from the naming I propably would expect ``set_counter`` to set a counter:

    def set_counter(counter = None):
          token.counter = counter

and ``inc_counter`` to increase a counter:
    
    def inc_counter(counter = 1)
          token.counter = token.counter + counter


?? 

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
@@ -145,7 +145,7 @@ def get_authentication_item(token_type,
                 # token_obj.enable(False)
                 # increase the counter by the consumed values and
                 # also store it in tokeninfo.
-                token_obj.inc_otp_counter(counter=count)
+                token_obj.inc_otp_counter(increment=count)
                 token_obj.add_tokeninfo(key="offline_counter",
                                         value=count)
                 ret["response"] = otps
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -1097,23 +1097,27 @@ def check_all(self, message_list):
 
     @log_with(log)
     @check_token_locked
-    def inc_otp_counter(self, counter=None, reset=True):
+    def inc_otp_counter(self, counter=None, increment=1, reset=True):
         """
         Increase the otp counter and store the token in the database
-        :param counter: the new counter value. If counter is given, than
-                        the counter is increased by (counter+1)
-                        If the counter is not given, the counter is increased
-                        by +1
+
+        Before increasing the token.count the token.count can be set using the
+        parameter counter.
+
+        :param counter: if given, the token counter is first set to counter and then
+                increased by increment
         :type counter: int
+        :param increment: increase the counter by this amount
+        :type increment: int
         :param reset: reset the failcounter if set to True
         :type reset: bool
         :return: the new counter value
         """
         reset_counter = False
         if counter:
-            self.token.count = counter + 1
-        else:
-            self.token.count += 1
+            self.token.count = counter
+
+        self.token.count += increment
 
         if reset is True and get_from_config("DefaultResetFailCount") == "True":
             reset_counter = True
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
@@ -493,7 +493,7 @@ def resync(self, otp1, otp2, options=None):
             return ret
 
         ret = True
-        self.inc_otp_counter(counter + 1, True)
+        self.inc_otp_counter(counter + 1, reset=True)
 
         log.debug("end. resync was successful: ret: {0!r}".format((ret)))
         return ret


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
@@ -4,6 +4,7 @@
 The api.lib.policy.py depends on lib.policy and on flask!
 """
 import json
+
 from .base import (MyTestCase, PWFILE)
 
 from privacyidea.lib.policy import (set_policy, delete_policy,
@@ -41,6 +42,7 @@
 from privacyidea.lib.machine import attach_token
 from privacyidea.lib.auth import ROLE
 import jwt
+import passlib
 from datetime import datetime, timedelta
 from dateutil.tz import tzlocal
 from privacyidea.lib.tokenclass import DATE_FORMAT
@@ -1596,7 +1598,16 @@ def test_06_offline_auth(self):
         self.assertEqual(len(response), 100)
         # check if the counter of the token was increased to 100
         tokenobject = get_tokens(serial=serial)[0]
-        self.assertEqual(tokenobject.token.count, 101)
+        self.assertEqual(tokenobject.token.count, 100)
+        # check that we cannot authenticate with an offline value
+        self.assertTrue(passlib.hash.\
+                        pbkdf2_sha512.verify("offline516516",
+                                             response.get('99')))
+        res = tokenobject.check_otp("516516") # count = 99
+        self.assertEqual(res, -1)
+        # check that we can authenticate online with the correct value
+        res = tokenobject.check_otp("295165")  # count = 100
+        self.assertEqual(res, 100)
         delete_policy("pol2")
 
     def test_07_sign_response(self):
diff --git a/tests/test_lib_applications.py b/tests/test_lib_applications.py
--- a/tests/test_lib_applications.py
+++ b/tests/test_lib_applications.py
@@ -112,17 +112,33 @@ def test_02_get_auth_item(self):
         # create ssh token
         init_token({"serial": serial, "type": "hotp", "otpkey": OTPKEY},
                    user=user)
+        # authenticate online initially
+        tok = get_tokens(serial=serial)[0]
+        res = tok.check_otp("359152") # count = 2
+        self.assertEqual(res, 2)
+        # check intermediate counter value
+        self.assertEqual(tok.token.count, 3)
 
         auth_item = OfflineApplication.get_authentication_item("hotp", serial)
         self.assertTrue(passlib.hash.\
-                        pbkdf2_sha512.verify("755224",
+                        pbkdf2_sha512.verify("969429", # count = 3
                                              auth_item.get("response").get(0)))
         self.assertTrue(passlib.hash.\
-                        pbkdf2_sha512.verify("254676",
+                        pbkdf2_sha512.verify("399871", # count = 8
                                              auth_item.get("response").get(5)))
         # After calling auth_item the token counter should be increased
-        tok = get_tokens(serial=serial)[0]
-        self.assertEqual(tok.token.count, 101)
+        # 3, because we used the otp value with count = 2 initially
+        # 100, because we obtained 100 offline OTPs
+        self.assertEqual(tok.token.count, 3 + 100)
+        # Assert that we cannot authenticate with the last offline OTP we got
+        self.assertEqual(len(auth_item.get("response")), 100)
+        self.assertTrue(passlib.hash.\
+                        pbkdf2_sha512.verify("629694", # count = 102
+                                             auth_item.get("response").get(99)))
+        res = tok.check_otp("629694") # count = 102
+        self.assertEqual(res, -1)
+        res = tok.check_otp("378717")  # count = 103
+        self.assertEqual(res, 103)
 
     def test_03_get_auth_item_unsupported(self):
         # unsupported token type