Repository: privacyidea/privacyidea

Issue:
Multi-Challenge: Resync Token
We could use multi-challenge to resync a token, which is out of sync.

The "problem" is the following:

If the user enters a wrong OTP value, we do not know, if this is a wrong OTP value by a legitimate user with an out-of-sync-token or if it is a guessed value by an attacker.

There are two approaches:

**a)** We need to calculate the next 1000 values to see, if this is a valid OTP value in this time windows. Only if we find a future OTP value, we can ask for a 2nd OTP value.

* We calculate up to 1000 OTP values on each first request.
* An attacker would know, if he guessed the a correct on-of-a-1000 values.

**b)** We could simply **assume** that the wrong OTP value is an OTP value from an out of sync token and immediately ask for the next OTP value.

* We calculage up to 1000 OTP values on each 2nd request.
* We very often would ask for a 2nd OTP value, each time either the user mis-types a value or an attacker mis-guesses a value.

Both scenarios do not look that opimal.


Discussion:
I think a mistyping is more probable, so In b), we could also ask the user if he/she mistyped the OTP like:

"The given OTP 123456 is not valid. In case you mistyped the OTP, please type it correctly. In case the given OTP is correct, please type the next one to resync your token."

To prevent an attacker to DDOS, we may add a logic to count resync attempts and disable tokens after a number of attempts or something the like...
If autoresync is enabled, we can send whatever challenge text: "....please try the next OTP value...".

This could simply be a new authentication request. (or we need to omit the PIN, if the first auth request was PIN+OTP).

Add a new decorator in `lib/challengeresponsedecorators.py` named like `generic_challenge_response_resync_token`.

If there returned token actually has a tokeninfo `otp1c` set (which indicates, that the first OTP value matched in the sync window), then we can set the challenge mode in the response and ask for the second OTP value.

# policy

We might want to add an additional policy to actually "resync_via_validate", similar to "change_pin_via_validate". Hm, the better naming would have been "resync_via_multichallenge", since the resync with autoresync is also done via validate...
But doing resync via multichallenge opens up some information disclosure, since an attacker would actually know that the first OTP value was correct - somewhere in the syncwindo, which could also happen by accident. 
(Guessing the first correct OTP value in the syncwindow would be a chance of 1:1000.)

Resolving patch:
diff --git a/privacyidea/lib/challengeresponsedecorators.py b/privacyidea/lib/challengeresponsedecorators.py
--- a/privacyidea/lib/challengeresponsedecorators.py
+++ b/privacyidea/lib/challengeresponsedecorators.py
@@ -45,13 +45,15 @@
 
 class CHALLENGE_TYPE(object):
     PIN_RESET = "generic_pin_reset"
+    RESYNC = "generic_resync"
 
 
-def _create_pin_reset_challenge(token_obj, message, challenge_data=None):
+def _create_challenge(token_obj, challenge_type, message, challenge_data=None):
     validity = int(get_from_config('DefaultChallengeValidityTime', 120))
-    validity = int(get_from_config('PinResetChallengeValidityTime', validity))
+    if challenge_type == CHALLENGE_TYPE.PIN_RESET:
+        validity = int(get_from_config('PinResetChallengeValidityTime', validity))
     db_challenge = Challenge(token_obj.token.serial,
-                             challenge=CHALLENGE_TYPE.PIN_RESET,
+                             challenge=challenge_type,
                              data=challenge_data,
                              validitytime=validity)
     db_challenge.save()
@@ -143,8 +145,9 @@ def generic_challenge_response_reset_pin(wrapped_function, *args, **kwds):
                     # We need to ask for a 2nd time
                     challenge.set_otp_status(True)
                     seed = get_rand_digit_str(SEED_LENGTH)
-                    reply_dict = _create_pin_reset_challenge(token_obj, _("Please enter the new PIN again"),
-                                                             pass_hash(args[1]))
+                    reply_dict = _create_challenge(token_obj, CHALLENGE_TYPE.PIN_RESET,
+                                                   _("Please enter the new PIN again"),
+                                                   pass_hash(args[1]))
                     return False, reply_dict
 
     success, reply_dict = wrapped_function(*args, **kwds)
@@ -157,7 +160,44 @@ def generic_challenge_response_reset_pin(wrapped_function, *args, **kwds):
         # The tokenlist can contain more than one token. So we get the matching token object
         token_obj = next(t for t in args[0] if t.token.serial == serial)
         if g and Match.token(g, scope=SCOPE.AUTH, action=ACTION.CHANGE_PIN_VIA_VALIDATE, token_obj=token_obj).any():
-            reply_dict = _create_pin_reset_challenge(token_obj, _("Please enter a new PIN"))
+            reply_dict = _create_challenge(token_obj, CHALLENGE_TYPE.PIN_RESET, _("Please enter a new PIN"))
             return False, reply_dict
 
     return success, reply_dict
+
+
+def generic_challenge_response_resync(wrapped_function, *args, **kwds):
+    """
+    Check if the authentication request results in an autosync
+
+    Conditions: To do so we check for "otp1c" in the tokeninfo data.
+
+    Policies: A policy defines that the token resync should be allowed this way.
+    Note: The general config "autoresync" needs to be set anyways.
+
+    args are:
+    :param tokenobject_list: The list of all the tokens of the user, that will be checked
+    :param passw: The password presented in the authentication.
+
+    kwds are:
+    :param options: options dictionary containing g
+    :param user: The user_obj
+    """
+    success, reply_dict = wrapped_function(*args, **kwds)
+
+    options = kwds.get("options") or {}
+    # After a failed authentication, we check if the token has an otp1c
+    if not success and reply_dict.get("serial"):
+        # FIXME: Only works if the one token has a unique PIN
+        serial = reply_dict.get("serial")
+        g = options.get("g")
+        # The tokenlist can contain more than one token. So we get the matching token object
+        token_obj = next(t for t in args[0] if t.token.serial == serial)
+        if token_obj and token_obj.get_tokeninfo("otp1c"):
+            # We have an entry for resync
+            if g and Match.token(g, scope=SCOPE.AUTH, action=ACTION.RESYNC_VIA_MULTICHALLENGE,
+                                 token_obj=token_obj).any():
+                reply_dict = _create_challenge(token_obj, CHALLENGE_TYPE.RESYNC,
+                                               _("To resync your token, please enter the next OTP value"))
+
+    return success, reply_dict
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -343,6 +343,7 @@ class ACTION(object):
     CHANGE_PIN_FIRST_USE = "change_pin_on_first_use"
     CHANGE_PIN_EVERY = "change_pin_every"
     CHANGE_PIN_VIA_VALIDATE = "change_pin_via_validate"
+    RESYNC_VIA_MULTICHALLENGE = "resync_via_multichallenge"
     CLIENTTYPE = "clienttype"
     REGISTERBODY = "registration_body"
     RESETALLTOKENS = "reset_all_user_tokens"
@@ -2149,6 +2150,11 @@ def get_static_policy_definitions(scope=None):
                 'desc': _("If the PIN of a token is to be changed, this will allow the user to change the "
                           "PIN during a validate/check request via challenge / response."),
             },
+            ACTION.RESYNC_VIA_MULTICHALLENGE: {
+                'type': 'bool',
+                'desc': _("The autoresync of a token can be done via a challenge response message."
+                          "You need to activate 'Automatic resync' in the general settings!"),
+            },
             ACTION.PASSTHRU: {
                 'type': 'str',
                 'value': radiusconfigs,
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -98,7 +98,8 @@
                                               auth_cache,
                                               config_lost_token,
                                               reset_all_user_tokens)
-from privacyidea.lib.challengeresponsedecorators import generic_challenge_response_reset_pin
+from privacyidea.lib.challengeresponsedecorators import (generic_challenge_response_reset_pin,
+                                                         generic_challenge_response_resync)
 from privacyidea.lib.tokenclass import DATE_FORMAT
 from privacyidea.lib.tokenclass import TOKENKIND
 from dateutil.tz import tzlocal
@@ -2141,6 +2142,7 @@ def weigh_token_type(token_obj):
 @log_with(log)
 @libpolicy(reset_all_user_tokens)
 @libpolicy(generic_challenge_response_reset_pin)
+@libpolicy(generic_challenge_response_resync)
 def check_token_list(tokenobject_list, passw, user=None, options=None, allow_reset_all_tokens=False):
     """
     this takes a list of token objects and tries to find the matching 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
@@ -1665,6 +1665,61 @@ def test_16_autoresync_hotp(self):
             self.assertEqual(result.get("value"), True)
 
         delete_privacyidea_config("AutoResync")
+        remove_token(serial)
+
+    def test_16_autoresync_hotp_via_multichallenge(self):
+        serial = "autosync1"
+        token = init_token({"serial": serial,
+                            "otpkey": self.otpkey,
+                            "pin": "async"}, User("cornelius", self.realm2))
+        set_privacyidea_config("AutoResync", True)
+        set_policy(name="mcr_resync", scope=SCOPE.AUTH, action=ACTION.RESYNC_VIA_MULTICHALLENGE)
+        token.set_sync_window(10)
+        token.set_count_window(5)
+        # counter = 8, is out of sync
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user":
+                                                    "cornelius@" + self.realm2,
+                                                 "pass": "async399871"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertEqual(detail.get('multi_challenge')[0].get("message"),
+                             u'To resync your token, please enter the next OTP value')
+            self.assertEqual(result.get("value"), False)
+            transaction_id = res.json.get("detail").get("transaction_id")
+            self.assertTrue(transaction_id)
+
+        # A false response will fail
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user":
+                                                     "cornelius@" + self.realm2,
+                                                 "transaction_id": transaction_id,
+                                                 "pass": "520111"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json.get("result")
+            self.assertFalse(result.get("value"))
+
+        # counter = 9, will be autosynced.
+        # Authentication is successful
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user":
+                                                    "cornelius@" + self.realm2,
+                                                 "transaction_id": transaction_id,
+                                                 "pass": "520489"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json.get("result")
+            self.assertEqual(result.get("value"), True)
+
+        delete_privacyidea_config("AutoResync")
+        remove_token(serial)
+        delete_policy("mcr_resync")
 
     def test_17_auth_timelimit_success(self):
         user = User("timelimituser", realm=self.realm2)