Repository: privacyidea/privacyidea

Issue:
Redesign `is_previous_otp`
The tokenclass method `is_previous_otp` is used in the token function `check_token_list` to check if the OTP value, that did not match is an older OTP value for the given token (with a matching PIN).

This makes somehow sense for HOTP token. We need to rethink and recheck this for TOTP tokens.

What would be a previous OTP value in regards to TOTP tokens?

E.g. the last OTP value used was from 11:00am. At 1:00pm the next authentication occurs. Would a previous OTP value be one from before *now* (1:00pm) but outside the timewindows or would a previous OTP value be an OTP value from before the *last authentication* (before 11:00am)?
Or should we skip this for TOTP tokens completely, since it simply sound awkward for time base OTPs?


Discussion:
For HOTP and TOTP tokens we could define, that "previous OTP" is the OTP value, that was used for the last authentication.
(In contrast, currently for HOTP the "previous OTP" would also be n-2 and n-3...)

And this can be deduced from the current counter of the token object. Thus the check for HOTP and TOTP would be the same an very simple:

~~~~python
        otplen = int(self.token.otplen)
        secretHOtp = self.token.get_otpkey()
        counter = int(self.get_otp_count())
        hmac2Otp = HmacOtp(secretHOtp,
                           counter-1,
                           otplen,
                           self.get_hashlib(self.hashlib))
        res = hmac2Otp.checkOtp(otp, 1)
        return res+1 == counter
~~~~

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
@@ -83,6 +83,8 @@ class HotpTokenClass(TokenClass):
     hotp token class implementation
     """
 
+    previous_otp_offset = 1
+
     @staticmethod
     def get_class_type():
         """
@@ -368,6 +370,23 @@ def hashlib(self):
                      get_from_config("hotp.hashlib", u'sha1')
         return hashlibStr
 
+    def _calc_otp(self, counter):
+        """
+        Helper function to calculate the OTP value for the given counter
+        :param counter: The OTP counter
+        :return: OTP value as string
+        """
+        otplen = int(self.token.otplen)
+        secretHOtp = self.token.get_otpkey()
+        hmac2Otp = HmacOtp(secretHOtp,
+                           self.get_otp_count(),
+                           otplen,
+                           self.get_hashlib(self.hashlib))
+
+        otpval = hmac2Otp.generate(counter=counter,
+                                   inc_counter=False,
+                                   do_truncation=True)
+        return otpval
 
     @log_with(log)
     @check_token_locked
@@ -446,7 +465,7 @@ def check_otp_exist(self, otp, window=10, symetric=False, inc_counter=True):
         return res
 
     @log_with(log)
-    def is_previous_otp(self, otp, window=10):
+    def is_previous_otp(self, otp):
         """
         Check if the OTP values was previously used.
 
@@ -454,12 +473,17 @@ def is_previous_otp(self, otp, window=10):
         :param window:
         :return:
         """
-        res = False
-        r = self.check_otp_exist(otp, window=window, symetric=True,
-                                 inc_counter=False)
-        if 0 <= r < self.token.count:
-            res = True
-        return res
+        counter = int(self.get_otp_count())
+        if counter > 0:
+            previous_otp = self._calc_otp(counter - self.previous_otp_offset)
+            res = previous_otp == otp
+            if res:
+                log.info("Previous OTP used again. "
+                         "Serial {0!s} with counter {1!s}.".format(self.token.serial, counter))
+            return res
+        else:
+            # The internal counter is 0, the token was not used, yet.
+            return False
 
     @log_with(log)
     def _autosync(self, hmac2Otp, anOtpVal):
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
@@ -56,6 +56,10 @@
 
 class TotpTokenClass(HotpTokenClass):
 
+    # In contrast to the HOTP the counter does not contain the next OTP value,
+    # but the last used OTP value, so we need to set this to 0.
+    previous_otp_offset = 0
+
     @log_with(log)
     def __init__(self, db_token):
         """


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -760,6 +760,9 @@ def test_03_check_user(self):
             result = res.json.get("result")
             self.assertTrue(result.get("status") is True, result)
             self.assertTrue(result.get("value") is False, result)
+            # This is the same OTP value, so we get the "previous otp value" message
+            detail = res.json.get("detail")
+            self.assertIn("previous otp used again", detail.get("message"))
 
     def test_03a_check_user_get(self):
         # Reset the counter!
@@ -3012,6 +3015,43 @@ def test_36_authorize_by_tokeninfo_condition(self):
         remove_token("softwareToken")
         remove_token("hardwareToken")
 
+    def test_03b_check_previous_otp_with_totp(self):
+        token = init_token({"type": "totp",
+                            "serial": "totp_previous",
+                            "otpkey": self.otpkey},
+                           user=User("cornelius", self.realm1))
+        # get the OTP
+        counter = token._time2counter(time.time(), timeStepping=30)
+        otp_now = token._calc_otp(counter)
+
+        # test successful authentication
+        with self.app.test_request_context('/validate/check',
+                                           method='GET',
+                                           query_string=urlencode(
+                                                    {"user": "cornelius",
+                                                     "pass": otp_now})):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        # check the same OTP value again
+        with self.app.test_request_context('/validate/check',
+                                           method='GET',
+                                           query_string=urlencode(
+                                                    {"user": "cornelius",
+                                                     "pass": otp_now})):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            self.assertIn("previous otp used again", detail.get("message"))
+        # clean up
+        remove_token("totp_previous")
+
 
 class RegistrationValidity(MyApiTestCase):
 
diff --git a/tests/test_lib_tokens_hotp.py b/tests/test_lib_tokens_hotp.py
--- a/tests/test_lib_tokens_hotp.py
+++ b/tests/test_lib_tokens_hotp.py
@@ -341,6 +341,9 @@ def test_13_check_otp(self):
         # OTP does exist
         res = token.check_otp_exist("969429")
         self.assertTrue(res == 3, res)
+        # check is_previous_otp
+        r = token.is_previous_otp("969429")
+        self.assertTrue(r)
 
     def test_14_split_pin_pass(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
@@ -652,7 +655,7 @@ def test_25_sha256_token(self):
         token.check_otp("67062674")
 
     def test_26_is_previous_otp(self):
-        # check if the OTP was used prebiously
+        # check if the OTP was used previously
         serial = "previous"
         db_token = Token(serial, tokentype="hotp")
         db_token.save()
@@ -677,8 +680,13 @@ def test_26_is_previous_otp(self):
         """
         r = token.check_otp("969429")
         self.assertEqual(r, 3)
+        # Too old
         r = token.is_previous_otp("755224")
+        self.assertEqual(r, False)
+        # The very previous OTP, that was just used in check_otp
+        r = token.is_previous_otp("969429")
         self.assertEqual(r, True)
+        # Future value
         r = token.is_previous_otp("254676")
         self.assertEqual(r, False)
         r = token.check_otp("254676")
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
@@ -17,6 +17,7 @@
 from privacyidea.lib.config import (set_privacyidea_config, set_prepend_pin)
 import datetime
 import binascii
+import time
 
 
 class TOTPTokenTestCase(MyTestCase):
@@ -294,7 +295,7 @@ def test_13_check_otp(self):
                                                                    30})
         # Found the counter 47251647
         self.assertTrue(res == 47251647, res)
-        
+
     def test_14_split_pin_pass(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = TotpTokenClass(db_token)
@@ -727,6 +728,39 @@ def test_26_get_setting_type(self):
         r = TotpTokenClass.get_setting_type("totp.blabla")
         self.assertEqual(r, "")
 
+    def test_26_is_previous_otp(self):
+        # check if the OTP was used previously
+        serial = "previous"
+        db_token = Token(serial, tokentype="totp")
+        db_token.save()
+        token = TotpTokenClass(db_token)
+        token.set_otpkey(self.otpkey)
+        token.set_hashlib("sha1")
+        token.set_otplen(6)
+        # Authenticate with the current OTP value
+        counter = token._time2counter(time.time(), timeStepping=30)
+        otp_now = token._calc_otp(counter)
+        r = token.check_otp(otp_now, window=180)
+        self.assertEqual(r, counter)
+        # Now we try several is_previous_otp and the timeShift must stay the same!
+        ts0 = float(token.get_tokeninfo("timeShift"))
+        self.assertTrue(-181 < ts0 < 181)
+        # Too old
+        r = token.is_previous_otp(token._calc_otp(counter - 3))
+        self.assertEqual(r, False)
+        ts = float(token.get_tokeninfo("timeShift"))
+        self.assertEqual(ts, ts0)
+        # The same OTP value
+        r = token.is_previous_otp(otp_now)
+        self.assertEqual(r, True)
+        ts = float(token.get_tokeninfo("timeShift"))
+        self.assertEqual(ts, ts0)
+        # Future value
+        r = token.is_previous_otp(token._calc_otp(counter + 8))
+        self.assertEqual(r, False)
+        ts = float(token.get_tokeninfo("timeShift"))
+        self.assertEqual(ts, ts0)
+
     def test_27_get_default_settings(self):
         params = {}
         g = FakeFlaskG()