Repository: privacyidea/privacyidea

Issue:
Allow additional checks while answering challenge response
During a challenge response the user can sends possible responses to privacyIDEA. 
These get checked against the challenge table.

The tokentype should define, which checks are performed in addition to checking if the challenge matches and is still valid.

The tokenclass can define a method ``check_challenge_response()`` which would be added in this if-statement.

https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2213

Within this method a tokenclass could call the existing check_failcount or run additional checks depending on the tokentype. The challenge table also counts the response tries, so this could be checked as well.

A certificate or smartcard token in the future could also use this hook to pull CRLs...


Discussion:
Turns out we already a ``check_challenge_response()`` in TokenClass, it's used a few lines above :)
https://github.com/privacyidea/privacyidea/blob/ead5e1fda80ceee55cf1cbdcfe72d8b8e61949a3/privacyidea/lib/token.py#L2209
... but this method checks if the given response matches *any* valid challenge for the token. We could add a method ``check_challenge_response_appropriate`` which implements tokenclass-specific checks if a given challenge response should be considered "appropriate", with regard to the criteria you mentioned above?
``check_challenge_response`` simply checks the answer to the challenge on a cryptographic basis and challenge-basis. It does not yet check any token-object-specific things.

I do not understand your comment. It does need to check *any* challenge with the correct transaction_id (which is passed in the options). And it does this right ????



Resolving patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2082,7 +2082,7 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
     valid_token_list = []
 
     # Remove locked tokens from tokenobject_list
-    if len(tokenobject_list) > 1:
+    if len(tokenobject_list) > 0:
         tokenobject_list = [token for token in tokenobject_list if not token.is_revoked()]
 
         if len(tokenobject_list) == 0:
@@ -2210,8 +2210,15 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
                                                     options=options) >= 0:
                 reply_dict["serial"] = tokenobject.token.serial
                 matching_challenge = True
-                if tokenobject.is_active():
-                    # OTP matches
+                messages = []
+                if not tokenobject.is_fit_for_challenge(messages, options=options):
+                    messages.insert(0, "Challenge matches, but token is not fit for challenge")
+                    reply_dict["message"] = ". ".join(messages)
+                    log.info("Received a valid response to a "
+                             "challenge for a non-fit token {0!s}. {1!s}".format(tokenobject.token.serial,
+                                                                                 reply_dict["message"]))
+                else:
+                    # Challenge matches, token is active and token is fit for challenge
                     res = True
                     if increase_auth_counters:
                         tokenobject.inc_count_auth_success()
@@ -2228,11 +2235,7 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
                     tokenobject.reset()
                     # We have one successful authentication, so we bail out
                     break
-                else:  # pragma: no cover
-                    # usually check_challenge response would return "False" in case of inactive tokens
-                    reply_dict["message"] = "Challenge matches, but token is inactive."
-                    log.info("Received a valid response to a "
-                             "challenge for inactive token {0!s}".format(tokenobject.token.serial))
+
         if not res:
             # We did not find any successful response, so we need to increase the
             # failcounters
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -625,6 +625,23 @@ def get_otp_count(self):
     def is_active(self):
         return self.token.active
 
+    def is_fit_for_challenge(self, messages, options=None):
+        """
+        This method is called if a cryptographically matching response to a challenge was found.
+        This method may implement final checks, if there is anything that should deny the
+        success of the authentication with the response to the challenge.
+
+        The options dictionary can also contain the transaction_id, so even the
+        challenge table for this token can be used for checking.
+
+        :param options:
+        :type options: dict
+        :param messages: This is a list of messages. This method can append new information to this message list.
+        :type messages: list
+        :return: True or False
+        """
+        return self.check_all(messages)
+
     def get_failcount(self):
         return self.token.failcount
 


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
@@ -2529,7 +2529,7 @@ def test_01_challenge_response_token_deactivate(self):
             data = json.loads(res.data.decode('utf8'))
             self.assertFalse(data.get("result").get("value"))
             detail = data.get("detail")
-            self.assertEqual(detail.get("message"), "Challenge matches, but token is inactive.")
+            self.assertEqual(detail.get("message"), "Challenge matches, but token is not fit for challenge. Token is disabled")
 
         # The token is still disabled. We are checking, if we can do a challenge response
         # for a disabled token
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
@@ -1125,6 +1125,29 @@ def test_48_challenge_request_two_tokens(self):
         self.assertFalse(r)
         self.assertTrue("message" in r_dict)
         self.assertTrue("transaction_id" in r_dict)
+        transaction_id = r_dict.get("transaction_id")
+
+        # Now we try authenticate:
+        r, r_dict = check_token_list([token_a, token_b], self.valid_otp_values[1], user,
+                                     options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+
+        # New challenge
+        r, r_dict = check_token_list([token_a, token_b], pin, user)
+        self.assertTrue("transaction_id" in r_dict)
+        transaction_id = r_dict.get("transaction_id")
+
+        # Now we run a bunch of failing responses to the challenge
+        for i in range(0, 10):
+            r, r_dict = check_token_list([token_a, token_b], self.valid_otp_values[1], user,
+                                         options={"transaction_id": transaction_id})
+            self.assertFalse(r)
+        # Now we try the next value, which fails
+        r, r_dict = check_token_list([token_a, token_b], self.valid_otp_values[2], user,
+                                     options={"transaction_id": transaction_id})
+        self.assertFalse(r)
+        self.assertEqual(r_dict.get("message"), "Challenge matches, but token is not fit for challenge. Failcounter exceeded")
+
         remove_token("CR2A")
         remove_token("CR2B")
         delete_policy("test48")
diff --git a/tests/test_lib_tokenclass.py b/tests/test_lib_tokenclass.py
--- a/tests/test_lib_tokenclass.py
+++ b/tests/test_lib_tokenclass.py
@@ -163,6 +163,15 @@ def test_04_base_methods(self):
         
         token.token.maxfail = 12
         self.assertTrue(token.get_max_failcount() == 12)
+
+        # Set token to be active
+        token.token.active = True
+        # Set failcounter high
+        token.token.failcount = 13
+        r = token.is_fit_for_challenge([])
+        self.assertFalse(r)
+        # reset failcount
+        token.token.failcount = 8
         
         self.assertTrue(token.get_user_id() == token.token.owners.first().user_id)