Repository: privacyidea/privacyidea

Issue:
Deactivated tokens should work like deleted tokens
If a user with U2F **and** Email tokens tries to login to ownCloud, it will not work. Even if one of the tokens is disabled. The user has to delete one of them, then the remaining one is working.

If you enabled "passOnNoToken", it is not enough to disable all tokens. You have to delete them.
Deactivated tokens should work like deleted tokens
If a user with U2F **and** Email tokens tries to login to ownCloud, it will not work. Even if one of the tokens is disabled. The user has to delete one of them, then the remaining one is working.

If you enabled "passOnNoToken", it is not enough to disable all tokens. You have to delete them.


Discussion:
This problem only occurs with /validate/triggerchallenge.
I am adding a test case and looking at the fix.
This problem only occurs with /validate/triggerchallenge.
I am adding a test case and looking at the fix.

Resolving patch:
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -540,7 +540,7 @@ def trigger_challenge():
                "clientip": g.client_ip,
                "user": user}
 
-    token_objs = get_tokens(serial=serial, user=user)
+    token_objs = get_tokens(serial=serial, user=user, active=True, revoked=False, locked=False)
     for token_obj in token_objs:
         if "challenge" in token_obj.mode:
             # If this is a challenge response token, we create a challenge
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2137,23 +2137,28 @@ def check_token_list(tokenobject_list, passw, user=None, options=None):
         for tokenobject in challenge_response_token_list:
             if tokenobject.check_challenge_response(passw=passw,
                                                     options=options) >= 0:
-                # OTP matches
-                res = True
-                tokenobject.inc_count_auth_success()
-                reply_dict["message"] = "Found matching challenge"
                 reply_dict["serial"] = tokenobject.token.serial
-                tokenobject.challenge_janitor()
-                # clean up all other challenges from other tokens. I.e.
-                # all challenges with this very transaction_id!
-                transaction_id = options.get("transaction_id") or \
-                                 options.get("state")
-                Challenge.query.filter(Challenge.transaction_id == u'' +
-                                       transaction_id).delete()
-
-                # Reset the fail counter of the challenge response token
-                tokenobject.reset()
-                # We have one successful authentication, so we bail out
-                break
+                if tokenobject.is_active():
+                    # OTP matches
+                    res = True
+                    tokenobject.inc_count_auth_success()
+                    reply_dict["message"] = "Found matching challenge"
+                    tokenobject.challenge_janitor()
+                    # clean up all other challenges from other tokens. I.e.
+                    # all challenges with this very transaction_id!
+                    transaction_id = options.get("transaction_id") or \
+                                     options.get("state")
+                    Challenge.query.filter(Challenge.transaction_id == u'' +
+                                           transaction_id).delete()
+
+                    # Reset the fail counter of the challenge response token
+                    tokenobject.reset()
+                    # We have one successful authentication, so we bail out
+                    break
+                else:
+                    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))
 
     elif challenge_request_token_list:
         # This is the initial REQUEST of a challenge response token


Test patch:
diff --git a/tests/base.py b/tests/base.py
--- a/tests/base.py
+++ b/tests/base.py
@@ -42,7 +42,7 @@ class MyTestCase(unittest.TestCase):
                         "399871",
                         "520489"]
 
-
+    
     @classmethod
     def setUpClass(cls):
         cls.app = create_app('testing', "")
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
@@ -10,7 +10,8 @@
                                     get_inc_fail_count_on_false_pin,
                                     delete_privacyidea_config)
 from privacyidea.lib.token import (get_tokens, init_token, remove_token,
-                                   reset_token, enable_token, revoke_token)
+                                   reset_token, enable_token, revoke_token,
+                                   set_pin)
 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
@@ -317,7 +318,7 @@ def test_00_run_complete_workflow(self):
         remove_token("ocra1234")
 
 
-class AAValidateOfflineTestCase(MyTestCase):
+class AValidateOfflineTestCase(MyTestCase):
     """
     Test api.validate endpoints that are responsible for offline auth.
     """
@@ -2177,3 +2178,135 @@ def test_30_challenge_text(self):
         remove_token("CHAL2")
         remove_token("CHAL3")
         remove_token("CHAL4")
+
+
+class AChallengeResponse(MyTestCase):
+
+    serial = "hotp1"
+    serial_email = "email1"
+
+    def setUp(self):
+        MyTestCase.setUp(self)
+        self.setUp_user_realms()
+
+    def test_01_challenge_response_token_deactivate(self):
+        # New token for the user "selfservice"
+        Token("hotp1", "hotp", otpkey=self.otpkey, userid=1004, resolver=self.resolvername1,
+              realm=self.realm1).save()
+        # Define HOTP token to be challenge response
+        set_policy(name="pol_cr", scope=SCOPE.AUTH, action="{0!s}=hotp".format(ACTION.CHALLENGERESPONSE))
+        set_pin(self.serial, "pin")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "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")
+            self.assertTrue("enter otp" in detail.get("message"), detail.get("message"))
+            transaction_id = detail.get("transaction_id")
+
+        # Now we try to provide the OTP value
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "pass": self.valid_otp_values[0],
+                                                 "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("value"))
+
+        # Now we send the challenge and then we disable the token
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "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")
+            self.assertTrue("enter otp" in detail.get("message"), detail.get("message"))
+            transaction_id = detail.get("transaction_id")
+
+        # disable the token
+        enable_token(self.serial, False)
+
+        # Now we try to provide the OTP value, but authentication must fail, since the token is disabled
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "pass": self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual(detail.get("message"), "Challenge matches, but token is inactive.")
+
+        # The token is still disabled. We are checking, if we can do a challenge response
+        # for a disabled token
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "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")
+            self.assertTrue("No active challenge response" in detail.get("message"), detail.get("message"))
+
+    @smtpmock.activate
+    def test_02_two_challenge_response_tokens(self):
+        smtpmock.setdata(response={"bla@example.com": (200, 'OK')})
+        # We test two challenge response tokens. One is active, one is disabled.
+        # Enroll an Email-Token to the user
+        init_token(user=User("selfservice", self.realm1),
+                   param={"serial": self.serial_email,
+                          "type": "email",
+                          "email": "bla@example.com",
+                          "otpkey": self.otpkey})
+        set_pin(self.serial_email, "pin")
+
+        toks = get_tokens(user=User("selfservice", self.realm1))
+        self.assertEqual(len(toks), 2)
+        self.assertFalse(toks[0].token.active)
+        self.assertTrue(toks[1].token.active)
+
+        # Now we create a challenge with two tokens
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "selfservice",
+                                                 "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")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("message"))
+
+        # Now test with triggerchallenge
+        with self.app.test_request_context('/validate/triggerchallenge',
+                                           method='POST',
+                                           data={"user": "selfservice"},
+                                           headers={"Authorization": self.at}):
+            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"))
+            # Triggerchallenge returns the numbers of tokens in the "value
+            self.assertEqual(data.get("result").get("value"), 1)
+            detail = data.get("detail")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("messages")[0])
\ No newline at end of file