stages/authenticator_webauthn: add option to configure max attempts (#15041)
* house keeping - migrate to session part 1 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup v2 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add max_attempts Signed-off-by: Jens Langhammer <jens@goauthentik.io> * teeny tiny cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -13,7 +13,6 @@ class Command(TenantCommand):
|
|||||||
parser.add_argument("usernames", nargs="*", type=str)
|
parser.add_argument("usernames", nargs="*", type=str)
|
||||||
|
|
||||||
def handle_per_tenant(self, **options):
|
def handle_per_tenant(self, **options):
|
||||||
print(options)
|
|
||||||
new_type = UserTypes(options["type"])
|
new_type = UserTypes(options["type"])
|
||||||
qs = (
|
qs = (
|
||||||
User.objects.exclude_anonymous()
|
User.objects.exclude_anonymous()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Validation stage challenge checking"""
|
"""Validation stage challenge checking"""
|
||||||
|
|
||||||
from json import loads
|
from json import loads
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -36,10 +37,12 @@ from authentik.stages.authenticator_email.models import EmailDevice
|
|||||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
|
||||||
|
|
||||||
|
|
||||||
class DeviceChallenge(PassiveSerializer):
|
class DeviceChallenge(PassiveSerializer):
|
||||||
@ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer):
|
|||||||
|
|
||||||
|
|
||||||
def get_challenge_for_device(
|
def get_challenge_for_device(
|
||||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
|
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate challenge for a single device"""
|
"""Generate challenge for a single device"""
|
||||||
if isinstance(device, WebAuthnDevice):
|
if isinstance(device, WebAuthnDevice):
|
||||||
return get_webauthn_challenge(request, stage, device)
|
return get_webauthn_challenge(stage_view, stage, device)
|
||||||
if isinstance(device, EmailDevice):
|
if isinstance(device, EmailDevice):
|
||||||
return {"email": mask_email(device.email)}
|
return {"email": mask_email(device.email)}
|
||||||
# Code-based challenges have no hints
|
# Code-based challenges have no hints
|
||||||
@ -64,26 +67,30 @@ def get_challenge_for_device(
|
|||||||
|
|
||||||
|
|
||||||
def get_webauthn_challenge_without_user(
|
def get_webauthn_challenge_without_user(
|
||||||
request: HttpRequest, stage: AuthenticatorValidateStage
|
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
|
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
|
||||||
who the device belongs to."""
|
who the device belongs to."""
|
||||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||||
authentication_options = generate_authentication_options(
|
authentication_options = generate_authentication_options(
|
||||||
rp_id=get_rp_id(request),
|
rp_id=get_rp_id(stage_view.request),
|
||||||
allow_credentials=[],
|
allow_credentials=[],
|
||||||
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
||||||
)
|
)
|
||||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
|
||||||
|
authentication_options.challenge
|
||||||
|
)
|
||||||
|
|
||||||
return loads(options_to_json(authentication_options))
|
return loads(options_to_json(authentication_options))
|
||||||
|
|
||||||
|
|
||||||
def get_webauthn_challenge(
|
def get_webauthn_challenge(
|
||||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None
|
stage_view: "AuthenticatorValidateStageView",
|
||||||
|
stage: AuthenticatorValidateStage,
|
||||||
|
device: WebAuthnDevice | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send the client a challenge that we'll check later"""
|
"""Send the client a challenge that we'll check later"""
|
||||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||||
|
|
||||||
allowed_credentials = []
|
allowed_credentials = []
|
||||||
|
|
||||||
@ -94,12 +101,14 @@ def get_webauthn_challenge(
|
|||||||
allowed_credentials.append(user_device.descriptor)
|
allowed_credentials.append(user_device.descriptor)
|
||||||
|
|
||||||
authentication_options = generate_authentication_options(
|
authentication_options = generate_authentication_options(
|
||||||
rp_id=get_rp_id(request),
|
rp_id=get_rp_id(stage_view.request),
|
||||||
allow_credentials=allowed_credentials,
|
allow_credentials=allowed_credentials,
|
||||||
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
|
||||||
)
|
)
|
||||||
|
|
||||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
|
||||||
|
authentication_options.challenge
|
||||||
|
)
|
||||||
|
|
||||||
return loads(options_to_json(authentication_options))
|
return loads(options_to_json(authentication_options))
|
||||||
|
|
||||||
@ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
|
|||||||
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
|
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
|
||||||
"""Validate WebAuthn Challenge"""
|
"""Validate WebAuthn Challenge"""
|
||||||
request = stage_view.request
|
request = stage_view.request
|
||||||
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
|
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
|
||||||
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||||
try:
|
try:
|
||||||
credential = parse_authentication_credential_json(data)
|
credential = parse_authentication_credential_json(data)
|
||||||
|
|||||||
@ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||||||
data={
|
data={
|
||||||
"device_class": device_class,
|
"device_class": device_class,
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": get_challenge_for_device(self.request, stage, device),
|
"challenge": get_challenge_for_device(self, stage, device),
|
||||||
"last_used": device.last_used,
|
"last_used": device.last_used,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||||||
"device_class": DeviceClasses.WEBAUTHN,
|
"device_class": DeviceClasses.WEBAUTHN,
|
||||||
"device_uid": -1,
|
"device_uid": -1,
|
||||||
"challenge": get_webauthn_challenge_without_user(
|
"challenge": get_webauthn_challenge_without_user(
|
||||||
self.request,
|
self,
|
||||||
self.executor.current_stage,
|
self.executor.current_stage,
|
||||||
),
|
),
|
||||||
"last_used": None,
|
"last_used": None,
|
||||||
|
|||||||
@ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
|||||||
WebAuthnDevice,
|
WebAuthnDevice,
|
||||||
WebAuthnDeviceType,
|
WebAuthnDeviceType,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
from authentik.stages.user_login.models import UserLoginStage
|
from authentik.stages.user_login.models import UserLoginStage
|
||||||
@ -103,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
device_classes=[DeviceClasses.WEBAUTHN],
|
device_classes=[DeviceClasses.WEBAUTHN],
|
||||||
webauthn_user_verification=UserVerification.PREFERRED,
|
webauthn_user_verification=UserVerification.PREFERRED,
|
||||||
)
|
)
|
||||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
plan = FlowPlan("")
|
||||||
|
stage_view = AuthenticatorValidateStageView(
|
||||||
|
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||||
|
)
|
||||||
|
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||||
del challenge["challenge"]
|
del challenge["challenge"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
challenge,
|
challenge,
|
||||||
@ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
validate_challenge_webauthn(
|
validate_challenge_webauthn(
|
||||||
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
|
{},
|
||||||
|
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
|
||||||
|
self.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_device_challenge_webauthn_restricted(self):
|
def test_device_challenge_webauthn_restricted(self):
|
||||||
@ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
sign_count=0,
|
sign_count=0,
|
||||||
rp_id=generate_id(),
|
rp_id=generate_id(),
|
||||||
)
|
)
|
||||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
plan = FlowPlan("")
|
||||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
|
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||||
|
)
|
||||||
|
stage_view = AuthenticatorValidateStageView(
|
||||||
|
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||||
|
)
|
||||||
|
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
challenge,
|
challenge["allowCredentials"],
|
||||||
{
|
[
|
||||||
"allowCredentials": [
|
|
||||||
{
|
{
|
||||||
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
|
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
|
||||||
"type": "public-key",
|
"type": "public-key",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"challenge": bytes_to_base64url(webauthn_challenge),
|
)
|
||||||
"rpId": "testserver",
|
self.assertIsNotNone(challenge["challenge"])
|
||||||
"timeout": 60000,
|
self.assertEqual(
|
||||||
"userVerification": "preferred",
|
challenge["rpId"],
|
||||||
},
|
"testserver",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
challenge["timeout"],
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
challenge["userVerification"],
|
||||||
|
"preferred",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_challenge_userless(self):
|
def test_get_challenge_userless(self):
|
||||||
@ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
sign_count=0,
|
sign_count=0,
|
||||||
rp_id=generate_id(),
|
rp_id=generate_id(),
|
||||||
)
|
)
|
||||||
challenge = get_webauthn_challenge_without_user(request, stage)
|
plan = FlowPlan("")
|
||||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
stage_view = AuthenticatorValidateStageView(
|
||||||
self.assertEqual(
|
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||||
challenge,
|
|
||||||
{
|
|
||||||
"allowCredentials": [],
|
|
||||||
"challenge": bytes_to_base64url(webauthn_challenge),
|
|
||||||
"rpId": "testserver",
|
|
||||||
"timeout": 60000,
|
|
||||||
"userVerification": "preferred",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
challenge = get_webauthn_challenge_without_user(stage_view, stage)
|
||||||
|
self.assertEqual(challenge["allowCredentials"], [])
|
||||||
|
self.assertIsNotNone(challenge["challenge"])
|
||||||
|
self.assertEqual(challenge["rpId"], "testserver")
|
||||||
|
self.assertEqual(challenge["timeout"], 60000)
|
||||||
|
self.assertEqual(challenge["userVerification"], "preferred")
|
||||||
|
|
||||||
def test_validate_challenge_unrestricted(self):
|
def test_validate_challenge_unrestricted(self):
|
||||||
"""Test webauthn authentication (unrestricted webauthn device)"""
|
"""Test webauthn authentication (unrestricted webauthn device)"""
|
||||||
@ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"last_used": None,
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
|
||||||
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
||||||
)
|
)
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"last_used": None,
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
|
||||||
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
|
||||||
)
|
)
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"last_used": None,
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
|
||||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||||
)
|
)
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||||
device_classes=[DeviceClasses.WEBAUTHN],
|
device_classes=[DeviceClasses.WEBAUTHN],
|
||||||
)
|
)
|
||||||
stage_view = AuthenticatorValidateStageView(
|
plan = FlowPlan(flow.pk.hex)
|
||||||
FlowExecutorView(flow=flow, current_stage=stage), request=request
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
)
|
|
||||||
request = get_request("/")
|
|
||||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
|
||||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||||
)
|
)
|
||||||
request.session.save()
|
request = get_request("/")
|
||||||
|
|
||||||
stage_view = AuthenticatorValidateStageView(
|
stage_view = AuthenticatorValidateStageView(
|
||||||
FlowExecutorView(flow=flow, current_stage=stage), request=request
|
FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request
|
||||||
)
|
)
|
||||||
request.META["SERVER_NAME"] = "localhost"
|
request.META["SERVER_NAME"] = "localhost"
|
||||||
request.META["SERVER_PORT"] = "9000"
|
request.META["SERVER_PORT"] = "9000"
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
|
|||||||
"resident_key_requirement",
|
"resident_key_requirement",
|
||||||
"device_type_restrictions",
|
"device_type_restrictions",
|
||||||
"device_type_restrictions_obj",
|
"device_type_restrictions_obj",
|
||||||
|
"max_attempts",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-06-13 22:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"authentik_stages_authenticator_webauthn",
|
||||||
|
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="authenticatorwebauthnstage",
|
||||||
|
name="max_attempts",
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||||||
|
|
||||||
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
|
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
|
||||||
|
|
||||||
|
max_attempts = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
def serializer(self) -> type[BaseSerializer]:
|
||||||
from authentik.stages.authenticator_webauthn.api.stages import (
|
from authentik.stages.authenticator_webauthn.api.stages import (
|
||||||
|
|||||||
@ -5,12 +5,13 @@ from uuid import UUID
|
|||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.http.request import QueryDict
|
from django.http.request import QueryDict
|
||||||
|
from django.utils.translation import gettext as __
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.fields import CharField
|
from rest_framework.fields import CharField
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from webauthn import options_to_json
|
from webauthn import options_to_json
|
||||||
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
|
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
|
||||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
from webauthn.helpers.exceptions import WebAuthnException
|
||||||
from webauthn.helpers.structs import (
|
from webauthn.helpers.structs import (
|
||||||
AttestationConveyancePreference,
|
AttestationConveyancePreference,
|
||||||
AuthenticatorAttachment,
|
AuthenticatorAttachment,
|
||||||
@ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import (
|
|||||||
)
|
)
|
||||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||||
|
|
||||||
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
|
PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge"
|
||||||
|
PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt"
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
|
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
|
||||||
@ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
|||||||
|
|
||||||
def validate_response(self, response: dict) -> dict:
|
def validate_response(self, response: dict) -> dict:
|
||||||
"""Validate webauthn challenge response"""
|
"""Validate webauthn challenge response"""
|
||||||
challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registration: VerifiedRegistration = verify_registration_response(
|
registration: VerifiedRegistration = verify_registration_response(
|
||||||
@ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
|||||||
expected_rp_id=get_rp_id(self.request),
|
expected_rp_id=get_rp_id(self.request),
|
||||||
expected_origin=get_origin(self.request),
|
expected_origin=get_origin(self.request),
|
||||||
)
|
)
|
||||||
except InvalidRegistrationResponse as exc:
|
except WebAuthnException as exc:
|
||||||
self.stage.logger.warning("registration failed", exc=exc)
|
self.stage.logger.warning("registration failed", exc=exc)
|
||||||
raise ValidationError(f"Registration failed. Error: {exc}") from None
|
raise ValidationError(f"Registration failed. Error: {exc}") from None
|
||||||
|
|
||||||
@ -114,9 +116,10 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||||||
response_class = AuthenticatorWebAuthnChallengeResponse
|
response_class = AuthenticatorWebAuthnChallengeResponse
|
||||||
|
|
||||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||||
# clear session variables prior to starting a new registration
|
|
||||||
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
|
||||||
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
||||||
|
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
|
||||||
|
# clear flow variables prior to starting a new registration
|
||||||
|
self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
|
||||||
user = self.get_pending_user()
|
user = self.get_pending_user()
|
||||||
|
|
||||||
# library accepts none so we store null in the database, but if there is a value
|
# library accepts none so we store null in the database, but if there is a value
|
||||||
@ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||||||
attestation=AttestationConveyancePreference.DIRECT,
|
attestation=AttestationConveyancePreference.DIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge
|
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge
|
||||||
self.request.session.save()
|
|
||||||
return AuthenticatorWebAuthnChallenge(
|
return AuthenticatorWebAuthnChallenge(
|
||||||
data={
|
data={
|
||||||
"registration": loads(options_to_json(registration_options)),
|
"registration": loads(options_to_json(registration_options)),
|
||||||
@ -153,6 +155,24 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||||||
response.user = self.get_pending_user()
|
response.user = self.get_pending_user()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def challenge_invalid(self, response):
|
||||||
|
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
||||||
|
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
|
||||||
|
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1
|
||||||
|
if (
|
||||||
|
stage.max_attempts > 0
|
||||||
|
and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts
|
||||||
|
):
|
||||||
|
return self.executor.stage_invalid(
|
||||||
|
__(
|
||||||
|
"Exceeded maximum attempts. "
|
||||||
|
"Contact your {brand} administrator for help.".format(
|
||||||
|
brand=self.request.brand.branding_title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return super().challenge_invalid(response)
|
||||||
|
|
||||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
# Webauthn Challenge has already been validated
|
# Webauthn Challenge has already been validated
|
||||||
webauthn_credential: VerifiedRegistration = response.validated_data["response"]
|
webauthn_credential: VerifiedRegistration = response.validated_data["response"]
|
||||||
@ -179,6 +199,3 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||||||
else:
|
else:
|
||||||
return self.executor.stage_invalid("Device with Credential ID already exists.")
|
return self.executor.stage_invalid("Device with Credential ID already exists.")
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
|||||||
WebAuthnDevice,
|
WebAuthnDevice,
|
||||||
WebAuthnDeviceType,
|
WebAuthnDeviceType,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +57,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
self.assertStageResponse(
|
self.assertStageResponse(
|
||||||
@ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
"name": self.user.username,
|
"name": self.user.username,
|
||||||
"displayName": self.user.name,
|
"displayName": self.user.name,
|
||||||
},
|
},
|
||||||
"challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]),
|
"challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]),
|
||||||
"pubKeyCredParams": [
|
"pubKeyCredParams": [
|
||||||
{"type": "public-key", "alg": -7},
|
{"type": "public-key", "alg": -7},
|
||||||
{"type": "public-key", "alg": -8},
|
{"type": "public-key", "alg": -8},
|
||||||
@ -97,11 +100,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
"""Test registration"""
|
"""Test registration"""
|
||||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
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_PENDING_USER] = self.user
|
||||||
session = self.client.session
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||||
session[SESSION_KEY_PLAN] = plan
|
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
|
||||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||||
)
|
)
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
@ -146,11 +149,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
|
|
||||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
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_PENDING_USER] = self.user
|
||||||
session = self.client.session
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||||
session[SESSION_KEY_PLAN] = plan
|
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
|
||||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||||
)
|
)
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
@ -209,11 +212,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
|
|
||||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
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_PENDING_USER] = self.user
|
||||||
session = self.client.session
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||||
session[SESSION_KEY_PLAN] = plan
|
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
|
||||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||||
)
|
)
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
@ -259,11 +262,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
|
|
||||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
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_PENDING_USER] = self.user
|
||||||
session = self.client.session
|
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
|
||||||
session[SESSION_KEY_PLAN] = plan
|
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
|
|
||||||
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
|
||||||
)
|
)
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
@ -298,3 +301,109 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||||
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
|
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_register_max_retries(self):
|
||||||
|
"""Test registration (exceeding max retries)"""
|
||||||
|
self.stage.max_attempts = 2
|
||||||
|
self.stage.save()
|
||||||
|
|
||||||
|
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=="
|
||||||
|
)
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
# first failed request
|
||||||
|
response = self.client.post(
|
||||||
|
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"
|
||||||
|
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
|
||||||
|
),
|
||||||
|
"attestationObject": (
|
||||||
|
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
|
||||||
|
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
|
||||||
|
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
|
||||||
|
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
|
||||||
|
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SERVER_NAME="localhost",
|
||||||
|
SERVER_PORT="9000",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
flow=self.flow,
|
||||||
|
component="ak-stage-authenticator-webauthn",
|
||||||
|
response_errors={
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"string": (
|
||||||
|
"Registration failed. Error: Unable to decode "
|
||||||
|
"client_data_json bytes as JSON"
|
||||||
|
),
|
||||||
|
"code": "invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
# Second failed request
|
||||||
|
response = self.client.post(
|
||||||
|
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"
|
||||||
|
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
|
||||||
|
),
|
||||||
|
"attestationObject": (
|
||||||
|
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
|
||||||
|
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
|
||||||
|
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
|
||||||
|
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
|
||||||
|
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SERVER_NAME="localhost",
|
||||||
|
SERVER_PORT="9000",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
flow=self.flow,
|
||||||
|
component="ak-stage-access-denied",
|
||||||
|
error_message=(
|
||||||
|
"Exceeded maximum attempts. Contact your authentik administrator for help."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||||
|
|||||||
@ -13310,6 +13310,12 @@
|
|||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
"title": "Device type restrictions"
|
"title": "Device type restrictions"
|
||||||
|
},
|
||||||
|
"max_attempts": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 2147483647,
|
||||||
|
"title": "Max attempts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
|
|||||||
16
schema.yml
16
schema.yml
@ -34963,6 +34963,10 @@ paths:
|
|||||||
name: friendly_name
|
name: friendly_name
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: max_attempts
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
- in: query
|
- in: query
|
||||||
name: name
|
name: name
|
||||||
schema:
|
schema:
|
||||||
@ -42633,6 +42637,10 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WebAuthnDeviceType'
|
$ref: '#/components/schemas/WebAuthnDeviceType'
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
max_attempts:
|
||||||
|
type: integer
|
||||||
|
maximum: 2147483647
|
||||||
|
minimum: 0
|
||||||
required:
|
required:
|
||||||
- component
|
- component
|
||||||
- device_type_restrictions_obj
|
- device_type_restrictions_obj
|
||||||
@ -42675,6 +42683,10 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
max_attempts:
|
||||||
|
type: integer
|
||||||
|
maximum: 2147483647
|
||||||
|
minimum: 0
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
AuthorizationCodeAuthMethodEnum:
|
AuthorizationCodeAuthMethodEnum:
|
||||||
@ -52625,6 +52637,10 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
max_attempts:
|
||||||
|
type: integer
|
||||||
|
maximum: 2147483647
|
||||||
|
minimum: 0
|
||||||
PatchedBlueprintInstanceRequest:
|
PatchedBlueprintInstanceRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Info about a single blueprint instance file
|
description: Info about a single blueprint instance file
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
|
|||||||
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
|
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
|
||||||
import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
|
import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
|
||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
|
import "@goauthentik/components/ak-number-input";
|
||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
|
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
|
||||||
import { DataProvision } from "@goauthentik/elements/ak-dual-select/types";
|
import { DataProvision } from "@goauthentik/elements/ak-dual-select/types";
|
||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
@ -165,6 +166,15 @@ export class AuthenticatorWebAuthnStageForm extends BaseStageForm<AuthenticatorW
|
|||||||
>
|
>
|
||||||
</ak-radio>
|
</ak-radio>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
|
<ak-number-input
|
||||||
|
label=${msg("Maximum registration attempts")}
|
||||||
|
required
|
||||||
|
name="maxAttempts"
|
||||||
|
value="${this.instance?.maxAttempts || 0}"
|
||||||
|
help=${msg(
|
||||||
|
"Maximum allowed registration attempts. When set to 0 attempts, attempts are not limited.",
|
||||||
|
)}
|
||||||
|
></ak-number-input>
|
||||||
<ak-form-element-horizontal
|
<ak-form-element-horizontal
|
||||||
label=${msg("Device type restrictions")}
|
label=${msg("Device type restrictions")}
|
||||||
name="deviceTypeRestrictions"
|
name="deviceTypeRestrictions"
|
||||||
|
|||||||
Reference in New Issue
Block a user