diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 4e4f587d4..d99585d23 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -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": [ diff --git a/authentik/stages/authenticator_validate/api.py b/authentik/stages/authenticator_validate/api.py index 429055657..474bc8a4e 100644 --- a/authentik/stages/authenticator_validate/api.py +++ b/authentik/stages/authenticator_validate/api.py @@ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer): "device_classes", "configuration_stages", "last_auth_threshold", + "webauthn_user_verification", ] diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 0d5c7fff0..f43996e5b 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -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) diff --git a/authentik/stages/authenticator_validate/migrations/0012_authenticatorvalidatestage_webauthn_user_verification.py b/authentik/stages/authenticator_validate/migrations/0012_authenticatorvalidatestage_webauthn_user_verification.py new file mode 100644 index 000000000..d40dfe8be --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0012_authenticatorvalidatestage_webauthn_user_verification.py @@ -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.", + ), + ), + ] diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index 2d943cfd1..f4203980e 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -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 diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 0afd3b79b..eedd8ef15 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -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() diff --git a/authentik/stages/authenticator_validate/tests/test_totp.py b/authentik/stages/authenticator_validate/tests/test_totp.py index 54ccfb912..5dafaa576 100644 --- a/authentik/stages/authenticator_validate/tests/test_totp.py +++ b/authentik/stages/authenticator_validate/tests/test_totp.py @@ -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 diff --git a/authentik/stages/authenticator_validate/tests/test_webauthn.py b/authentik/stages/authenticator_validate/tests/test_webauthn.py index c275a6546..70caed4ab 100644 --- a/authentik/stages/authenticator_validate/tests/test_webauthn.py +++ b/authentik/stages/authenticator_validate/tests/test_webauthn.py @@ -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, diff --git a/schema.yml b/schema.yml index a3a994254..170f6378a 100644 --- a/schema.yml +++ b/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 diff --git a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts index 85e9421a1..64dd46309 100644 --- a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts +++ b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts @@ -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 + + + ${this.showConfigurationStages ? html`