diff --git a/.vscode/settings.json b/.vscode/settings.json index 939403a9f..27f7700e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,8 @@ "saml", "totp", "webauthn", - "traefik" + "traefik", + "passwordless" ], "python.linting.pylintEnabled": true, "todo-tree.tree.showCountsInTree": true, diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 33788bf02..e7d53c92d 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -43,6 +43,20 @@ def get_challenge_for_device(request: HttpRequest, device: Device) -> dict: return {} +def get_webauthn_challenge_userless(request: HttpRequest) -> dict: + """Same as `get_webauthn_challenge`, but allows any client device. We can then later check + who the device belongs to.""" + request.session.pop("challenge", None) + authentication_options = generate_authentication_options( + rp_id=get_rp_id(request), + allow_credentials=[], + ) + + request.session["challenge"] = authentication_options.challenge + + return loads(options_to_json(authentication_options)) + + def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice] = None) -> dict: """Send the client a challenge that we'll check later""" request.session.pop("challenge", None) @@ -87,7 +101,7 @@ def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str: # pylint: disable=unused-argument -def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> dict: +def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> Device: """Validate WebAuthn Challenge""" challenge = request.session.get("challenge") credential_id = data.get("id") @@ -107,12 +121,12 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> require_user_verification=False, ) - except (InvalidAuthenticationResponse) as exc: + except InvalidAuthenticationResponse as exc: LOGGER.warning("Assertion failed", exc=exc) raise ValidationError("Assertion failed") from exc device.set_sign_count(authentication_verification.new_sign_count) - return data + return device def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> int: diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index b3384ca04..3af044a70 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger from authentik.events.models import Event, EventAction +from authentik.events.utils import cleanse_dict, sanitize_dict from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge from authentik.flows.models import NotConfiguredAction, Stage from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER @@ -14,12 +15,15 @@ from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, get_challenge_for_device, + get_webauthn_challenge_userless, select_challenge, validate_challenge_code, validate_challenge_duo, validate_challenge_webauthn, ) from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS LOGGER = get_logger() @@ -129,15 +133,33 @@ class AuthenticatorValidateStageView(ChallengeStageView): LOGGER.debug("adding challenge for device", challenge=challenge) return challenges + def get_userless_webauthn_challenge(self) -> list[dict]: + """Get a WebAuthn challenge when no pending user is set.""" + challenge = DeviceChallenge( + data={ + "device_class": DeviceClasses.WEBAUTHN, + "device_uid": -1, + "challenge": get_webauthn_challenge_userless(self.request), + } + ) + challenge.is_valid() + return [challenge.data] + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Check if a user is set, and check if the user has any devices if not, we can skip this entire stage""" user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) - if not user: - LOGGER.debug("No pending user, continuing") - return self.executor.stage_ok() stage: AuthenticatorValidateStage = self.executor.current_stage - challenges = self.get_device_challenges() + if user: + challenges = self.get_device_challenges() + else: + # Passwordless auth, with just webauthn + if DeviceClasses.WEBAUTHN in stage.device_classes: + LOGGER.debug("Userless flow, getting generic webauthn challenge") + challenges = self.get_userless_webauthn_challenge() + else: + LOGGER.debug("No pending user, continuing") + return self.executor.stage_ok() self.request.session["device_challenges"] = challenges # No allowed devices @@ -181,4 +203,19 @@ class AuthenticatorValidateStageView(ChallengeStageView): # pylint: disable=unused-argument def challenge_valid(self, response: AuthenticatorValidationChallengeResponse) -> HttpResponse: # All validation is done by the serializer + user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not user: + webauthn_device: WebAuthnDevice = response.data.get("webauthn", None) + if not webauthn_device: + return self.executor.stage_ok() + LOGGER.debug("Set user from userless flow", user=webauthn_device.user) + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = webauthn_device.user + self.executor.plan.context[PLAN_CONTEXT_METHOD] = "auth_webauthn_pwl" + self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict( + sanitize_dict( + { + "device": webauthn_device, + } + ) + ) return self.executor.stage_ok() diff --git a/website/docs/flow/stages/authenticator_validate/index.md b/website/docs/flow/stages/authenticator_validate/index.md index c710fe940..180a4d821 100644 --- a/website/docs/flow/stages/authenticator_validate/index.md +++ b/website/docs/flow/stages/authenticator_validate/index.md @@ -17,3 +17,49 @@ Using the `Not configured action`, you can choose what happens when a user does - Skip: Validation is skipped and the flow continues - Deny: Access is denied, the flow execution ends - Configure: This option requires a *Configuration stage* to be set. The validation stage will be marked as successful, and the configuration stage will be injected into the flow. + +## Passwordless authentication + +::: +Requires authentik 2021.12.4 +::: + +Passwordless authentication currently only supports WebAuthn devices, like security keys and biometrics. + +To configure passwordless authentication, create a new Flow with the delegation set to *Authentication*. + +As first stage, add an *Authentication validation* stage, with the WebAuthn device class allowed. +After this stage you can bind any additional verification stages. +As final stage, bind a *User login* stage. + +This flow will return an error for users without a WebAuthn device. To circumvent this, you can add an identification and password stage +after the initial validation stage, and use a policy to skip them if the first stage already set a user. You can use a policy like this: + +```python +return bool(request.user) +``` + +#### Logging + +Logins which used Passwordless authentication have the *auth_method* context variable set to `auth_webauthn_pwl`, and the device used is saved in the arguments. Example: + +```json +{ + "auth_method": "auth_webauthn_pwl", + "http_request": { + "args": { + "query": "" + }, + "path": "/api/v3/flows/executor/test/", + "method": "GET" + }, + "auth_method_args": { + "device": { + "pk": 1, + "app": "authentik_stages_authenticator_webauthn", + "name": "test device", + "model_name": "webauthndevice" + } + } +} +```