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:
Jens Langhammer 2022-11-21 16:59:32 +01:00
parent 91897b0ac6
commit a2e512c36c
10 changed files with 119 additions and 16 deletions

View file

@ -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": [

View file

@ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
"device_classes",
"configuration_stages",
"last_auth_threshold",
"webauthn_user_verification",
]

View file

@ -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)

View file

@ -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.",
),
),
]

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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

View 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