stages/authenticator_validate: add passwordless login
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
15803dc67d
commit
5b3a9e29fb
|
@ -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