Repository: privacyidea/privacyidea

Issue:
Registration token validity not working

# What did you try to do?

Using Registration token with validity period

# What outcome did you expect?

Registration token that is outside of validity period should not work.

# What outcome did you experience?

Registration token that is outside of validity period is still successfully authenticated.

# Configuration

* privacyIDEA Version: 2.23.5

* Installation method:
 from source


## more details: 

* OS: Amazon Linux

* Webserver: Nginx

* Tokendatabase: PostgreSQL


# Note:
I suspect that because in privacyidea/lib/token.py method `check_token_list` we delete the token during the `inc_count_auth_success()` before the `check_all()`, resulting in the incorrect behavior mentioned in this ticket.

```
        # One ore more successfully authenticating tokens found
        # We need to return success
        message_list = ["matching {0:d} tokens".format(len(valid_token_list))]
        # write serial numbers or something to audit log
        for token_obj in valid_token_list:
            if increase_auth_counters:
                token_obj.inc_count_auth_success()
            # Check if the max auth is succeeded
            if token_obj.check_all(message_list):
```

I think this will be a security issue, and would like to ask if we can enhance the logic? If not maybe I can use event handler to handle this issue?

Best regards,

Quynh Nguyen
Registration token validity not working

# What did you try to do?

Using Registration token with validity period

# What outcome did you expect?

Registration token that is outside of validity period should not work.

# What outcome did you experience?

Registration token that is outside of validity period is still successfully authenticated.

# Configuration

* privacyIDEA Version: 2.23.5

* Installation method:
 from source


## more details: 

* OS: Amazon Linux

* Webserver: Nginx

* Tokendatabase: PostgreSQL


# Note:
I suspect that because in privacyidea/lib/token.py method `check_token_list` we delete the token during the `inc_count_auth_success()` before the `check_all()`, resulting in the incorrect behavior mentioned in this ticket.

```
        # One ore more successfully authenticating tokens found
        # We need to return success
        message_list = ["matching {0:d} tokens".format(len(valid_token_list))]
        # write serial numbers or something to audit log
        for token_obj in valid_token_list:
            if increase_auth_counters:
                token_obj.inc_count_auth_success()
            # Check if the max auth is succeeded
            if token_obj.check_all(message_list):
```

I think this will be a security issue, and would like to ask if we can enhance the logic? If not maybe I can use event handler to handle this issue?

Best regards,

Quynh Nguyen


Discussion:
Hi Quynh, thank you for the bug report! I reproduced this issue locally, and your analysis is correct: 

In order to check if a token is still valid, ``check_all()`` fetches the token's token info from the database and looks for the ``validity_period_end`` info. But because ``inc_count_auth_success()`` has already deleted the registration token, the resulting token info is empty, so the validity period is not checked. 

I would consider this a bug, but I'm not yet sure what would be the cleanest way to fix it.

I think this issue affects the validity period checks and the ``count_auth_max`` check. As far as I can see, authentication does fail for inactive tokens and for tokens whose failcounter has been exceeded, because both information are cached locally even though the database object has been deleted in the meantime. 

Since WebUI logins are handled by the ``/auth`` endpoint which currently misses an event handler (this will be fixed by #1567), I don't think we can mitigate this issue with an event handler. :-/

But it can be partially mitigated using the token janitor: One could periodically disable all active registration tokens whose ``validity_period_end`` lies in the past:

```shell
privacyidea-token-janitor find --tokentype registration --tokeninfo-key validity_period_end --tokeninfo-value-before $(date -Isecond) --active True --action disable
```

... or even delete them by changing ``--action disable`` to ``--action delete``.
The problem is that we have a conflicting logic.

The current implementation works this way:

1. The token is checked if it authenticates successfully.
2. The inc_count_auth_success() is called.
3. Then the validity of the token is checked.

This makes absoluetly sense e.g. for HOTP tokens. Imaginge an HOTP token, that is currently invalid. But a valid OTP value was used for authentication. Then this very used OTP value needs to be invalidated, which is done by the *authentication* and the *inc_count_auth_success*.
Otherwise an attacker could reuse this OTP value *later*, when the token is activated again.
THis is the reason, the disablement and the validity period is checked at the end! We should not change this.

However, I think the registration token is deleted "to early". So maybe an alternative would be to delete the registration token later.
So we should not delete the token here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2164
but somewhere here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2175
Hi Quynh, thank you for the bug report! I reproduced this issue locally, and your analysis is correct: 

In order to check if a token is still valid, ``check_all()`` fetches the token's token info from the database and looks for the ``validity_period_end`` info. But because ``inc_count_auth_success()`` has already deleted the registration token, the resulting token info is empty, so the validity period is not checked. 

I would consider this a bug, but I'm not yet sure what would be the cleanest way to fix it.

I think this issue affects the validity period checks and the ``count_auth_max`` check. As far as I can see, authentication does fail for inactive tokens and for tokens whose failcounter has been exceeded, because both information are cached locally even though the database object has been deleted in the meantime. 

Since WebUI logins are handled by the ``/auth`` endpoint which currently misses an event handler (this will be fixed by #1567), I don't think we can mitigate this issue with an event handler. :-/

But it can be partially mitigated using the token janitor: One could periodically disable all active registration tokens whose ``validity_period_end`` lies in the past:

```shell
privacyidea-token-janitor find --tokentype registration --tokeninfo-key validity_period_end --tokeninfo-value-before $(date -Isecond) --active True --action disable
```

... or even delete them by changing ``--action disable`` to ``--action delete``.
The problem is that we have a conflicting logic.

The current implementation works this way:

1. The token is checked if it authenticates successfully.
2. The inc_count_auth_success() is called.
3. Then the validity of the token is checked.

This makes absoluetly sense e.g. for HOTP tokens. Imaginge an HOTP token, that is currently invalid. But a valid OTP value was used for authentication. Then this very used OTP value needs to be invalidated, which is done by the *authentication* and the *inc_count_auth_success*.
Otherwise an attacker could reuse this OTP value *later*, when the token is activated again.
THis is the reason, the disablement and the validity period is checked at the end! We should not change this.

However, I think the registration token is deleted "to early". So maybe an alternative would be to delete the registration token later.
So we should not delete the token here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2164
but somewhere here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2175

Resolving patch:
diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py
--- a/privacyidea/lib/config.py
+++ b/privacyidea/lib/config.py
@@ -212,6 +212,7 @@ class SYSCONF(object):
     SPLITATSIGN = "splitAtSign"
     INCFAILCOUNTER = "IncFailCountOnFalsePin"
     RETURNSAML = "ReturnSamlAttributes"
+    RESET_FAILCOUNTER_ON_PIN_ONLY = "ResetFailcounterOnPIN"
 
 
 #@cache.cached(key_prefix="allConfig")
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -82,7 +82,7 @@
                                 MachineToken, TokenInfo, TokenOwner)
 from privacyidea.lib.config import (get_token_class, get_token_prefix,
                                     get_token_types, get_from_config,
-                                    get_inc_fail_count_on_false_pin)
+                                    get_inc_fail_count_on_false_pin, SYSCONF)
 from privacyidea.lib.user import User
 from privacyidea.lib import _
 from privacyidea.lib.realm import realm_is_defined
@@ -2160,12 +2160,14 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
         message_list = ["matching {0:d} tokens".format(len(valid_token_list))]
         # write serial numbers or something to audit log
         for token_obj in valid_token_list:
-            if increase_auth_counters:
-                token_obj.inc_count_auth_success()
             # Reset the failcounter, if there is a timeout set
             token_obj.check_reset_failcount()
-            # Check if the max auth is succeeded
+            # Check if the max auth is succeeded.
+            # We need to set the offsets, since we are in the n+1st authentication.
             if token_obj.check_all(message_list):
+                if increase_auth_counters:
+                    token_obj.inc_count_auth_success()
+
                 # The token is active and the auth counters are ok.
                 res = True
                 if not reply_dict.get("type"):
@@ -2262,7 +2264,8 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
         # So we increase the failcounter. Return failure.
         for tokenobject in pin_matching_token_list:
             tokenobject.inc_failcount()
-            tokenobject.check_reset_failcount()
+            if get_from_config(SYSCONF.RESET_FAILCOUNTER_ON_PIN_ONLY, False, return_bool=True):
+                tokenobject.check_reset_failcount()
             reply_dict["message"] = "wrong otp value"
             if len(pin_matching_token_list) == 1:
                 # If there is only one pin matching token, we look if it was
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -1058,20 +1058,20 @@ def check_failcount(self):
     def check_auth_counter(self):
         """
         This function checks the count_auth and the count_auth_success.
-        If the count_auth is less than count_auth_max
-        and count_auth_success is less than count_auth_success_max
+        If the counters are less or equal than the maximum allowed counters
         it returns True. Otherwise False.
         
         :return: success if the counter is less than max
         :rtype: bool
         """
-        if self.get_count_auth_max() != 0 and self.get_count_auth() >= \
-                self.get_count_auth_max():
+        count_auth = self.get_count_auth()
+        count_auth_max = self.get_count_auth_max()
+        count_auth_success = self.get_count_auth_success()
+        count_auth_success_max = self.get_count_auth_success_max()
+        if count_auth_max != 0 and count_auth >= count_auth_max:
             return False
 
-        if self.get_count_auth_success_max() != 0 and  \
-                        self.get_count_auth_success() >=  \
-                        self.get_count_auth_success_max():
+        if count_auth_success_max != 0 and count_auth_success >= count_auth_success_max:
             return False
 
         return True


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
@@ -21,6 +21,7 @@
 from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
 from privacyidea.lib import _
 
+import datetime
 import time
 import responses
 from . import smtpmock, ldap3mock
@@ -877,7 +878,7 @@ def test_07_authentication_counter_exceeded(self):
                                 "type": "spass"})
         token_obj.set_count_auth_max(5)
 
-        for i in range(1, 5):
+        for i in range(0, 5):
             with self.app.test_request_context('/validate/check',
                                                method='POST',
                                                data={"serial": "pass1",
@@ -2407,6 +2408,35 @@ def test_33_auth_cache(self):
             self.assertFalse(result.get("value"))
 
 
+class RegistrationValidity(MyApiTestCase):
+
+    def setUp(self):
+        super(RegistrationValidity, self).setUp()
+        self.setUp_user_realms()
+
+    def test_00_registrationtoken_with_validity_period(self):
+        r = init_token({"type": "registration"},
+                       user=User("cornelius", self.realm1))
+        password = r.init_details.get("otpkey")
+
+        # The enddate is 17 minutes in the past
+        end_date = datetime.datetime.now() - datetime.timedelta(minutes=17)
+        end_date_str = end_date.strftime("%Y-%m-%dT%H:%M%z")
+        r.set_validity_period_end(end_date_str)
+        # now check if authentication fails
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": password}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data.decode('utf8'))
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual("matching 1 tokens, Outside validity period", detail.get("message"))
+
+
 class AChallengeResponse(MyApiTestCase):
 
     serial = "hotp1"
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -15,10 +15,10 @@
 """
 from .base import MyTestCase, FakeAudit
 from privacyidea.lib.user import (User)
-from privacyidea.lib.tokenclass import TokenClass, TOKENKIND
+from privacyidea.lib.tokenclass import TokenClass, TOKENKIND, FAILCOUNTER_EXCEEDED, FAILCOUNTER_CLEAR_TIMEOUT
 from privacyidea.lib.tokens.totptoken import TotpTokenClass
 from privacyidea.models import (Token, Challenge, TokenRealm)
-from privacyidea.lib.config import (set_privacyidea_config, get_token_types)
+from privacyidea.lib.config import (set_privacyidea_config, get_token_types, delete_privacyidea_config, SYSCONF)
 from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy
 from privacyidea.lib.utils import b32encode_and_unicode
 import datetime
@@ -1617,3 +1617,87 @@ def test_04_reset_all_failcounters(self):
         # Clean up
         remove_token(pin1)
         remove_token(pin2)
+
+    def test_05_reset_failcounter(self):
+        tok = init_token({"type": "hotp",
+                          "serial": "test05",
+                          "otpkey": self.otpkey})
+        # Set failcounter clear timeout to 1 minute
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 1)
+        tok.token.count = 10
+        tok.set_pin("hotppin")
+        tok.set_failcount(10)
+        exceeded_timestamp = datetime.datetime.now(tzlocal()) - datetime.timedelta(minutes=1)
+        tok.add_tokeninfo(FAILCOUNTER_EXCEEDED, exceeded_timestamp.strftime(DATE_FORMAT))
+
+        # OTP value #11
+        res, reply = check_token_list([tok], "hotppin481090")
+        self.assertTrue(res)
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 0)
+        remove_token("test05")
+
+    def test_06_reset_failcounter_out_of_sync(self):
+        # Reset fail counter of a token that is out of sync
+        # The fail counter will reset, even if the token is out of sync, since the
+        # autoresync is handled in the tokenclass.authenticate.
+        tok = init_token({"type": "hotp",
+                          "serial": "test06",
+                          "otpkey": self.otpkey})
+
+        set_privacyidea_config("AutoResyncTimeout", "300")
+        set_privacyidea_config("AutoResync", 1)
+
+        tok.set_pin("hotppin")
+        tok.set_count_window(2)
+
+        res, reply = check_token_list([tok], "hotppin{0!s}".format(self.valid_otp_values[0]))
+        self.assertTrue(res)
+
+        # Now we set the failoucnter and the exceeded time.
+        tok.set_failcount(10)
+        exceeded_timestamp = datetime.datetime.now(tzlocal()) - datetime.timedelta(minutes=1)
+        tok.add_tokeninfo(FAILCOUNTER_EXCEEDED, exceeded_timestamp.strftime(DATE_FORMAT))
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 1)
+
+        # authentication with otp value #3 will fail
+        res, reply = check_token_list([tok], "hotppin{0!s}".format(self.valid_otp_values[3]))
+        self.assertFalse(res)
+
+        # authentication with otp value #4 will resync and succeed
+        res, reply = check_token_list([tok], "hotppin{0!s}".format(self.valid_otp_values[4]))
+        self.assertTrue(res)
+        self.assertEqual(tok.get_failcount(), 0)
+
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 0)
+        delete_privacyidea_config("AutoResyncTimeout")
+        delete_privacyidea_config("AutoResync")
+        remove_token("test06")
+
+    def test_07_reset_failcounter_on_pin_only(self):
+        tok = init_token({"type": "hotp",
+                          "serial": "test07",
+                          "otpkey": self.otpkey})
+        # Set failcounter clear timeout to 1 minute
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 1)
+        tok.token.count = 10
+        tok.set_pin("hotppin")
+        tok.set_failcount(10)
+        exceeded_timestamp = datetime.datetime.now(tzlocal()) - datetime.timedelta(minutes=1)
+        tok.add_tokeninfo(FAILCOUNTER_EXCEEDED, exceeded_timestamp.strftime(DATE_FORMAT))
+
+        # by default, correct PIN + wrong OTP value does not reset the failcounter
+        res, reply = check_token_list([tok], "hotppin123456")
+        self.assertEqual(tok.get_failcount(), 10)
+        self.assertFalse(res)
+        # with the corresponding config option ...
+        set_privacyidea_config(SYSCONF.RESET_FAILCOUNTER_ON_PIN_ONLY, "True")
+        # ... correct PIN + wrong OTP resets the failcounter ...
+        res, reply = check_token_list([tok], "hotppin123456")
+        self.assertEqual(tok.get_failcount(), 0)
+        # ... but authentication still fails
+        self.assertFalse(res)
+
+        set_privacyidea_config(FAILCOUNTER_CLEAR_TIMEOUT, 0)
+        delete_privacyidea_config(SYSCONF.RESET_FAILCOUNTER_ON_PIN_ONLY)
+        remove_token("test07")
+