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

View File

@ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
"device_classes", "device_classes",
"configuration_stages", "configuration_stages",
"last_auth_threshold", "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.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)

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

View File

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

View File

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

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

View File

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

View 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