stages/authenticator_webauthn: save attestation certificate when creating credential (#20095)

* stages/authenticator_webauthn: save attestation certificate when creating credential

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add toggle

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix migration

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* squash

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better test

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-03-30 12:55:39 +01:00
committed by GitHub
parent 0748a3800f
commit 0b1ba60354
21 changed files with 403 additions and 44 deletions
@@ -26,6 +26,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
"hints",
"device_type_restrictions",
"device_type_restrictions_obj",
"prevent_duplicate_devices",
"max_attempts",
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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
@@ -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.")
@@ -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"
}
}
@@ -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)"""
+5
View File
@@ -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,
+40 -2
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -65,6 +65,12 @@ pub struct AuthenticatorWebAuthnStage {
pub device_type_restrictions: Option<Vec<uuid::Uuid>>,
#[serde(rename = "device_type_restrictions_obj")]
pub device_type_restrictions_obj: Vec<models::WebAuthnDeviceType>,
/// 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<bool>,
#[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
}
@@ -97,6 +103,7 @@ impl AuthenticatorWebAuthnStage {
hints: None,
device_type_restrictions: None,
device_type_restrictions_obj,
prevent_duplicate_devices: None,
max_attempts: None,
}
}
@@ -47,6 +47,12 @@ pub struct AuthenticatorWebAuthnStageRequest {
skip_serializing_if = "Option::is_none"
)]
pub device_type_restrictions: Option<Vec<uuid::Uuid>>,
/// 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<bool>,
#[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
}
@@ -63,6 +69,7 @@ impl AuthenticatorWebAuthnStageRequest {
resident_key_requirement: None,
hints: None,
device_type_restrictions: None,
prevent_duplicate_devices: None,
max_attempts: None,
}
}
@@ -47,6 +47,12 @@ pub struct PatchedAuthenticatorWebAuthnStageRequest {
skip_serializing_if = "Option::is_none"
)]
pub device_type_restrictions: Option<Vec<uuid::Uuid>>,
/// 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<bool>,
#[serde(rename = "max_attempts", skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
}
@@ -63,6 +69,7 @@ impl PatchedAuthenticatorWebAuthnStageRequest {
resident_key_requirement: None,
hints: None,
device_type_restrictions: None,
prevent_duplicate_devices: None,
max_attempts: None,
}
}
@@ -145,6 +145,12 @@ export interface AuthenticatorWebAuthnStage {
* @memberof AuthenticatorWebAuthnStage
*/
readonly deviceTypeRestrictionsObj: Array<WebAuthnDeviceType>;
/**
* 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<any>).map(WebAuthnHintEnumFromJSON)),
'deviceTypeRestrictions': json['device_type_restrictions'] == null ? undefined : json['device_type_restrictions'],
'deviceTypeRestrictionsObj': ((json['device_type_restrictions_obj'] as Array<any>).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<Authenticator
'resident_key_requirement': UserVerificationEnumToJSON(value['residentKeyRequirement']),
'hints': value['hints'] == null ? undefined : ((value['hints'] as Array<any>).map(WebAuthnHintEnumToJSON)),
'device_type_restrictions': value['deviceTypeRestrictions'],
'prevent_duplicate_devices': value['preventDuplicateDevices'],
'max_attempts': value['maxAttempts'],
};
}
@@ -89,6 +89,12 @@ export interface AuthenticatorWebAuthnStageRequest {
* @memberof AuthenticatorWebAuthnStageRequest
*/
deviceTypeRestrictions?: Array<string>;
/**
* 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<any>).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<any>).map(WebAuthnHintEnumToJSON)),
'device_type_restrictions': value['deviceTypeRestrictions'],
'prevent_duplicate_devices': value['preventDuplicateDevices'],
'max_attempts': value['maxAttempts'],
};
}
@@ -89,6 +89,12 @@ export interface PatchedAuthenticatorWebAuthnStageRequest {
* @memberof PatchedAuthenticatorWebAuthnStageRequest
*/
deviceTypeRestrictions?: Array<string>;
/**
* 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<any>).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<any>).map(WebAuthnHintEnumToJSON)),
'device_type_restrictions': value['deviceTypeRestrictions'],
'prevent_duplicate_devices': value['preventDuplicateDevices'],
'max_attempts': value['maxAttempts'],
};
}
+9
View File
@@ -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
@@ -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<AuthenticatorW
"Maximum allowed registration attempts. When set to 0 attempts, attempts are not limited.",
)}
></ak-number-input>
<ak-switch-input
name="preventDuplicateDevices"
label=${msg("Prevent duplicate devices")}
?checked=${this.instance?.preventDuplicateDevices ?? true}
help=${msg(
"When enabled, any unique authenticator can only be registered once.",
)}
></ak-switch-input>
<ak-form-element-horizontal
label=${msg("Device type restrictions")}
name="deviceTypeRestrictions"
@@ -22,6 +22,10 @@ Configure if the created authenticator is stored in the encrypted memory on the
Configure if authentik will require either a removable device (like a YubiKey, Google Titan, etc) or a non-removable device (like Windows Hello, TouchID or password managers), or not send a requirement.
### Prevent duplicate devices
When enabled, any unique authenticator can only be registered once. This check can only be enforced if the authenticator stores a unique attestsion certificate.
#### Device type restrictions
Optionally restrict the types of devices allowed to be enrolled. This option can be used to ensure users are only able to enroll FIPS-compliant devices for example.