Repository: privacyidea/privacyidea

Issue:
failcounter in challenge response is not increased
When authenticating with a token in challenge-response mode the failcounter when presenting a wrong OTP value is not increased.

If there is a single transaction_id we can determine the serial number, add the serial number to the audit log and also increase the failcounter of this very token.

If the transaction_id belongs to several tokens, then all failcounters should be increased.
No serial would be written to the audit log in this case.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2200,10 +2200,12 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
     elif challenge_response_token_list:
         # The RESPONSE for a previous request of a challenge response token was
         # found.
+        matching_challenge = False
         for tokenobject in challenge_response_token_list:
             if tokenobject.check_challenge_response(passw=passw,
                                                     options=options) >= 0:
                 reply_dict["serial"] = tokenobject.token.serial
+                matching_challenge = True
                 if tokenobject.is_active():
                     # OTP matches
                     res = True
@@ -2227,6 +2229,19 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_res
                     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
+            for token_obj in challenge_response_token_list:
+                token_obj.inc_failcount()
+            if not matching_challenge:
+                if len(challenge_response_token_list) == 1:
+                    reply_dict["serial"] = challenge_response_token_list[0].token.serial
+                    reply_dict["type"] = challenge_response_token_list[0].token.tokentype
+                    reply_dict["message"] = "Response did not match the challenge."
+                else:
+                    reply_dict["message"] = "Response did not match for " \
+                                            "{0!s} tokens.".format(len(challenge_response_token_list))
 
     elif challenge_request_token_list:
         # This is the initial REQUEST of a challenge response 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
@@ -11,7 +11,7 @@
                                     delete_privacyidea_config)
 from privacyidea.lib.token import (get_tokens, init_token, remove_token,
                                    reset_token, enable_token, revoke_token,
-                                   set_pin)
+                                   set_pin, get_one_token)
 from privacyidea.lib.policy import SCOPE, ACTION, set_policy, delete_policy
 from privacyidea.lib.event import set_event
 from privacyidea.lib.event import delete_event
@@ -2936,6 +2936,82 @@ def test_08_challenge_text(self):
         delete_policy("pol_footer")
         delete_policy("pol_hotp")
 
+    def test_09_challenge_response_inc_failcounter(self):
+        # make HOTP a challenge response token
+        set_policy(name="pol_hotp",
+                   scope=SCOPE.AUTH,
+                   action="{0!s}=hotp".format(ACTION.CHALLENGERESPONSE))
+        init_token({"serial": "tok1",
+                    "otpkey": self.otpkey,
+                    "pin": "pin"}, user=User("cornelius", self.realm1))
+
+        # On token fails to challenge response
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            transaction_id = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "wrongOTP",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual("tok1", detail.get("serial"))
+            self.assertEqual("hotp", detail.get("type"))
+            self.assertEqual("Response did not match the challenge.", detail.get("message"))
+
+        init_token({"serial": "tok2",
+                    "otpkey": self.otpkey,
+                    "pin": "pin"}, user=User("cornelius", self.realm1))
+
+        # Now, two tokens will not match
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            transaction_id = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "wrongOTP",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertTrue("serial" not in detail)
+            self.assertEqual("Response did not match for 2 tokens.", detail.get("message"))
+
+        # Now check the fail counters of the tokens
+        self.assertEqual(2, get_one_token(serial="tok1").token.failcount)
+        self.assertEqual(1, get_one_token(serial="tok2").token.failcount)
+
+        remove_token("tok1")
+        remove_token("tok2")
+        delete_policy("pol_hotp")
+
 
 class TriggeredPoliciesTestCase(MyApiTestCase):