Repository: privacyidea/privacyidea

Issue:
add more sophisticated check in is_challenge_response
In the lib/tokenclass.py the method ``is_challenge_response`` only checks, if the request (options) contain a transaction_id to identify if this is supposed to be a response to a challenge.

We need to investigate, if we also should check, if the given transaction_id actually is a running transaction_id to this very token.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -1360,8 +1360,12 @@ def is_challenge_response(self, passw, user=None, options=None):
         """
         options = options or {}
         challenge_response = False
-        if "state" in options or "transaction_id" in options:
-            challenge_response = True
+        transaction_id = options.get("transaction_id") or options.get("state")
+        if transaction_id:
+            # Now we also need to check, if the transaction_id is an entry to the
+            # serial number of this token
+            chals = get_challenges(serial=self.token.serial, transaction_id=transaction_id)
+            challenge_response = bool(chals)
 
         return challenge_response
 


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
@@ -1054,6 +1054,128 @@ def test_11_challenge_response_hotp(self):
         # delete the token
         remove_token(serial=serial)
 
+    def test_11b_challenge_response_multiple_hotp_failcounters(self):
+        # Check behavior of Challenge-Response with multiple tokens
+        # set a chalresp policy for HOTP
+        with self.app.test_request_context('/policy/pol_chal_resp',
+                                           data={'action':
+                                                     "challenge_response=hotp",
+                                                 'scope': "authentication",
+                                                 'realm': '',
+                                                 'active': True},
+                                           method='POST',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            self.assertTrue(result["status"] is True, result)
+            self.assertEquals(result['value']['setPolicy pol_chal_resp'], 1, result)
+
+        chalresp_serials = ["CHALRESP1", "CHALRESP2"]
+        chalresp_pins = ["chalresp1", "chalresp2"]
+        tokens = []
+
+        # create two C/R tokens with different PINs for the same user
+        for serial, pin in zip(chalresp_serials, chalresp_pins):
+            # create a token and assign to the user
+            db_token = Token(serial, tokentype="hotp")
+            db_token.update_otpkey(self.otpkey)
+            db_token.save()
+            token = HotpTokenClass(db_token)
+            token.add_user(User("cornelius", self.realm1))
+            token.set_pin(pin)
+            # Set the failcounter
+            token.set_failcount(5)
+            tokens.append(token)
+
+        # create a challenge for the first token by authenticating with the OTP PIN
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": chalresp_pins[0]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            detail = json.loads(res.data.decode('utf8')).get("detail")
+            self.assertFalse(result.get("value"))
+            self.assertEqual(detail.get("message"), _("please enter otp: "))
+            transaction_id = detail.get("transaction_id")
+
+        # Failcounters are unchanged
+        self.assertEqual(tokens[0].get_failcount(), 5)
+        self.assertEqual(tokens[1].get_failcount(), 5)
+
+        # send an incorrect OTP value
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id":
+                                                     transaction_id,
+                                                 "pass": "111111"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            detail = json.loads(res.data.decode('utf8')).get("detail")
+            self.assertFalse(result.get("value"))
+
+        # Failcounter for the first token is increased
+        # Failcounter for the second token is unchanged
+        self.assertEqual(tokens[0].get_failcount(), 6)
+        self.assertEqual(tokens[1].get_failcount(), 5)
+
+        # send the correct OTP value
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id":
+                                                     transaction_id,
+                                                 "pass": "359152"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            detail = json.loads(res.data.decode('utf8')).get("detail")
+            self.assertTrue(result.get("value"))
+
+        # Failcounter for the first token is reset
+        # Failcounter for the second token is unchanged
+        self.assertEqual(tokens[0].get_failcount(), 0)
+        self.assertEqual(tokens[1].get_failcount(), 5)
+
+        # Set the same failcount for both tokens
+        tokens[0].set_failcount(5)
+
+        # trigger a challenge for both tokens
+        with self.app.test_request_context('/validate/triggerchallenge',
+                                           method='POST',
+                                           data={"user": "cornelius"},
+                                           headers={"Authorization": self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            detail = json.loads(res.data.decode('utf8')).get("detail")
+            transaction_id = detail.get("transaction_id")
+
+        # send an incorrect OTP value
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id":
+                                                     transaction_id,
+                                                 "pass": "111111"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data.decode('utf8')).get("result")
+            detail = json.loads(res.data.decode('utf8')).get("detail")
+            self.assertFalse(result.get("value"))
+
+        # Failcounter for both tokens are increased
+        self.assertEqual(tokens[0].get_failcount(), 6)
+        self.assertEqual(tokens[1].get_failcount(), 6)
+
+        # delete the tokens
+        for serial in chalresp_serials:
+            remove_token(serial=serial)
+
     def test_12_challenge_response_sms(self):
         # set a chalresp policy for SMS
         with self.app.test_request_context('/policy/pol_chal_resp',
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
@@ -528,6 +528,7 @@ def test_17_update_token(self):
     def test_18_challenges(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = TokenClass(db_token)
+        transaction_id = "123456789"
 
         db_token.set_pin("test")
         # No challenge request
@@ -548,12 +549,20 @@ def test_18_challenges(self):
         resp = token.is_challenge_response(User(login="cornelius",
                                                 realm=self.realm1),
                                             "test123456",
-                                            options={"transaction_id": "123456789"})
-        self.assertTrue(resp, resp)
+                                            options={"transaction_id": transaction_id})
+        # The token has not DB entry in the challenges table
+        self.assertFalse(resp)
 
-        # test if challenge is valid
-        C = Challenge("S123455", transaction_id="tid", challenge="Who are you?")
+        # Create a challenge
+        C = Challenge(serial=self.serial1, transaction_id=transaction_id, challenge="12")
         C.save()
+        resp = token.is_challenge_response(User(login="cornelius",
+                                                realm=self.realm1),
+                                           "test123456",
+                                           options={"transaction_id": transaction_id})
+        self.assertTrue(resp)
+
+        # test if challenge is valid
         self.assertTrue(C.is_valid())
 
     def test_19_pin_otp_functions(self):
diff --git a/tests/test_lib_tokens_daplug.py b/tests/test_lib_tokens_daplug.py
--- a/tests/test_lib_tokens_daplug.py
+++ b/tests/test_lib_tokens_daplug.py
@@ -427,16 +427,19 @@ def test_18_challenges(self):
                                                 realm=self.realm1),
                                             "test"+_digi2daplug("123456"))
         self.assertFalse(resp, resp)
+
+        transaction_id = "123456789"
+        C = Challenge(self.serial1, transaction_id=transaction_id, challenge="Who are you?")
+        C.save()
         resp = token.is_challenge_response(User(login="cornelius",
                                                 realm=self.realm1),
                                             "test"+_digi2daplug("123456"),
                                             options={"transaction_id":
-                                                         "123456789"})
+                                                         transaction_id})
         self.assertTrue(resp, resp)
 
         # test if challenge is valid
-        C = Challenge("S123455", transaction_id="tid", challenge="Who are you?")
-        C.save()
+        C.is_valid()
 
     def test_19_pin_otp_functions(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
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
@@ -431,19 +431,21 @@ def test_17_update_token(self):
     def test_18_challenges(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = HotpTokenClass(db_token)
+        transaction_id = "123456789"
         resp = token.is_challenge_response(User(login="cornelius",
                                                 realm=self.realm1),
                                             "test123456")
         self.assertFalse(resp, resp)
+
+        C = Challenge(self.serial1, transaction_id=transaction_id, challenge="Who are you?")
+        C.save()
         resp = token.is_challenge_response(User(login="cornelius",
                                                 realm=self.realm1),
                                             "test123456",
-                                            options={"transaction_id": "123456789"})
+                                            options={"transaction_id": transaction_id})
         self.assertTrue(resp, resp)
-
         # test if challenge is valid
-        C = Challenge("S123455", transaction_id="tid", challenge="Who are you?")
-        C.save()
+        self.assertTrue(C.is_valid())
 
     def test_19_pin_otp_functions(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
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
@@ -396,15 +396,18 @@ def test_18_challenges(self):
                                                 realm=self.realm1),
                                             "test123456")
         self.assertFalse(resp, resp)
+
+        transaction_id = "123456789"
+        C = Challenge(self.serial1, transaction_id=transaction_id, challenge="Who are you?")
+        C.save()
         resp = token.is_challenge_response(User(login="cornelius",
                                                 realm=self.realm1),
                                             "test123456",
-                                            options={"transaction_id": "123456789"})
+                                            options={"transaction_id": transaction_id})
         self.assertTrue(resp, resp)
 
         # test if challenge is valid
-        C = Challenge("S123455", transaction_id="tid", challenge="Who are you?")
-        C.save()
+        C.is_valid()
 
     def test_19_pin_otp_functions(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()