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",
|
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||||
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
|
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
|
||||||
|
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||||
},
|
},
|
||||||
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
||||||
"POSTPROCESSING_HOOKS": [
|
"POSTPROCESSING_HOOKS": [
|
||||||
|
|
|
@ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
||||||
"device_classes",
|
"device_classes",
|
||||||
"configuration_stages",
|
"configuration_stages",
|
||||||
"last_auth_threshold",
|
"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.lib.utils.http import get_client_ip
|
||||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.models import DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
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.authenticator_webauthn.stage import SESSION_KEY_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
|
||||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE
|
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE
|
||||||
|
@ -46,29 +46,35 @@ class DeviceChallenge(PassiveSerializer):
|
||||||
challenge = JSONField()
|
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"""
|
"""Generate challenge for a single device"""
|
||||||
if isinstance(device, WebAuthnDevice):
|
if isinstance(device, WebAuthnDevice):
|
||||||
return get_webauthn_challenge(request, device)
|
return get_webauthn_challenge(request, stage, device)
|
||||||
# Code-based challenges have no hints
|
# Code-based challenges have no hints
|
||||||
return {}
|
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
|
"""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)
|
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||||
authentication_options = generate_authentication_options(
|
authentication_options = generate_authentication_options(
|
||||||
rp_id=get_rp_id(request),
|
rp_id=get_rp_id(request),
|
||||||
allow_credentials=[],
|
allow_credentials=[],
|
||||||
|
user_verification=stage.webauthn_user_verification,
|
||||||
)
|
)
|
||||||
|
|
||||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
||||||
|
|
||||||
return loads(options_to_json(authentication_options))
|
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"""
|
"""Send the client a challenge that we'll check later"""
|
||||||
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
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(
|
authentication_options = generate_authentication_options(
|
||||||
rp_id=get_rp_id(request),
|
rp_id=get_rp_id(request),
|
||||||
allow_credentials=allowed_credentials,
|
allow_credentials=allowed_credentials,
|
||||||
|
user_verification=stage.webauthn_user_verification,
|
||||||
)
|
)
|
||||||
|
|
||||||
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
|
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:
|
if not device:
|
||||||
raise ValidationError("Invalid device")
|
raise ValidationError("Invalid device")
|
||||||
|
|
||||||
|
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||||
|
|
||||||
try:
|
try:
|
||||||
authentication_verification = verify_authentication_response(
|
authentication_verification = verify_authentication_response(
|
||||||
credential=AuthenticationCredential.parse_raw(dumps(data)),
|
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),
|
expected_origin=get_origin(request),
|
||||||
credential_public_key=base64url_to_bytes(device.public_key),
|
credential_public_key=base64url_to_bytes(device.public_key),
|
||||||
credential_current_sign_count=device.sign_count,
|
credential_current_sign_count=device.sign_count,
|
||||||
require_user_verification=False,
|
require_user_verification=stage.webauthn_user_verification == UserVerification.REQUIRED,
|
||||||
)
|
)
|
||||||
except InvalidAuthenticationResponse as exc:
|
except InvalidAuthenticationResponse as exc:
|
||||||
LOGGER.warning("Assertion failed", exc=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.flows.models import NotConfiguredAction, Stage
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
|
from authentik.stages.authenticator_webauthn.models import UserVerification
|
||||||
|
|
||||||
|
|
||||||
class DeviceClasses(models.TextChoices):
|
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
|
@property
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
def serializer(self) -> type[BaseSerializer]:
|
||||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||||
|
|
|
@ -177,7 +177,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, device),
|
"challenge": get_challenge_for_device(self.request, stage, device),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
challenge.is_valid()
|
challenge.is_valid()
|
||||||
|
@ -194,7 +194,10 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
data={
|
data={
|
||||||
"device_class": DeviceClasses.WEBAUTHN,
|
"device_class": DeviceClasses.WEBAUTHN,
|
||||||
"device_uid": -1,
|
"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()
|
challenge.is_valid()
|
||||||
|
|
|
@ -260,7 +260,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
|
||||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||||
device_classes=[DeviceClasses.TOTP],
|
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):
|
with self.assertRaises(ValidationError):
|
||||||
validate_challenge_code(
|
validate_challenge_code(
|
||||||
"1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user
|
"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.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
|
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.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
|
||||||
|
@ -90,8 +90,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
last_auth_threshold="milliseconds=0",
|
last_auth_threshold="milliseconds=0",
|
||||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||||
device_classes=[DeviceClasses.WEBAUTHN],
|
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"]
|
del challenge["challenge"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
challenge,
|
challenge,
|
||||||
|
@ -118,6 +119,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
request = get_request("/")
|
request = get_request("/")
|
||||||
request.user = self.user
|
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(
|
webauthn_device = WebAuthnDevice.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
public_key=(
|
public_key=(
|
||||||
|
@ -128,7 +136,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
sign_count=0,
|
sign_count=0,
|
||||||
rp_id=generate_id(),
|
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]
|
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
challenge,
|
challenge,
|
||||||
|
@ -149,7 +157,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
def test_get_challenge_userless(self):
|
def test_get_challenge_userless(self):
|
||||||
"""Test webauthn (userless)"""
|
"""Test webauthn (userless)"""
|
||||||
request = get_request("/")
|
request = get_request("/")
|
||||||
|
stage = AuthenticatorValidateStage.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
WebAuthnDevice.objects.create(
|
WebAuthnDevice.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
public_key=(
|
public_key=(
|
||||||
|
@ -160,7 +170,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
sign_count=0,
|
sign_count=0,
|
||||||
rp_id=generate_id(),
|
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]
|
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
challenge,
|
challenge,
|
||||||
|
|
12
schema.yml
12
schema.yml
|
@ -25842,6 +25842,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: If any of the user's device has been used within this threshold,
|
description: If any of the user's device has been used within this threshold,
|
||||||
this stage will be skipped
|
this stage will be skipped
|
||||||
|
webauthn_user_verification:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||||
|
description: Enforce user verification for WebAuthn devices.
|
||||||
required:
|
required:
|
||||||
- component
|
- component
|
||||||
- meta_model_name
|
- meta_model_name
|
||||||
|
@ -25880,6 +25884,10 @@ components:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
description: If any of the user's device has been used within this threshold,
|
description: If any of the user's device has been used within this threshold,
|
||||||
this stage will be skipped
|
this stage will be skipped
|
||||||
|
webauthn_user_verification:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||||
|
description: Enforce user verification for WebAuthn devices.
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
AuthenticatorValidationChallenge:
|
AuthenticatorValidationChallenge:
|
||||||
|
@ -33326,6 +33334,10 @@ components:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
description: If any of the user's device has been used within this threshold,
|
description: If any of the user's device has been used within this threshold,
|
||||||
this stage will be skipped
|
this stage will be skipped
|
||||||
|
webauthn_user_verification:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserVerificationEnum'
|
||||||
|
description: Enforce user verification for WebAuthn devices.
|
||||||
PatchedBlueprintInstanceRequest:
|
PatchedBlueprintInstanceRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Info about a single blueprint instance file
|
description: Info about a single blueprint instance file
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
|
import { first } from "@goauthentik/common/utils";
|
||||||
import "@goauthentik/elements/forms/FormGroup";
|
import "@goauthentik/elements/forms/FormGroup";
|
||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||||
|
@ -16,6 +17,7 @@ import {
|
||||||
DeviceClassesEnum,
|
DeviceClassesEnum,
|
||||||
NotConfiguredActionEnum,
|
NotConfiguredActionEnum,
|
||||||
StagesApi,
|
StagesApi,
|
||||||
|
UserVerificationEnum,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-validate-form")
|
@customElement("ak-stage-authenticator-validate-form")
|
||||||
|
@ -182,6 +184,35 @@ export class AuthenticatorValidateStageForm extends ModelForm<AuthenticatorValid
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</ak-form-element-horizontal>
|
</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
|
${this.showConfigurationStages
|
||||||
? html`
|
? html`
|
||||||
<ak-form-element-horizontal
|
<ak-form-element-horizontal
|
||||||
|
|
Reference in New Issue