Repository: privacyidea/privacyidea (Python)

Issue:
Do not increase PIN when checking for an answered challenge.
This refers to
https://community.privacyidea.org/t/tiqr-authentication-fail-counter-increase-if-we-query-the-status/1061

There are certain tokentypes, that run a bit different authentication workflow.
The TiQR and the PUSH token are both challenge response token.
The challenge is sent by issuing the PIN to the ``/validate/check`` endpoint.
But the repsonse to the challenge is not sent to the /validate/check endpoint but to another endpoint of privacyIDEA.
/validate/check can simply act as an interface to check, if the challenge has been asnwered correctly. So the /validate/check in this case is not prune to brute force and we do not need to increase the PIN.

This was tried in PR #1684 but in a not quite nice and generic way.

Tokenclasses need to have a flag, if they are actually such tokens.


Discussion:

Patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
index 407b1275b3..bf3e8b9c77 100644
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2237,7 +2237,8 @@ def check_token_list(tokenobject_list, passw, user=None, options=None, allow_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 token_obj.is_outofband():
+                    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
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
index aa312dbc2a..c90e9060ca 100644
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -124,12 +124,19 @@ class TOKENKIND(object):
     VIRTUAL = "virtual"
 
 
+class TOKENMODE(object):
+    AUTHENTICATE = 'authenticate'
+    CHALLENGE = 'challenge'
+    # If the challenge is answered out of band
+    OUTOFBAND = 'outofband'
+
+
 class TokenClass(object):
 
     # Class properties
     using_pin = True
     hKeyRequired = False
-    mode = ['authenticate', 'challenge']
+    mode = [TOKENMODE.AUTHENTICATE, TOKENMODE.CHALLENGE]
 
     @log_with(log)
     def __init__(self, db_token):
@@ -163,6 +170,10 @@ def set_type(self, tokentype):
         self.type = tokentype
         self.token.tokentype = tokentype
 
+    @classmethod
+    def is_outofband(cls):
+        return TOKENMODE.OUTOFBAND in cls.mode
+
     @staticmethod
     def get_class_type():
         return None
diff --git a/privacyidea/lib/tokens/pushtoken.py b/privacyidea/lib/tokens/pushtoken.py
index a56b1b423b..cdfa5c7917 100644
--- a/privacyidea/lib/tokens/pushtoken.py
+++ b/privacyidea/lib/tokens/pushtoken.py
@@ -40,7 +40,7 @@
 from privacyidea.lib.log import log_with
 from privacyidea.lib import _
 
-from privacyidea.lib.tokenclass import TokenClass
+from privacyidea.lib.tokenclass import TokenClass, TOKENMODE
 from privacyidea.models import Challenge, db
 from privacyidea.lib.decorators import check_token_locked
 import logging
@@ -173,6 +173,8 @@ class PushTokenClass(TokenClass):
     https://github.com/privacyidea/privacyidea/wiki/concept%3A-PushToken
 
     """
+    mode = [TOKENMODE.AUTHENTICATE, TOKENMODE.CHALLENGE, TOKENMODE.OUTOFBAND]
+
     def __init__(self, db_token):
         TokenClass.__init__(self, db_token)
         self.set_type(u"push")
diff --git a/privacyidea/lib/tokens/tiqrtoken.py b/privacyidea/lib/tokens/tiqrtoken.py
index 0d3a6c092b..d50a8cf10d 100644
--- a/privacyidea/lib/tokens/tiqrtoken.py
+++ b/privacyidea/lib/tokens/tiqrtoken.py
@@ -80,7 +80,7 @@
 
 from privacyidea.api.lib.utils import getParam
 from privacyidea.lib.config import get_from_config
-from privacyidea.lib.tokenclass import TokenClass
+from privacyidea.lib.tokenclass import TokenClass, TOKENMODE
 from privacyidea.lib.log import log_with
 from privacyidea.lib.crypto import generate_otpkey
 from privacyidea.lib.utils import create_img
@@ -115,6 +115,7 @@ class TiqrTokenClass(OcraTokenClass):
     """
     The TiQR Token implementation.
     """
+    mode = [TOKENMODE.AUTHENTICATE, TOKENMODE.CHALLENGE, TOKENMODE.OUTOFBAND]
 
     @staticmethod
     def get_class_type():


Test patch:
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
index d3132793ab..aae94dd4b1 100644
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -1459,6 +1459,45 @@ def test_56_get_tokens_paginated_generator_removal(self):
         self.assertEquals(set(t.token.serial for t in list1 + list2), all_serials)
 
 
+class TokenOutOfBandTestCase(MyTestCase):
+
+    def test_00_create_realms(self):
+        self.setUp_user_realms()
+
+    def test_01_failcounter_no_increase(self):
+        # The fail counter for tiqr tokens will not increase, since this
+        # is a tokenmode outofband.
+        user = User(login="cornelius", realm=self.realm1)
+        pin1 = "pin1"
+        token1 = init_token({"serial": pin1, "pin": pin1,
+                             "type": "tiqr", "genkey": 1}, user=user)
+
+        r = token1.get_failcount()
+        self.assertEqual(r, 0)
+
+        r, r_dict = check_token_list([token1], pin1, user=user, options={})
+        self.assertFalse(r)
+        transaction_id = r_dict.get("transaction_id")
+
+        # Now we check the status of the challenge several times and verify that the
+        # failcounter is not increased:
+        for i in range(1, 10):
+            r, r_dict = check_token_list([token1], "", user=user, options={"transaction_id": transaction_id})
+            self.assertFalse(r)
+            self.assertEqual(r_dict.get("type"), "tiqr")
+
+        r = token1.get_failcount()
+        self.assertEqual(r, 0)
+
+        # Now set the challenge to be answered and recheck:
+        Challenge.query.filter(Challenge.transaction_id == transaction_id).update({"otp_valid": 1})
+        r, r_dict = check_token_list([token1], "", user=user, options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+        self.assertEqual(r_dict.get("message"), "Found matching challenge")
+
+        remove_token(pin1)
+
+
 class TokenFailCounterTestCase(MyTestCase):
     """
     Test the lib.token on an interface level