diff --git a/authentik/stages/authenticator_webauthn/api/stages.py b/authentik/stages/authenticator_webauthn/api/stages.py index 497f1046f9..d03f490c6f 100644 --- a/authentik/stages/authenticator_webauthn/api/stages.py +++ b/authentik/stages/authenticator_webauthn/api/stages.py @@ -26,6 +26,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer): "hints", "device_type_restrictions", "device_type_restrictions_obj", + "prevent_duplicate_devices", "max_attempts", ] diff --git a/authentik/stages/authenticator_webauthn/migrations/0012_webauthndevice_created_webauthndevice_last_updated_and_more_squashed_0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py b/authentik/stages/authenticator_webauthn/migrations/0012_webauthndevice_created_webauthndevice_last_updated_and_more_squashed_0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py new file mode 100644 index 0000000000..70ec0fc6a4 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/migrations/0012_webauthndevice_created_webauthndevice_last_updated_and_more_squashed_0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.12 on 2026-03-24 13:00 + +import datetime +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [ + ( + "authentik_stages_authenticator_webauthn", + "0012_webauthndevice_created_webauthndevice_last_updated_and_more", + ), + ("authentik_stages_authenticator_webauthn", "0013_authenticatorwebauthnstage_max_attempts"), + ( + "authentik_stages_authenticator_webauthn", + "0014_alter_authenticatorwebauthnstage_friendly_name", + ), + ("authentik_stages_authenticator_webauthn", "0015_authenticatorwebauthnstage_hints"), + ( + "authentik_stages_authenticator_webauthn", + "0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more", + ), + ] + + dependencies = [ + ("authentik_stages_authenticator_webauthn", "0001_squashed_0011_webauthndevice_aaguid"), + ] + + operations = [ + migrations.AddField( + model_name="webauthndevice", + name="created", + field=models.DateTimeField( + auto_now_add=True, + default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), + ), + preserve_default=False, + ), + migrations.AddField( + model_name="webauthndevice", + name="last_updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="webauthndevice", + name="last_used", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="authenticatorwebauthnstage", + name="max_attempts", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name="authenticatorwebauthnstage", + name="friendly_name", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ), + migrations.AddField( + model_name="authenticatorwebauthnstage", + name="hints", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ("security-key", "Security Key"), + ("client-device", "Client Device"), + ("hybrid", "Hybrid"), + ] + ), + blank=True, + default=list, + size=None, + ), + ), + migrations.AddField( + model_name="authenticatorwebauthnstage", + name="prevent_duplicate_devices", + field=models.BooleanField( + default=True, help_text="When enabled, a given device can only be registered once." + ), + ), + migrations.AddField( + model_name="webauthndevice", + name="attestation_certificate_fingerprint", + field=models.TextField(default=None, null=True), + ), + migrations.AddField( + model_name="webauthndevice", + name="attestation_certificate_pem", + field=models.TextField(default=None, null=True), + ), + ] diff --git a/authentik/stages/authenticator_webauthn/migrations/0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py b/authentik/stages/authenticator_webauthn/migrations/0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py new file mode 100644 index 0000000000..8262f0561f --- /dev/null +++ b/authentik/stages/authenticator_webauthn/migrations/0016_authenticatorwebauthnstage_prevent_duplicate_devices_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.11 on 2026-03-24 12:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_authenticator_webauthn", "0015_authenticatorwebauthnstage_hints"), + ] + + operations = [ + migrations.AddField( + model_name="authenticatorwebauthnstage", + name="prevent_duplicate_devices", + field=models.BooleanField( + default=True, help_text="When enabled, a given device can only be registered once." + ), + ), + migrations.AddField( + model_name="webauthndevice", + name="attestation_certificate_fingerprint", + field=models.TextField(default=None, null=True), + ), + migrations.AddField( + model_name="webauthndevice", + name="attestation_certificate_pem", + field=models.TextField(default=None, null=True), + ), + ] diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py index 163335656e..dd5198a999 100644 --- a/authentik/stages/authenticator_webauthn/models.py +++ b/authentik/stages/authenticator_webauthn/models.py @@ -1,5 +1,6 @@ """WebAuthn stage""" +from cryptography.x509 import Certificate, load_pem_x509_certificate from django.contrib.auth import get_user_model from django.contrib.postgres.fields.array import ArrayField from django.db import models @@ -101,6 +102,9 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage): choices=AuthenticatorAttachment.choices, default=None, null=True ) + prevent_duplicate_devices = models.BooleanField( + default=True, help_text=_("When enabled, a given device can only be registered once.") + ) hints = ArrayField( models.TextField(choices=WebAuthnHint.choices), default=list, @@ -159,6 +163,8 @@ class WebAuthnDevice(SerializerModel, Device): created_on = models.DateTimeField(auto_now_add=True) last_t = models.DateTimeField(default=now) + attestation_certificate_pem = models.TextField(null=True, default=None) + attestation_certificate_fingerprint = models.TextField(null=True, default=None) aaguid = models.TextField(default=UNKNOWN_DEVICE_TYPE_AAGUID) device_type = models.ForeignKey( "WebAuthnDeviceType", on_delete=models.SET_DEFAULT, null=True, default=None @@ -169,6 +175,12 @@ class WebAuthnDevice(SerializerModel, Device): """Get a publickeydescriptor for this device""" return PublicKeyCredentialDescriptor(id=base64url_to_bytes(self.credential_id)) + @property + def attestation_certificate(self) -> Certificate | None: + if not self.attestation_certificate_pem: + return None + return load_pem_x509_certificate(self.attestation_certificate_pem.encode()) + def set_sign_count(self, sign_count: int) -> None: """Set the sign_count and update the last_t datetime.""" self.sign_count = sign_count diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py index 8509b6393e..913fb448fc 100644 --- a/authentik/stages/authenticator_webauthn/stage.py +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -1,7 +1,11 @@ """WebAuthn stage""" +from dataclasses import dataclass from uuid import UUID +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509 import load_der_x509_certificate +from django.db.models import Q from django.http import HttpRequest, HttpResponse from django.http.request import QueryDict from django.utils.translation import gettext as __ @@ -11,6 +15,7 @@ from rest_framework.serializers import ValidationError from webauthn.helpers.bytes_to_base64url import bytes_to_base64url from webauthn.helpers.exceptions import WebAuthnException from webauthn.helpers.options_to_json_dict import options_to_json_dict +from webauthn.helpers.parse_attestation_object import parse_attestation_object from webauthn.helpers.structs import ( AttestationConveyancePreference, AuthenticatorAttachment, @@ -28,6 +33,7 @@ from webauthn.registration.verify_registration_response import ( from authentik.core.api.utils import JSONDictField from authentik.core.models import User +from authentik.crypto.models import fingerprint_sha256 from authentik.flows.challenge import ( Challenge, ChallengeResponse, @@ -46,6 +52,14 @@ PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/ PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt" +@dataclass +class VerifiedRegistrationData: + registration: VerifiedRegistration + exists_query: Q + attest_cert: str | None = None + attest_cert_fingerprint: str | None = None + + class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): """WebAuthn Challenge""" @@ -62,7 +76,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): request: HttpRequest user: User - def validate_response(self, response: dict) -> dict: + def validate_response(self, response: dict) -> VerifiedRegistrationData: """Validate webauthn challenge response""" challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] @@ -77,13 +91,33 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): self.stage.logger.warning("registration failed", exc=exc) raise ValidationError(f"Registration failed. Error: {exc}") from None - credential_id_exists = WebAuthnDevice.objects.filter( - credential_id=bytes_to_base64url(registration.credential_id) - ).first() + registration_data = VerifiedRegistrationData( + registration, + exists_query=Q(credential_id=bytes_to_base64url(registration.credential_id)), + ) + stage: AuthenticatorWebAuthnStage = self.stage.executor.current_stage + + att_obj = parse_attestation_object(registration.attestation_object) + if ( + att_obj + and att_obj.att_stmt + and att_obj.att_stmt.x5c is not None + and len(att_obj.att_stmt.x5c) > 0 + ): + cert = load_der_x509_certificate(att_obj.att_stmt.x5c[0]) + registration_data.attest_cert = cert.public_bytes( + encoding=Encoding.PEM, + ).decode("utf-8") + registration_data.attest_cert_fingerprint = fingerprint_sha256(cert) + if stage.prevent_duplicate_devices: + registration_data.exists_query |= Q( + attestation_certificate_fingerprint=registration_data.attest_cert_fingerprint + ) + + credential_id_exists = WebAuthnDevice.objects.filter(registration_data.exists_query).first() if credential_id_exists: raise ValidationError("Credential ID already exists.") - stage: AuthenticatorWebAuthnStage = self.stage.executor.current_stage aaguid = registration.aaguid allowed_aaguids = stage.device_type_restrictions.values_list("aaguid", flat=True) if allowed_aaguids.exists(): @@ -103,11 +137,11 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): UUID(UNKNOWN_DEVICE_TYPE_AAGUID) in allowed_aaguids and not WebAuthnDeviceType.objects.filter(aaguid=aaguid).exists() ): - return registration + return registration_data # Otherwise just check if the given aaguid is in the allowed aaguids if UUID(aaguid) not in allowed_aaguids: raise invalid_error - return registration + return registration_data class AuthenticatorWebAuthnStageView(ChallengeStageView): @@ -190,26 +224,28 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # Webauthn Challenge has already been validated - webauthn_credential: VerifiedRegistration = response.validated_data["response"] - existing_device = WebAuthnDevice.objects.filter( - credential_id=bytes_to_base64url(webauthn_credential.credential_id) - ).first() + webauthn_credential: VerifiedRegistrationData = response.validated_data["response"] + existing_device = WebAuthnDevice.objects.filter(webauthn_credential.exists_query).first() if not existing_device: name = "WebAuthn Device" device_type = WebAuthnDeviceType.objects.filter( - aaguid=webauthn_credential.aaguid + aaguid=webauthn_credential.registration.aaguid ).first() if device_type and device_type.description: name = device_type.description WebAuthnDevice.objects.create( name=name, user=self.get_pending_user(), - public_key=bytes_to_base64url(webauthn_credential.credential_public_key), - credential_id=bytes_to_base64url(webauthn_credential.credential_id), - sign_count=webauthn_credential.sign_count, + public_key=bytes_to_base64url( + webauthn_credential.registration.credential_public_key + ), + credential_id=bytes_to_base64url(webauthn_credential.registration.credential_id), + sign_count=webauthn_credential.registration.sign_count, rp_id=get_rp_id(self.request), device_type=device_type, - aaguid=webauthn_credential.aaguid, + aaguid=webauthn_credential.registration.aaguid, + attestation_certificate_pem=webauthn_credential.attest_cert, + attestation_certificate_fingerprint=webauthn_credential.attest_cert_fingerprint, ) else: return self.executor.stage_invalid("Device with Credential ID already exists.") diff --git a/authentik/stages/authenticator_webauthn/tests/__init__.py b/authentik/stages/authenticator_webauthn/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/stages/authenticator_webauthn/tests/fixtures/register.json b/authentik/stages/authenticator_webauthn/tests/fixtures/register.json new file mode 100644 index 0000000000..8e3507cea7 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/tests/fixtures/register.json @@ -0,0 +1,10 @@ +{ + "id": "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ", + "rawId": "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ", + "type": "public-key", + "registrationClientExtensions": "{}", + "response": { + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiaUhJWDNBdGtaWkN4U1lMeE9oazgwWlhJN1JuQUMwUGI0V1RrOWRFSjRlTEpkem9oOGpSbWpLVzJVOW9FX0NCbjVuNlpqNjdCSUladkZMM2xwaXdKd2ciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEYwRAIgRkCRBg_Z0-cS8M4HyiZpar7cy6PRbGW_G0yTnG_lMUUCIHOKwNqU_Mr4sip5zUECezH-NJWdIGUbFR7D7mSC1wMSY3g1Y4FZAt0wggLZMIIBwaADAgECAgkA8Oq7fWgETIowDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG8xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xKDAmBgNVBAMMH1l1YmljbyBVMkYgRUUgU2VyaWFsIDIxMDk0NjczNzYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATmZ9M7upxFm4Ce_MtqC64sXPxL14HVc0g9lv3pJR9kLM3mwgZVFPMzgkasmVKAACrSOK-8A3G21_rDv8ueedIwo4GBMH8wEwYKKwYBBAGCxAoNAQQFBAMFBAMwIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQL8BXn4ETR-qxFrtajbkgKjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQC2Mago15M4rSkAig1_eaOgPc8uDJsfYvrPtIqeVZV3p1FslZtkKxjwDEx3Io0Z-dRCIlwSaL0jGKCMahdzBk8CmcmbskOKR7tnsdDbJSuUln4SAVqaK-nkLdRUJoiQYf4fIlb--Hbdc5kyRoNxGrBt6rxvRWhq-e7hgXlsIzs-2ew9wKy98vkNqE8ZJ-lz1jIA0bj05AE5miU0XcwEoquyk4AjtF9bQlJBjQ1SdYVjH2HEVs25iwoU3g1uUn9nP20yTVhhKRMnpV_EdOjm18hxot9nV0isx5jXb5Z6-My58Vb-oHgStjkaN-3dxuJkEQuZtD1AtTItfvyUeIsL2kkiaGF1dGhEYXRhWMJJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY8UAAAADL8BXn4ETR-qxFrtajbkgKgAwf7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQpQECAyYgASFYIH-8L_Jj_qaEsYfueu2KcYEacayeFjsZ1LowkryCG3MYIlggKCjYkvnPmx-ZcyOs3em0ZseMtwDga1j0Hi-WmFLboNmha2NyZWRQcm90ZWN0Ag" + } +} diff --git a/authentik/stages/authenticator_webauthn/tests.py b/authentik/stages/authenticator_webauthn/tests/test_stage.py similarity index 94% rename from authentik/stages/authenticator_webauthn/tests.py rename to authentik/stages/authenticator_webauthn/tests/test_stage.py index 9f67905830..20ccc7aead 100644 --- a/authentik/stages/authenticator_webauthn/tests.py +++ b/authentik/stages/authenticator_webauthn/tests/test_stage.py @@ -1,6 +1,7 @@ """Test WebAuthn API""" from base64 import b64decode +from json import loads from django.urls import reverse from webauthn.helpers.bytes_to_base64url import bytes_to_base64url @@ -12,6 +13,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id +from authentik.lib.tests.utils import load_fixture from authentik.stages.authenticator_webauthn.models import ( UNKNOWN_DEVICE_TYPE_AAGUID, AuthenticatorWebAuthnStage, @@ -102,7 +104,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( - b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" + b"iHIX3AtkZZCxSYLxOhk80ZXI7RnAC0Pb4WTk9dEJ4eLJdzoh8jRmjKW2U9oE/CBn5n6Zj67BIIZvFL3lpiwJwg==" ) session = self.client.session session[SESSION_KEY_PLAN] = plan @@ -111,35 +113,22 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), data={ "component": "ak-stage-authenticator-webauthn", - "response": { - "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", - "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", - "type": "public-key", - "registrationClientExtensions": "{}", - "response": { - "clientDataJSON": ( - "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" - "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" - "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" - "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" - "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" - ), - "attestationObject": ( - "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" - "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" - "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" - "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" - "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" - ), - }, - }, + "response": loads(load_fixture("fixtures/register.json")), }, SERVER_NAME="localhost", SERVER_PORT="9000", ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) - self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) + device = WebAuthnDevice.objects.filter(user=self.user).first() + self.assertIsNotNone(device) + self.assertEqual( + device.credential_id, "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ" + ) + self.assertEqual( + device.attestation_certificate_fingerprint, + "3e:28:fc:df:45:19:bb:94:0a:0c:90:98:f2:08:72:53:2a:9e:e2:76:13:02:3e:69:61:4a:d9:90:49:80:3d:34", + ) def test_register_restricted_device_type_deny(self): """Test registration with restricted devices (fail)""" diff --git a/blueprints/schema.json b/blueprints/schema.json index 05de806e01..4c4e8702c6 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -14854,6 +14854,11 @@ }, "title": "Device type restrictions" }, + "prevent_duplicate_devices": { + "type": "boolean", + "title": "Prevent duplicate devices", + "description": "When enabled, a given device can only be registered once." + }, "max_attempts": { "type": "integer", "minimum": 0, diff --git a/packages/client-go/model_authenticator_web_authn_stage.go b/packages/client-go/model_authenticator_web_authn_stage.go index 1d40390d26..23cfe5ca80 100644 --- a/packages/client-go/model_authenticator_web_authn_stage.go +++ b/packages/client-go/model_authenticator_web_authn_stage.go @@ -41,8 +41,10 @@ type AuthenticatorWebAuthnStage struct { Hints []WebAuthnHintEnum `json:"hints,omitempty"` DeviceTypeRestrictions []string `json:"device_type_restrictions,omitempty"` DeviceTypeRestrictionsObj []WebAuthnDeviceType `json:"device_type_restrictions_obj"` - MaxAttempts *int32 `json:"max_attempts,omitempty"` - AdditionalProperties map[string]interface{} + // When enabled, a given device can only be registered once. + PreventDuplicateDevices *bool `json:"prevent_duplicate_devices,omitempty"` + MaxAttempts *int32 `json:"max_attempts,omitempty"` + AdditionalProperties map[string]interface{} } type _AuthenticatorWebAuthnStage AuthenticatorWebAuthnStage @@ -510,6 +512,38 @@ func (o *AuthenticatorWebAuthnStage) SetDeviceTypeRestrictionsObj(v []WebAuthnDe o.DeviceTypeRestrictionsObj = v } +// GetPreventDuplicateDevices returns the PreventDuplicateDevices field value if set, zero value otherwise. +func (o *AuthenticatorWebAuthnStage) GetPreventDuplicateDevices() bool { + if o == nil || IsNil(o.PreventDuplicateDevices) { + var ret bool + return ret + } + return *o.PreventDuplicateDevices +} + +// GetPreventDuplicateDevicesOk returns a tuple with the PreventDuplicateDevices field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *AuthenticatorWebAuthnStage) GetPreventDuplicateDevicesOk() (*bool, bool) { + if o == nil || IsNil(o.PreventDuplicateDevices) { + return nil, false + } + return o.PreventDuplicateDevices, true +} + +// HasPreventDuplicateDevices returns a boolean if a field has been set. +func (o *AuthenticatorWebAuthnStage) HasPreventDuplicateDevices() bool { + if o != nil && !IsNil(o.PreventDuplicateDevices) { + return true + } + + return false +} + +// SetPreventDuplicateDevices gets a reference to the given bool and assigns it to the PreventDuplicateDevices field. +func (o *AuthenticatorWebAuthnStage) SetPreventDuplicateDevices(v bool) { + o.PreventDuplicateDevices = &v +} + // GetMaxAttempts returns the MaxAttempts field value if set, zero value otherwise. func (o *AuthenticatorWebAuthnStage) GetMaxAttempts() int32 { if o == nil || IsNil(o.MaxAttempts) { @@ -581,6 +615,9 @@ func (o AuthenticatorWebAuthnStage) ToMap() (map[string]interface{}, error) { toSerialize["device_type_restrictions"] = o.DeviceTypeRestrictions } toSerialize["device_type_restrictions_obj"] = o.DeviceTypeRestrictionsObj + if !IsNil(o.PreventDuplicateDevices) { + toSerialize["prevent_duplicate_devices"] = o.PreventDuplicateDevices + } if !IsNil(o.MaxAttempts) { toSerialize["max_attempts"] = o.MaxAttempts } @@ -649,6 +686,7 @@ func (o *AuthenticatorWebAuthnStage) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "hints") delete(additionalProperties, "device_type_restrictions") delete(additionalProperties, "device_type_restrictions_obj") + delete(additionalProperties, "prevent_duplicate_devices") delete(additionalProperties, "max_attempts") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_authenticator_web_authn_stage_request.go b/packages/client-go/model_authenticator_web_authn_stage_request.go index da45279f73..a2c59ccf07 100644 --- a/packages/client-go/model_authenticator_web_authn_stage_request.go +++ b/packages/client-go/model_authenticator_web_authn_stage_request.go @@ -30,7 +30,9 @@ type AuthenticatorWebAuthnStageRequest struct { ResidentKeyRequirement *UserVerificationEnum `json:"resident_key_requirement,omitempty"` Hints []WebAuthnHintEnum `json:"hints,omitempty"` DeviceTypeRestrictions []string `json:"device_type_restrictions,omitempty"` - MaxAttempts *int32 `json:"max_attempts,omitempty"` + // When enabled, a given device can only be registered once. + PreventDuplicateDevices *bool `json:"prevent_duplicate_devices,omitempty"` + MaxAttempts *int32 `json:"max_attempts,omitempty"` AdditionalProperties map[string]interface{} } @@ -324,6 +326,38 @@ func (o *AuthenticatorWebAuthnStageRequest) SetDeviceTypeRestrictions(v []string o.DeviceTypeRestrictions = v } +// GetPreventDuplicateDevices returns the PreventDuplicateDevices field value if set, zero value otherwise. +func (o *AuthenticatorWebAuthnStageRequest) GetPreventDuplicateDevices() bool { + if o == nil || IsNil(o.PreventDuplicateDevices) { + var ret bool + return ret + } + return *o.PreventDuplicateDevices +} + +// GetPreventDuplicateDevicesOk returns a tuple with the PreventDuplicateDevices field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *AuthenticatorWebAuthnStageRequest) GetPreventDuplicateDevicesOk() (*bool, bool) { + if o == nil || IsNil(o.PreventDuplicateDevices) { + return nil, false + } + return o.PreventDuplicateDevices, true +} + +// HasPreventDuplicateDevices returns a boolean if a field has been set. +func (o *AuthenticatorWebAuthnStageRequest) HasPreventDuplicateDevices() bool { + if o != nil && !IsNil(o.PreventDuplicateDevices) { + return true + } + + return false +} + +// SetPreventDuplicateDevices gets a reference to the given bool and assigns it to the PreventDuplicateDevices field. +func (o *AuthenticatorWebAuthnStageRequest) SetPreventDuplicateDevices(v bool) { + o.PreventDuplicateDevices = &v +} + // GetMaxAttempts returns the MaxAttempts field value if set, zero value otherwise. func (o *AuthenticatorWebAuthnStageRequest) GetMaxAttempts() int32 { if o == nil || IsNil(o.MaxAttempts) { @@ -388,6 +422,9 @@ func (o AuthenticatorWebAuthnStageRequest) ToMap() (map[string]interface{}, erro if !IsNil(o.DeviceTypeRestrictions) { toSerialize["device_type_restrictions"] = o.DeviceTypeRestrictions } + if !IsNil(o.PreventDuplicateDevices) { + toSerialize["prevent_duplicate_devices"] = o.PreventDuplicateDevices + } if !IsNil(o.MaxAttempts) { toSerialize["max_attempts"] = o.MaxAttempts } @@ -442,6 +479,7 @@ func (o *AuthenticatorWebAuthnStageRequest) UnmarshalJSON(data []byte) (err erro delete(additionalProperties, "resident_key_requirement") delete(additionalProperties, "hints") delete(additionalProperties, "device_type_restrictions") + delete(additionalProperties, "prevent_duplicate_devices") delete(additionalProperties, "max_attempts") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_patched_authenticator_web_authn_stage_request.go b/packages/client-go/model_patched_authenticator_web_authn_stage_request.go index 1632927751..ceb41df98b 100644 --- a/packages/client-go/model_patched_authenticator_web_authn_stage_request.go +++ b/packages/client-go/model_patched_authenticator_web_authn_stage_request.go @@ -29,7 +29,9 @@ type PatchedAuthenticatorWebAuthnStageRequest struct { ResidentKeyRequirement *UserVerificationEnum `json:"resident_key_requirement,omitempty"` Hints []WebAuthnHintEnum `json:"hints,omitempty"` DeviceTypeRestrictions []string `json:"device_type_restrictions,omitempty"` - MaxAttempts *int32 `json:"max_attempts,omitempty"` + // When enabled, a given device can only be registered once. + PreventDuplicateDevices *bool `json:"prevent_duplicate_devices,omitempty"` + MaxAttempts *int32 `json:"max_attempts,omitempty"` AdditionalProperties map[string]interface{} } @@ -330,6 +332,38 @@ func (o *PatchedAuthenticatorWebAuthnStageRequest) SetDeviceTypeRestrictions(v [ o.DeviceTypeRestrictions = v } +// GetPreventDuplicateDevices returns the PreventDuplicateDevices field value if set, zero value otherwise. +func (o *PatchedAuthenticatorWebAuthnStageRequest) GetPreventDuplicateDevices() bool { + if o == nil || IsNil(o.PreventDuplicateDevices) { + var ret bool + return ret + } + return *o.PreventDuplicateDevices +} + +// GetPreventDuplicateDevicesOk returns a tuple with the PreventDuplicateDevices field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PatchedAuthenticatorWebAuthnStageRequest) GetPreventDuplicateDevicesOk() (*bool, bool) { + if o == nil || IsNil(o.PreventDuplicateDevices) { + return nil, false + } + return o.PreventDuplicateDevices, true +} + +// HasPreventDuplicateDevices returns a boolean if a field has been set. +func (o *PatchedAuthenticatorWebAuthnStageRequest) HasPreventDuplicateDevices() bool { + if o != nil && !IsNil(o.PreventDuplicateDevices) { + return true + } + + return false +} + +// SetPreventDuplicateDevices gets a reference to the given bool and assigns it to the PreventDuplicateDevices field. +func (o *PatchedAuthenticatorWebAuthnStageRequest) SetPreventDuplicateDevices(v bool) { + o.PreventDuplicateDevices = &v +} + // GetMaxAttempts returns the MaxAttempts field value if set, zero value otherwise. func (o *PatchedAuthenticatorWebAuthnStageRequest) GetMaxAttempts() int32 { if o == nil || IsNil(o.MaxAttempts) { @@ -396,6 +430,9 @@ func (o PatchedAuthenticatorWebAuthnStageRequest) ToMap() (map[string]interface{ if !IsNil(o.DeviceTypeRestrictions) { toSerialize["device_type_restrictions"] = o.DeviceTypeRestrictions } + if !IsNil(o.PreventDuplicateDevices) { + toSerialize["prevent_duplicate_devices"] = o.PreventDuplicateDevices + } if !IsNil(o.MaxAttempts) { toSerialize["max_attempts"] = o.MaxAttempts } @@ -429,6 +466,7 @@ func (o *PatchedAuthenticatorWebAuthnStageRequest) UnmarshalJSON(data []byte) (e delete(additionalProperties, "resident_key_requirement") delete(additionalProperties, "hints") delete(additionalProperties, "device_type_restrictions") + delete(additionalProperties, "prevent_duplicate_devices") delete(additionalProperties, "max_attempts") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-rust/src/models/authenticator_web_authn_stage.rs b/packages/client-rust/src/models/authenticator_web_authn_stage.rs index 953a426b9b..46ed9034a2 100644 --- a/packages/client-rust/src/models/authenticator_web_authn_stage.rs +++ b/packages/client-rust/src/models/authenticator_web_authn_stage.rs @@ -65,6 +65,12 @@ pub struct AuthenticatorWebAuthnStage { pub device_type_restrictions: Option>, #[serde(rename = "device_type_restrictions_obj")] pub device_type_restrictions_obj: Vec, + /// When enabled, a given device can only be registered once. + #[serde( + rename = "prevent_duplicate_devices", + skip_serializing_if = "Option::is_none" + )] + pub prevent_duplicate_devices: Option, #[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")] pub max_attempts: Option, } @@ -97,6 +103,7 @@ impl AuthenticatorWebAuthnStage { hints: None, device_type_restrictions: None, device_type_restrictions_obj, + prevent_duplicate_devices: None, max_attempts: None, } } diff --git a/packages/client-rust/src/models/authenticator_web_authn_stage_request.rs b/packages/client-rust/src/models/authenticator_web_authn_stage_request.rs index 38e25d9386..acdc1e48e6 100644 --- a/packages/client-rust/src/models/authenticator_web_authn_stage_request.rs +++ b/packages/client-rust/src/models/authenticator_web_authn_stage_request.rs @@ -47,6 +47,12 @@ pub struct AuthenticatorWebAuthnStageRequest { skip_serializing_if = "Option::is_none" )] pub device_type_restrictions: Option>, + /// When enabled, a given device can only be registered once. + #[serde( + rename = "prevent_duplicate_devices", + skip_serializing_if = "Option::is_none" + )] + pub prevent_duplicate_devices: Option, #[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")] pub max_attempts: Option, } @@ -63,6 +69,7 @@ impl AuthenticatorWebAuthnStageRequest { resident_key_requirement: None, hints: None, device_type_restrictions: None, + prevent_duplicate_devices: None, max_attempts: None, } } diff --git a/packages/client-rust/src/models/patched_authenticator_web_authn_stage_request.rs b/packages/client-rust/src/models/patched_authenticator_web_authn_stage_request.rs index 01d1be35be..921cce9325 100644 --- a/packages/client-rust/src/models/patched_authenticator_web_authn_stage_request.rs +++ b/packages/client-rust/src/models/patched_authenticator_web_authn_stage_request.rs @@ -47,6 +47,12 @@ pub struct PatchedAuthenticatorWebAuthnStageRequest { skip_serializing_if = "Option::is_none" )] pub device_type_restrictions: Option>, + /// When enabled, a given device can only be registered once. + #[serde( + rename = "prevent_duplicate_devices", + skip_serializing_if = "Option::is_none" + )] + pub prevent_duplicate_devices: Option, #[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")] pub max_attempts: Option, } @@ -63,6 +69,7 @@ impl PatchedAuthenticatorWebAuthnStageRequest { resident_key_requirement: None, hints: None, device_type_restrictions: None, + prevent_duplicate_devices: None, max_attempts: None, } } diff --git a/packages/client-ts/src/models/AuthenticatorWebAuthnStage.ts b/packages/client-ts/src/models/AuthenticatorWebAuthnStage.ts index 4a7f35c626..38d879741d 100644 --- a/packages/client-ts/src/models/AuthenticatorWebAuthnStage.ts +++ b/packages/client-ts/src/models/AuthenticatorWebAuthnStage.ts @@ -145,6 +145,12 @@ export interface AuthenticatorWebAuthnStage { * @memberof AuthenticatorWebAuthnStage */ readonly deviceTypeRestrictionsObj: Array; + /** + * When enabled, a given device can only be registered once. + * @type {boolean} + * @memberof AuthenticatorWebAuthnStage + */ + preventDuplicateDevices?: boolean; /** * * @type {number} @@ -195,6 +201,7 @@ export function AuthenticatorWebAuthnStageFromJSONTyped(json: any, ignoreDiscrim 'hints': json['hints'] == null ? undefined : ((json['hints'] as Array).map(WebAuthnHintEnumFromJSON)), 'deviceTypeRestrictions': json['device_type_restrictions'] == null ? undefined : json['device_type_restrictions'], 'deviceTypeRestrictionsObj': ((json['device_type_restrictions_obj'] as Array).map(WebAuthnDeviceTypeFromJSON)), + 'preventDuplicateDevices': json['prevent_duplicate_devices'] == null ? undefined : json['prevent_duplicate_devices'], 'maxAttempts': json['max_attempts'] == null ? undefined : json['max_attempts'], }; } @@ -218,6 +225,7 @@ export function AuthenticatorWebAuthnStageToJSONTyped(value?: Omit).map(WebAuthnHintEnumToJSON)), 'device_type_restrictions': value['deviceTypeRestrictions'], + 'prevent_duplicate_devices': value['preventDuplicateDevices'], 'max_attempts': value['maxAttempts'], }; } diff --git a/packages/client-ts/src/models/AuthenticatorWebAuthnStageRequest.ts b/packages/client-ts/src/models/AuthenticatorWebAuthnStageRequest.ts index 170a360bd0..31e7d8fb51 100644 --- a/packages/client-ts/src/models/AuthenticatorWebAuthnStageRequest.ts +++ b/packages/client-ts/src/models/AuthenticatorWebAuthnStageRequest.ts @@ -89,6 +89,12 @@ export interface AuthenticatorWebAuthnStageRequest { * @memberof AuthenticatorWebAuthnStageRequest */ deviceTypeRestrictions?: Array; + /** + * When enabled, a given device can only be registered once. + * @type {boolean} + * @memberof AuthenticatorWebAuthnStageRequest + */ + preventDuplicateDevices?: boolean; /** * * @type {number} @@ -125,6 +131,7 @@ export function AuthenticatorWebAuthnStageRequestFromJSONTyped(json: any, ignore 'residentKeyRequirement': json['resident_key_requirement'] == null ? undefined : UserVerificationEnumFromJSON(json['resident_key_requirement']), 'hints': json['hints'] == null ? undefined : ((json['hints'] as Array).map(WebAuthnHintEnumFromJSON)), 'deviceTypeRestrictions': json['device_type_restrictions'] == null ? undefined : json['device_type_restrictions'], + 'preventDuplicateDevices': json['prevent_duplicate_devices'] == null ? undefined : json['prevent_duplicate_devices'], 'maxAttempts': json['max_attempts'] == null ? undefined : json['max_attempts'], }; } @@ -148,6 +155,7 @@ export function AuthenticatorWebAuthnStageRequestToJSONTyped(value?: Authenticat 'resident_key_requirement': UserVerificationEnumToJSON(value['residentKeyRequirement']), 'hints': value['hints'] == null ? undefined : ((value['hints'] as Array).map(WebAuthnHintEnumToJSON)), 'device_type_restrictions': value['deviceTypeRestrictions'], + 'prevent_duplicate_devices': value['preventDuplicateDevices'], 'max_attempts': value['maxAttempts'], }; } diff --git a/packages/client-ts/src/models/PatchedAuthenticatorWebAuthnStageRequest.ts b/packages/client-ts/src/models/PatchedAuthenticatorWebAuthnStageRequest.ts index 5e0ba99528..8543673f2e 100644 --- a/packages/client-ts/src/models/PatchedAuthenticatorWebAuthnStageRequest.ts +++ b/packages/client-ts/src/models/PatchedAuthenticatorWebAuthnStageRequest.ts @@ -89,6 +89,12 @@ export interface PatchedAuthenticatorWebAuthnStageRequest { * @memberof PatchedAuthenticatorWebAuthnStageRequest */ deviceTypeRestrictions?: Array; + /** + * When enabled, a given device can only be registered once. + * @type {boolean} + * @memberof PatchedAuthenticatorWebAuthnStageRequest + */ + preventDuplicateDevices?: boolean; /** * * @type {number} @@ -124,6 +130,7 @@ export function PatchedAuthenticatorWebAuthnStageRequestFromJSONTyped(json: any, 'residentKeyRequirement': json['resident_key_requirement'] == null ? undefined : UserVerificationEnumFromJSON(json['resident_key_requirement']), 'hints': json['hints'] == null ? undefined : ((json['hints'] as Array).map(WebAuthnHintEnumFromJSON)), 'deviceTypeRestrictions': json['device_type_restrictions'] == null ? undefined : json['device_type_restrictions'], + 'preventDuplicateDevices': json['prevent_duplicate_devices'] == null ? undefined : json['prevent_duplicate_devices'], 'maxAttempts': json['max_attempts'] == null ? undefined : json['max_attempts'], }; } @@ -147,6 +154,7 @@ export function PatchedAuthenticatorWebAuthnStageRequestToJSONTyped(value?: Patc 'resident_key_requirement': UserVerificationEnumToJSON(value['residentKeyRequirement']), 'hints': value['hints'] == null ? undefined : ((value['hints'] as Array).map(WebAuthnHintEnumToJSON)), 'device_type_restrictions': value['deviceTypeRestrictions'], + 'prevent_duplicate_devices': value['preventDuplicateDevices'], 'max_attempts': value['maxAttempts'], }; } diff --git a/schema.yml b/schema.yml index 964715fb7d..2a711de85e 100644 --- a/schema.yml +++ b/schema.yml @@ -35147,6 +35147,9 @@ components: items: $ref: '#/components/schemas/WebAuthnDeviceType' readOnly: true + prevent_duplicate_devices: + type: boolean + description: When enabled, a given device can only be registered once. max_attempts: type: integer maximum: 2147483647 @@ -35192,6 +35195,9 @@ components: items: type: string format: uuid + prevent_duplicate_devices: + type: boolean + description: When enabled, a given device can only be registered once. max_attempts: type: integer maximum: 2147483647 @@ -47324,6 +47330,9 @@ components: items: type: string format: uuid + prevent_duplicate_devices: + type: boolean + description: When enabled, a given device can only be registered once. max_attempts: type: integer maximum: 2147483647 diff --git a/web/src/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm.ts b/web/src/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm.ts index 18124efe7d..93c4c9f434 100644 --- a/web/src/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm.ts +++ b/web/src/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm.ts @@ -3,6 +3,7 @@ import "#elements/ak-dual-select/ak-dual-select-provider"; import "#elements/forms/HorizontalFormElement"; import "#elements/forms/Radio"; import "#elements/forms/SearchSelect/index"; +import "#components/ak-switch-input"; import { DEFAULT_CONFIG } from "#common/api/config"; @@ -205,6 +206,14 @@ export class AuthenticatorWebAuthnStageForm extends BaseStageForm +