Repository: privacyidea/privacyidea

Issue:
OCRA/TiQR token challenge accepts any response
Steps to reproduce with an OCRA token on current privacyIDEA master (8307090f7f6dd6b56a9cc8f024d64bdf78998000):

```
$ http --verify=no 'http://localhost:5000/token/init' PI-Authorization:$TOKEN type=ocra pin=1234 | jq .detail.serial
"OCRA0003B73E"
$ http --verify=no 'http://localhost:5000/validate/check' serial=OCRA0003B73E pass=1234 | jq .detail.transaction_id
"03429472993971020039"
$ http --verify=no 'http://localhost:5000/validate/check' serial=OCRA0003B73E transaction_id=03429472993971020039 pass=anything
HTTP/1.0 200 OK
Cache-Control: no-cache
Content-Length: 902
Content-Type: application/json
Date: Wed, 11 Apr 2018 13:48:42 GMT
Server: Werkzeug/0.10.4 Python/2.7.14

{
    "detail": {
        "message": "Found matching challenge",
        "serial": "OCRA0003B73E",
        "threadid": 140110273467584
    },
    "id": 1,
    "jsonrpc": "2.0",
    "result": {
        "status": true,
        "value": true
    },
    "signature": "...",
    "time": 1523454522.55882,
    "version": "privacyIDEA 2.22.dev2",
    "versionnumber": "2.22.dev2"
}
```

This is also the case for TiQR tokens.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokens/ocratoken.py b/privacyidea/lib/tokens/ocratoken.py
--- a/privacyidea/lib/tokens/ocratoken.py
+++ b/privacyidea/lib/tokens/ocratoken.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
 #
 #  http://www.privacyidea.org
+#  2018-04-16 Friedrich Weber <friedrich.weber@netknights.it>
+#             Fix validation of challenge responses
 #  2017-08-29 Cornelis Kölbel <cornelius.koelbel@netknights.it>
 #             Initial implementation of OCRA base token
 #
@@ -250,46 +252,16 @@ def verify_response(self, passw=None, challenge=None):
         return r
 
     @check_token_locked
-    def check_challenge_response(self, user=None, passw=None, options=None):
+    def check_otp(self, otpval, counter=None, window=None, options=None):
         """
-        This function checks, if the challenge for the given transaction_id
-        was marked as answered correctly.
-        For this we check the otp_status of the challenge with the
-        transaction_id in the database.
-
-        We do not care about the password
-
-        :param user: the requesting user
-        :type user: User object
-        :param passw: the password (pin+otp)
-        :type passw: string
-        :param options: additional arguments from the request, which could
-                        be token specific. Usually "transaction_id"
-        :type options: dict
-        :return: return otp_counter. If -1, challenge does not match
-        :rtype: int
+        This function is invoked by ``TokenClass.check_challenge_response``
+        and checks if the given password matches the expected response
+        for the given challenge.
+
+        :param otpval: the password (pin + otp)
+        :param counter: ignored
+        :param window: ignored
+        :param options: dictionary that *must* contain "challenge"
+        :return: >=0 if the challenge matches, -1 otherwise
         """
-        options = options or {}
-        otp_counter = -1
-
-        # fetch the transaction_id
-        transaction_id = options.get('transaction_id')
-        if transaction_id is None:
-            transaction_id = options.get('state')
-
-        # get the challenges for this transaction ID
-        if transaction_id is not None:
-            challengeobject_list = get_challenges(serial=self.token.serial,
-                                                  transaction_id=transaction_id)
-
-            for challengeobject in challengeobject_list:
-                # check if we are still in time.
-                if challengeobject.is_valid():
-                    if self.verify_response(passw, challengeobject.challenge):
-                        # create a positive response
-                        otp_counter = 1
-                        # delete the challenge
-                        challengeobject.delete()
-                        break
-
-        return otp_counter
+        return self.verify_response(otpval, options['challenge'])
diff --git a/privacyidea/lib/tokens/tiqrtoken.py b/privacyidea/lib/tokens/tiqrtoken.py
--- a/privacyidea/lib/tokens/tiqrtoken.py
+++ b/privacyidea/lib/tokens/tiqrtoken.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
 #
 #  http://www.privacyidea.org
+#  2018-04-16 Friedrich Weber <friedrich.weber@netknights.it>
+#             Fix validation of challenge responses
 #  2015-09-01 Initial writeup.
 #             Cornelius Kölbel <cornelius@privacyidea.org>
 #
@@ -407,3 +409,49 @@ def create_challenge(self, transactionid=None, options=None):
                       "hideResponseInput": True}
 
         return True, message, db_challenge.transaction_id, attributes
+
+    @check_token_locked
+    def check_challenge_response(self, user=None, passw=None, options=None):
+        """
+        This function checks, if the challenge for the given transaction_id
+        was marked as answered correctly.
+        For this we check the otp_status of the challenge with the
+        transaction_id in the database.
+
+        We do not care about the password
+
+        :param user: the requesting user
+        :type user: User object
+        :param passw: the password (pin+otp)
+        :type passw: string
+        :param options: additional arguments from the request, which could
+                        be token specific. Usually "transaction_id"
+        :type options: dict
+        :return: return otp_counter. If -1, challenge does not match
+        :rtype: int
+        """
+        options = options or {}
+        otp_counter = -1
+
+        # fetch the transaction_id
+        transaction_id = options.get('transaction_id')
+        if transaction_id is None:
+            transaction_id = options.get('state')
+
+        # get the challenges for this transaction ID
+        if transaction_id is not None:
+            challengeobject_list = get_challenges(serial=self.token.serial,
+                                                  transaction_id=transaction_id)
+
+            for challengeobject in challengeobject_list:
+                # check if we are still in time.
+                if challengeobject.is_valid():
+                    _, status = challengeobject.get_otp_status()
+                    if status is True:
+                        # create a positive response
+                        otp_counter = 1
+                        # delete the challenge
+                        challengeobject.delete()
+                        break
+
+        return otp_counter


Test patch:
diff --git a/tests/test_lib_tokens_tiqr.py b/tests/test_lib_tokens_tiqr.py
--- a/tests/test_lib_tokens_tiqr.py
+++ b/tests/test_lib_tokens_tiqr.py
@@ -4,6 +4,7 @@
 This depends on lib.tokenclass
 """
 from .base import MyTestCase
+from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib.tokens.tiqrtoken import TiqrTokenClass
 from privacyidea.lib.tokens.ocratoken import OcraTokenClass
 from privacyidea.lib.tokens.ocra import OCRASuite, OCRA
@@ -349,10 +350,41 @@ def test_01_create_token(self):
         self.assertEqual(r[0], True)
         self.assertEqual(r[1], "Please answer the challenge")
 
+        # answer the challenge wrongly
+        r = token.verify_response(passw="00065298", challenge=challengeQH40)
+        self.assertTrue(r < 0, r)
+
         # answer the challenge
         r = token.verify_response(passw="90065298", challenge=challengeQH40)
         self.assertTrue(r > 0, r)
 
+        # create another challenge
+        displayTAN_challenge = "83507112  ~320,00~1399458665_G6HNVF"
+        challengeQH40 = binascii.hexlify(hashlib.sha1(
+            displayTAN_challenge).digest())
+        r = token.create_challenge(options={"challenge": challengeQH40})
+        self.assertEqual(r[0], True)
+        self.assertEqual(r[1], "Please answer the challenge")
+        transaction_id = r[2]
+
+        # answer the challenge wrongly using check_challenge_response
+        r = token.check_challenge_response(passw="00065298", options={
+            "transaction_id": transaction_id
+        })
+        self.assertEqual(r, -1)
+
+        # assert there is still one challenge
+        self.assertEqual(len(get_challenges(serial="OCRA1", transaction_id=transaction_id)), 1)
+
+        # answer the challenge correctly using check_challenge_response
+        r = token.check_challenge_response(passw="90065298", options={
+            "transaction_id": transaction_id
+        })
+        self.assertTrue(r > 0, r)
+
+        # assert there is no challenge anymore
+        self.assertEqual(len(get_challenges(serial="OCRA1", transaction_id=transaction_id)), 0)
+
 
 class TiQRTokenTestCase(MyTestCase):
     serial1 = "ser1"
@@ -502,6 +534,11 @@ def _test_api_endpoint(self, user, expected_netloc):
         self.assertEqual(r[0], "plain")
         self.assertEqual(r[1], "INVALID_RESPONSE")
 
+        # Check that the OTP status is still incorrect
+        r = token.check_challenge_response(options={"transaction_id":
+                                                    transaction_id})
+        self.assertEqual(r, -1)
+
         # Send the correct response
         req.all_data = {"response": response,
                         "userId": encoded_user_id,