Repository: privacyidea/privacyidea

Issue:
disabled passkey can be used for authentication
disabled passkey can be used for authentication


Discussion:

Patch:
diff --git a/privacyidea/api/auth.py b/privacyidea/api/auth.py
index 202992a61f..7fd067c10a 100644
--- a/privacyidea/api/auth.py
+++ b/privacyidea/api/auth.py
@@ -73,7 +73,8 @@
                                            disabled_token_types, auth_timelimit)
 from privacyidea.api.lib.utils import (send_result, get_all_params,
                                        verify_auth_token, getParam, get_optional, get_required)
-from privacyidea.lib.utils import get_client_ip, hexlify_and_unicode, to_unicode, get_plugin_info_from_useragent
+from privacyidea.lib.utils import (get_client_ip, hexlify_and_unicode, to_unicode, get_plugin_info_from_useragent,
+                                   AUTH_RESPONSE)
 from privacyidea.lib.config import get_from_config, SYSCONF, ensure_no_config_object, get_privacyidea_node
 from privacyidea.lib.event import event, EventConfiguration
 from privacyidea.lib import _
@@ -258,12 +259,22 @@ def get_auth_token():
         if not token:
             raise AuthError(_("Authentication failure. The passkey is not registered."),
                             id=ERROR.AUTHENTICATE_WRONG_CREDENTIALS)
+        if not token.is_active():
+            log.debug(f"Authentication attempted with disabled token {token.get_serial()}")
+            g.audit_object.log({"info": log_used_user(user, "Token is disabled"),
+                                "success": False,
+                                "authentication": AUTH_RESPONSE.REJECT,
+                                "serial": token.get_serial(),
+                                "token_type": details.get("type")})
+            return send_result(False, rid=2, details={"message": "Token is disabled"})
+
         if not token.user:
             raise AuthError(_("Authentication failure. Token has no user."),
                             id=ERROR.AUTHENTICATE_MISSING_USERNAME)
         if token.get_type() in request.all_data.get("disabled_token_types", []):
-            raise AuthError(_("Authentication failure. The token type {token_type} is disabled.").format(token.get_type()),
-                            id=ERROR.AUTHENTICATE_WRONG_CREDENTIALS)
+            raise AuthError(
+                _("Authentication failure. The token type {token_type} is disabled.").format(token.get_type()),
+                id=ERROR.AUTHENTICATE_WRONG_CREDENTIALS)
         if not check_last_auth_policy(g, token):
             log.debug(f"Last authentication policy check failed for token {token.get_serial()}.")
             raise AuthError(
@@ -323,11 +334,14 @@ def get_auth_token():
         if user.realm in superuser_realms:
             role = ROLE.ADMIN
             admin_auth = True
-            g.audit_object.log({"user": user.login, "realm": user.realm, "info": log_used_user(user)})
         else:
             user_auth = True
-            g.audit_object.log({"user": user.login, "realm": user.realm, "info": log_used_user(user)})
-
+        g.audit_object.log({
+            "user": user.login,
+            "realm": user.realm,
+            "resolver": user.resolver,
+            "info": log_used_user(user)
+        })
     # Check if the remote user is allowed
     elif (request.remote_user == username) and is_remote_user_allowed(request) != REMOTE_USER.DISABLE:
         # Authenticated by the Web Server
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
index 168dc73f8b..537de8e2b8 100644
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -118,7 +118,7 @@
 from privacyidea.lib.token import get_tokens
 from privacyidea.lib.tokenclass import CHALLENGE_SESSION
 from privacyidea.lib.user import get_user_from_param, log_used_user, User
-from privacyidea.lib.utils import get_client_ip, get_plugin_info_from_useragent
+from privacyidea.lib.utils import get_client_ip, get_plugin_info_from_useragent, AUTH_RESPONSE
 from privacyidea.lib.utils import is_true, get_computer_name_from_user_agent
 from .lib.utils import required
 from .lib.utils import send_result, getParam, get_required
@@ -499,8 +499,16 @@ def check():
                 details["message"] = gettext("Last authentication policy check failed for token {serial}").format(
                     serial=token.get_serial())
                 return send_result(False, rid=2, details=details)
-            else:
-                result = verify_fido2_challenge(transaction_id, token, request.all_data) > 0
+            elif not token.is_active():
+                log.debug(f"Authentication attempted with disabled token {token.get_serial()}")
+                g.audit_object.log({"info": log_used_user(user, "Token is disabled"),
+                                    "success": False,
+                                    "authentication": AUTH_RESPONSE.REJECT,
+                                    "serial": token.get_serial(),
+                                    "token_type": details.get("type")})
+                return send_result(False, rid=2, details={"message": "Token is disabled"})
+
+            result = verify_fido2_challenge(transaction_id, token, request.all_data) > 0
         success = result
         if success:
             # If the authentication was successful, return the username of the token owner


Test patch:
diff --git a/tests/test_api_passkey.py b/tests/test_api_passkey.py
index ec84a3db97..6721818138 100644
--- a/tests/test_api_passkey.py
+++ b/tests/test_api_passkey.py
@@ -30,6 +30,7 @@
 from privacyidea.lib.token import remove_token, init_token, get_tokens, get_one_token
 from privacyidea.lib.tokens.webauthn import CoseAlgorithm
 from privacyidea.lib.user import User
+from privacyidea.lib.utils import AUTH_RESPONSE
 from tests.base import MyApiTestCase, OverrideConfigTestCase
 from tests.passkey_base import PasskeyTestBase
 
@@ -299,6 +300,10 @@ def test_05_trigger_with_pin(self):
                    action=f"{FIDO2PolicyAction.USER_VERIFICATION_REQUIREMENT}=discouraged")
         set_policy("challenge_text", scope=SCOPE.AUTH, action=f"passkey_challenge_text=test text")
 
+        # Test with authz policy add_user_in_response
+        set_policy("user_in_response", scope=SCOPE.AUTHZ,
+                   action=f"{PolicyAction.ADDUSERINRESPONSE}, {PolicyAction.ADDRESOLVERINRESPONSE}")
+
         with patch('privacyidea.lib.fido2.challenge.get_fido2_nonce') as get_nonce:
             get_nonce.return_value = self.authentication_challenge_no_uv
             with self.app.test_request_context('/validate/check', method='POST',
@@ -968,7 +973,7 @@ def test_17_cancel_enroll_via_multichallenge_not_allowed(self):
             self.assertEqual("REJECT", result["authentication"])
             self.assertIn("Failed to cancel enrollment", j["detail"]["message"])
         remove_token(serial)
-        remove_token(evm_serial) # the token still exists because the enrollment was not cancelled
+        remove_token(evm_serial)  # the token still exists because the enrollment was not cancelled
         delete_policy("evm")
 
     def test_18_last_auth_policy(self):
@@ -1072,6 +1077,87 @@ def test_18_last_auth_policy(self):
         delete_policy("last_auth")
         remove_token(serial)
 
+    def test_19_disabled_passkey(self):
+        """
+        Disabled passkeys should not be usable for authentication
+        """
+        serial = self._enroll_static_passkey()
+        with self.app.test_request_context('/token/disable', method='POST',
+                                           data={"serial": serial},
+                                           headers={"Authorization": self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(200, res.status_code, res.json)
+            self.assertIn("result", res.json)
+            self.assertIn("status", res.json["result"])
+
+        passkey_challenge = self._trigger_passkey_challenge(self.authentication_challenge_no_uv)
+        self.assertIn("user_verification", passkey_challenge)
+        # By default, user_verification is preferred
+        self.assertEqual("preferred", passkey_challenge["user_verification"])
+
+        transaction_id = passkey_challenge["transaction_id"]
+        # Answer the challenge
+        data = self.authentication_response_no_uv
+        data["transaction_id"] = transaction_id
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data=data,
+                                           headers={"Origin": self.expected_origin}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(200, res.status_code)
+            j = res.json
+            detail = j["detail"]
+            self.assertIn("message", detail)
+            # Failed authentications should not return the username
+            self.assertNotIn("username", detail)
+            # I also do not need to tell you the serial if the token is disabled
+            self.assertNotIn("serial", detail)
+            self.assertNotIn("auth_items", res.json)
+            result = j["result"]
+            self.assertEqual(AUTH_RESPONSE.REJECT, result["authentication"])
+            self.assertFalse(result["value"])
+
+        remove_token(serial)
+
+    def test_20_disabled_passkey_auth(self):
+        """
+        Disabled passkeys should not be usable for authentication
+        """
+        serial = self._enroll_static_passkey()
+        with self.app.test_request_context('/token/disable', method='POST',
+                                           data={"serial": serial},
+                                           headers={"Authorization": self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(200, res.status_code, res.json)
+            self.assertIn("result", res.json)
+            self.assertIn("status", res.json["result"])
+
+        passkey_challenge = self._trigger_passkey_challenge(self.authentication_challenge_no_uv)
+        self.assertIn("user_verification", passkey_challenge)
+        # By default, user_verification is preferred
+        self.assertEqual("preferred", passkey_challenge["user_verification"])
+
+        transaction_id = passkey_challenge["transaction_id"]
+        # Answer the challenge
+        data = self.authentication_response_no_uv
+        data["transaction_id"] = transaction_id
+        with self.app.test_request_context('/auth', method='POST',
+                                           data=data,
+                                           headers={"Origin": self.expected_origin}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(200, res.status_code)
+            j = res.json
+            detail = j["detail"]
+            self.assertIn("message", detail)
+            # Failed authentications should not return the username
+            self.assertNotIn("username", detail)
+            # I also do not need to tell you the serial if the token is disabled
+            self.assertNotIn("serial", detail)
+            self.assertNotIn("auth_items", res.json)
+            result = j["result"]
+            self.assertEqual(AUTH_RESPONSE.REJECT, result["authentication"])
+            self.assertFalse(result["value"])
+
+        remove_token(serial)
 
 class PasskeyAuthAPITest(PasskeyAPITestBase, OverrideConfigTestCase):
     """
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
index 12290ca51d..058cd7b776 100644
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -3227,7 +3227,7 @@ def test_37_challenge_response_hotp_with_container(self):
         auth_time = datetime.datetime.now(datetime.timezone.utc)
         last_auth = container.last_authentication
         time_diff = abs((auth_time - last_auth).total_seconds())
-        self.assertLessEqual(time_diff, 1)
+        self.assertLessEqual(time_diff, 2)
 
         # delete the token
         remove_token(serial=serial)