stages/authenticator_validate: add flag to configure user_verification for webauthn devices
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
91897b0ac6
commit
a2e512c36c
|
@ -145,6 +145,7 @@ SPECTACULAR_SETTINGS = {
|
|||
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
|
||||
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||
},
|
||||
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
||||
"POSTPROCESSING_HOOKS": [
|
||||
|
|
|
@ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
|||
"device_classes",
|
||||
"configuration_stages",
|
||||
"last_auth_threshold",
|
||||
"webauthn_user_verification",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -29,8 +29,8 @@ from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
|||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||
from authentik.stages.authenticator_validate.models import DeviceClasses
|
||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
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.utils import get_origin, get_rp_id
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE
|
||||
|
@ -46,29 +46,35 @@ class DeviceChallenge(PassiveSerializer):
|
|||
challenge = JSONField()
|
||||
|
||||
|
||||
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
|
||||
def get_challenge_for_device(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
|
||||
) -> dict:
|
||||
"""Generate challenge for a single device"""
|
||||
if isinstance(device, WebAuthnDevice):
|
||||
return get_webauthn_challenge(request, device)
|
||||
return get_webauthn_challenge(request, stage, device)
|
||||
# Code-based challenges have no hints
|
||||
return {}
|
||||
|
||||
|
||||
def get_webauthn_challenge_without_user(request: HttpRequest) -> dict:
|
||||
def get_webauthn_challenge_without_user(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage
|
||||
) -> dict:
|
||||
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
|
||||
who the device belongs to."""
|
||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
authentication_options = generate_authentication_options(
|
||||
rp_id=get_rp_id(request),
|
||||
allow_credentials=[],
|
||||
user_verification=stage.webauthn_user_verification,
|
||||
)
|
||||
|
||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
||||
|
||||
return loads(options_to_json(authentication_options))
|
||||
|
||||
|
||||
def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice] = None) -> dict:
|
||||
def get_webauthn_challenge(
|
||||
request: HttpRequest, stage: AuthenticatorValidateStage, device: Optional[WebAuthnDevice] = None
|
||||
) -> dict:
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
|
||||
|
@ -83,6 +89,7 @@ def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice
|
|||
authentication_options = generate_authentication_options(
|
||||
rp_id=get_rp_id(request),
|
||||
allow_credentials=allowed_credentials,
|
||||
user_verification=stage.webauthn_user_verification,
|
||||
)
|
||||
|
||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
||||
|
@ -129,6 +136,8 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
|
|||
if not device:
|
||||
raise ValidationError("Invalid device")
|
||||
|
||||
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||
|
||||
try:
|
||||
authentication_verification = verify_authentication_response(
|
||||
credential=AuthenticationCredential.parse_raw(dumps(data)),
|
||||
|
@ -137,7 +146,7 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
|
|||
expected_origin=get_origin(request),
|
||||
credential_public_key=base64url_to_bytes(device.public_key),
|
||||
credential_current_sign_count=device.sign_count,
|
||||
require_user_verification=False,
|
||||
require_user_verification=stage.webauthn_user_verification == UserVerification.REQUIRED,
|
||||
)
|
||||
except InvalidAuthenticationResponse as exc:
|
||||
LOGGER.warning("Assertion failed", exc=exc)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 4.1.3 on 2022-11-21 16:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_validate",
|
||||
"0011_authenticatorvalidatestage_last_auth_threshold",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="webauthn_user_verification",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("required", "Required"),
|
||||
("preferred", "Preferred"),
|
||||
("discouraged", "Discouraged"),
|
||||
],
|
||||
default="preferred",
|
||||
help_text="Enforce user verification for WebAuthn devices.",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -8,6 +8,7 @@ from rest_framework.serializers import BaseSerializer
|
|||
|
||||
from authentik.flows.models import NotConfiguredAction, Stage
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification
|
||||
|
||||
|
||||
class DeviceClasses(models.TextChoices):
|
||||
|
@ -69,6 +70,12 @@ class AuthenticatorValidateStage(Stage):
|
|||
),
|
||||
)
|
||||
|
||||
webauthn_user_verification = models.TextField(
|
||||
help_text=_("Enforce user verification for WebAuthn devices."),
|
||||
choices=UserVerification.choices,
|
||||
default=UserVerification.PREFERRED,
|
||||
)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||
|
|
|
@ -177,7 +177,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||
data={
|
||||
"device_class": device_class,
|
||||
"device_uid": device.pk,
|
||||
"challenge": get_challenge_for_device(self.request, device),
|
||||
"challenge": get_challenge_for_device(self.request, stage, device),
|
||||
}
|
||||
)
|
||||
challenge.is_valid()
|
||||
|
@ -194,7 +194,10 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||
data={
|
||||
"device_class": DeviceClasses.WEBAUTHN,
|
||||
"device_uid": -1,
|
||||
"challenge": get_webauthn_challenge_without_user(self.request),
|
||||
"challenge": get_webauthn_challenge_without_user(
|
||||
self.request,
|
||||
self.executor.current_stage,
|
||||
),
|
||||
}
|
||||
)
|
||||
challenge.is_valid()
|
||||
|
|
|
@ -260,7 +260,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
|
|||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.TOTP],
|
||||
)
|
||||
self.assertEqual(get_challenge_for_device(request, totp_device), {})
|
||||
self.assertEqual(get_challenge_for_device(request, stage, totp_device), {})
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_code(
|
||||
"1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user
|
||||
|
|
|
@ -21,7 +21,7 @@ from authentik.stages.authenticator_validate.challenge import (
|
|||
)
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
|
||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
@ -90,8 +90,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||
last_auth_threshold="milliseconds=0",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
)
|
||||
challenge = get_challenge_for_device(request, webauthn_device)
|
||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
||||
del challenge["challenge"]
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
|
@ -118,6 +119,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||
request = get_request("/")
|
||||
request.user = self.user
|
||||
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
last_auth_threshold="milliseconds=0",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
)
|
||||
webauthn_device = WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key=(
|
||||
|
@ -128,7 +136,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
challenge = get_challenge_for_device(request, webauthn_device)
|
||||
challenge = get_challenge_for_device(request, stage, webauthn_device)
|
||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
|
@ -149,7 +157,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||
def test_get_challenge_userless(self):
|
||||
"""Test webauthn (userless)"""
|
||||
request = get_request("/")
|
||||
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key=(
|
||||
|
@ -160,7 +170,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
challenge = get_webauthn_challenge_without_user(request)
|
||||
challenge = get_webauthn_challenge_without_user(request, stage)
|
||||
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||
self.assertEqual(
|
||||
challenge,
|
||||
|
|
12
schema.yml
12
schema.yml
|
@ -25842,6 +25842,10 @@ components:
|
|||
type: string
|
||||
description: If any of the user's device has been used within this threshold,
|
||||
this stage will be skipped
|
||||
webauthn_user_verification:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||
description: Enforce user verification for WebAuthn devices.
|
||||
required:
|
||||
- component
|
||||
- meta_model_name
|
||||
|
@ -25880,6 +25884,10 @@ components:
|
|||
minLength: 1
|
||||
description: If any of the user's device has been used within this threshold,
|
||||
this stage will be skipped
|
||||
webauthn_user_verification:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||
description: Enforce user verification for WebAuthn devices.
|
||||
required:
|
||||
- name
|
||||
AuthenticatorValidationChallenge:
|
||||
|
@ -33326,6 +33334,10 @@ components:
|
|||
minLength: 1
|
||||
description: If any of the user's device has been used within this threshold,
|
||||
this stage will be skipped
|
||||
webauthn_user_verification:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||
description: Enforce user verification for WebAuthn devices.
|
||||
PatchedBlueprintInstanceRequest:
|
||||
type: object
|
||||
description: Info about a single blueprint instance file
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
|
@ -16,6 +17,7 @@ import {
|
|||
DeviceClassesEnum,
|
||||
NotConfiguredActionEnum,
|
||||
StagesApi,
|
||||
UserVerificationEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-stage-authenticator-validate-form")
|
||||
|
@ -182,6 +184,35 @@ export class AuthenticatorValidateStageForm extends ModelForm<AuthenticatorValid
|
|||
</option>
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`WebAuthn User verification`}
|
||||
?required=${true}
|
||||
name="webauthnUserVerification"
|
||||
>
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value="${UserVerificationEnum.Required}"
|
||||
?selected=${this.instance?.webauthnUserVerification ===
|
||||
UserVerificationEnum.Required}
|
||||
>
|
||||
${t`User verification must occur.`}
|
||||
</option>
|
||||
<option
|
||||
value="${UserVerificationEnum.Preferred}"
|
||||
?selected=${this.instance?.webauthnUserVerification ===
|
||||
UserVerificationEnum.Preferred}
|
||||
>
|
||||
${t`User verification is preferred if available, but not required.`}
|
||||
</option>
|
||||
<option
|
||||
value="${UserVerificationEnum.Discouraged}"
|
||||
?selected=${this.instance?.webauthnUserVerification ===
|
||||
UserVerificationEnum.Discouraged}
|
||||
>
|
||||
${t`User verification should not occur.`}
|
||||
</option>
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
${this.showConfigurationStages
|
||||
? html`
|
||||
<ak-form-element-horizontal
|
||||
|
|
Reference in a new issue