stages/authenticator_validate: add passwordless login
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,8 @@ | ||||
|         "saml", | ||||
|         "totp", | ||||
|         "webauthn", | ||||
|         "traefik" | ||||
|         "traefik", | ||||
|         "passwordless" | ||||
|     ], | ||||
|     "python.linting.pylintEnabled": true, | ||||
|     "todo-tree.tree.showCountsInTree": true, | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
| @ -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() | ||||
|  | ||||
| @ -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" | ||||
|         } | ||||
|     } | ||||
| } | ||||
| ``` | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer