stages/authenticator_validate: add passwordless login
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
15803dc67d
commit
5b3a9e29fb
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
Reference in a new issue