stages/authenticator_validate: add passwordless login

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-12-22 17:34:46 +01:00
parent 15803dc67d
commit 5b3a9e29fb
4 changed files with 106 additions and 8 deletions

View file

@ -11,7 +11,8 @@
"saml", "saml",
"totp", "totp",
"webauthn", "webauthn",
"traefik" "traefik",
"passwordless"
], ],
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,

View file

@ -43,6 +43,20 @@ def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
return {} 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: def get_webauthn_challenge(request: HttpRequest, 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("challenge", None) request.session.pop("challenge", None)
@ -87,7 +101,7 @@ def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str:
# pylint: disable=unused-argument # 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""" """Validate WebAuthn Challenge"""
challenge = request.session.get("challenge") challenge = request.session.get("challenge")
credential_id = data.get("id") credential_id = data.get("id")
@ -107,12 +121,12 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) ->
require_user_verification=False, require_user_verification=False,
) )
except (InvalidAuthenticationResponse) as exc: except InvalidAuthenticationResponse as exc:
LOGGER.warning("Assertion failed", exc=exc) LOGGER.warning("Assertion failed", exc=exc)
raise ValidationError("Assertion failed") from exc raise ValidationError("Assertion failed") from exc
device.set_sign_count(authentication_verification.new_sign_count) 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: def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> int:

View file

@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction 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.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge
from authentik.flows.models import NotConfiguredAction, Stage from authentik.flows.models import NotConfiguredAction, Stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER 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 ( from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge, DeviceChallenge,
get_challenge_for_device, get_challenge_for_device,
get_webauthn_challenge_userless,
select_challenge, select_challenge,
validate_challenge_code, validate_challenge_code,
validate_challenge_duo, validate_challenge_duo,
validate_challenge_webauthn, validate_challenge_webauthn,
) )
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses 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() LOGGER = get_logger()
@ -129,15 +133,33 @@ class AuthenticatorValidateStageView(ChallengeStageView):
LOGGER.debug("adding challenge for device", challenge=challenge) LOGGER.debug("adding challenge for device", challenge=challenge)
return challenges 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: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check if a user is set, and check if the user has any devices """Check if a user is set, and check if the user has any devices
if not, we can skip this entire stage""" if not, we can skip this entire stage"""
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user: stage: AuthenticatorValidateStage = self.executor.current_stage
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") LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok() return self.executor.stage_ok()
stage: AuthenticatorValidateStage = self.executor.current_stage
challenges = self.get_device_challenges()
self.request.session["device_challenges"] = challenges self.request.session["device_challenges"] = challenges
# No allowed devices # No allowed devices
@ -181,4 +203,19 @@ class AuthenticatorValidateStageView(ChallengeStageView):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def challenge_valid(self, response: AuthenticatorValidationChallengeResponse) -> HttpResponse: def challenge_valid(self, response: AuthenticatorValidationChallengeResponse) -> HttpResponse:
# All validation is done by the serializer # 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() return self.executor.stage_ok()

View file

@ -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 - Skip: Validation is skipped and the flow continues
- Deny: Access is denied, the flow execution ends - 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. - 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"
}
}
}
```