stages/authenticator_validate: start rewrite to SPA
This commit is contained in:
		| @ -23,6 +23,7 @@ class NotConfiguredAction(models.TextChoices): | |||||||
|     """Decides how the FlowExecutor should proceed when a stage isn't configured""" |     """Decides how the FlowExecutor should proceed when a stage isn't configured""" | ||||||
|  |  | ||||||
|     SKIP = "skip" |     SKIP = "skip" | ||||||
|  |     DENY = "deny" | ||||||
|     # CONFIGURE = "configure" |     # CONFIGURE = "configure" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from authentik.flows.models import NotConfiguredAction, Stage | |||||||
| class DeviceClasses(models.TextChoices): | class DeviceClasses(models.TextChoices): | ||||||
|     """Device classes this stage can validate""" |     """Device classes this stage can validate""" | ||||||
|  |  | ||||||
|  |     # device class must match Device's class name so StaticDevice -> static | ||||||
|     STATIC = "static" |     STATIC = "static" | ||||||
|     TOTP = "totp", _("TOTP") |     TOTP = "totp", _("TOTP") | ||||||
|     WEBAUTHN = "webauthn", _("WebAuthn") |     WEBAUTHN = "webauthn", _("WebAuthn") | ||||||
|  | |||||||
| @ -1,38 +1,44 @@ | |||||||
| """OTP Validation""" | """Authenticator Validation""" | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django_otp import user_has_device | from django_otp import devices_for_user, user_has_device | ||||||
| from rest_framework.fields import IntegerField | from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | from authentik.flows.challenge import ( | ||||||
|  |     ChallengeResponse, | ||||||
|  |     ChallengeTypes, | ||||||
|  |     WithUserInfoChallenge, | ||||||
|  | ) | ||||||
| from authentik.flows.models import NotConfiguredAction | from authentik.flows.models import NotConfiguredAction | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.stages.authenticator_validate.forms import ValidationForm |  | ||||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage | from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class CodeChallengeResponse(ChallengeResponse): | class AuthenticatorChallenge(WithUserInfoChallenge): | ||||||
|  |     """Authenticator challenge""" | ||||||
|  |  | ||||||
|  |     users_device_classes = ListField(child=CharField()) | ||||||
|  |     class_challenges = DictField(JSONField()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthenticatorChallengeResponse(ChallengeResponse): | ||||||
|     """Challenge used for Code-based authenticators""" |     """Challenge used for Code-based authenticators""" | ||||||
|  |  | ||||||
|     code = IntegerField(min_value=0) |     device_challenges = DictField(JSONField()) | ||||||
|  |  | ||||||
|  |     def validate_device_challenges(self, value: dict[str, dict]): | ||||||
| class WebAuthnChallengeResponse(ChallengeResponse): |         return value | ||||||
|     """Challenge used for WebAuthn authenticators""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatorValidateStageView(ChallengeStageView): | class AuthenticatorValidateStageView(ChallengeStageView): | ||||||
|     """OTP Validation""" |     """Authenticator Validation""" | ||||||
|  |  | ||||||
|     form_class = ValidationForm |     response_class = AuthenticatorChallengeResponse | ||||||
|  |  | ||||||
|     # def get_form_kwargs(self, **kwargs) -> dict[str, Any]: |     allowed_device_classes: set[str] | ||||||
|     #     kwargs = super().get_form_kwargs(**kwargs) |  | ||||||
|     #     kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) |  | ||||||
|     #     return kwargs |  | ||||||
|  |  | ||||||
|     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 | ||||||
| @ -44,33 +50,38 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|         has_devices = user_has_device(user) |         has_devices = user_has_device(user) | ||||||
|         stage: AuthenticatorValidateStage = self.executor.current_stage |         stage: AuthenticatorValidateStage = self.executor.current_stage | ||||||
|  |  | ||||||
|         if not has_devices: |         user_devices = devices_for_user(self.get_pending_user()) | ||||||
|  |         user_device_classes = set( | ||||||
|  |             [ | ||||||
|  |                 device.__class__.__name__.lower().replace("device", "") | ||||||
|  |                 for device in user_devices | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  |         stage_device_classes = set(self.executor.current_stage.device_classes) | ||||||
|  |         self.allowed_device_classes = user_device_classes.intersection(stage_device_classes) | ||||||
|  |  | ||||||
|  |         # User has no devices, or the devices they have don't overlap with the allowed | ||||||
|  |         # classes | ||||||
|  |         if not has_devices or len(self.allowed_device_classes) < 1: | ||||||
|             if stage.not_configured_action == NotConfiguredAction.SKIP: |             if stage.not_configured_action == NotConfiguredAction.SKIP: | ||||||
|                 LOGGER.debug("Authenticator not configured, skipping stage") |                 LOGGER.debug("Authenticator not configured, skipping stage") | ||||||
|                 return self.executor.stage_ok() |                 return self.executor.stage_ok() | ||||||
|  |             if stage.not_configured_action == NotConfiguredAction.DENY: | ||||||
|  |                 LOGGER.debug("Authenticator not configured, denying") | ||||||
|  |                 return self.executor.stage_invalid() | ||||||
|         return super().get(request, *args, **kwargs) |         return super().get(request, *args, **kwargs) | ||||||
|  |  | ||||||
|     # def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: |     def get_challenge(self) -> AuthenticatorChallenge: | ||||||
|     #     kwargs = super().get_form_kwargs(**kwargs) |         return AuthenticatorChallenge( | ||||||
|     #     kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) |             data={ | ||||||
|     #     return kwargs |  | ||||||
|  |  | ||||||
|     def get_challenge(self) -> Challenge: |  | ||||||
|         return Challenge( |  | ||||||
|             { |  | ||||||
|                 "type": ChallengeTypes.native, |                 "type": ChallengeTypes.native, | ||||||
|                 # TODO: use component based on devices |  | ||||||
|                 "component": "ak-stage-authenticator-validate", |                 "component": "ak-stage-authenticator-validate", | ||||||
|                 "args": {"user": "foo.bar.baz"}, |                 "users_device_classes": self.allowed_device_classes, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse: |     def challenge_valid( | ||||||
|  |         self, challenge: AuthenticatorChallengeResponse | ||||||
|  |     ) -> HttpResponse: | ||||||
|         print(challenge) |         print(challenge) | ||||||
|         return HttpResponse() |         return HttpResponse() | ||||||
|  |  | ||||||
|     # def form_valid(self, form: ValidationForm) -> HttpResponse: |  | ||||||
|     #     """Verify OTP Token""" |  | ||||||
|     #     # Since we do token checking in the form, we know the token is valid here |  | ||||||
|     #     # so we can just continue |  | ||||||
|     #     return self.executor.stage_ok() |  | ||||||
|  | |||||||
							
								
								
									
										83
									
								
								authentik/stages/authenticator_validate/webauthn.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								authentik/stages/authenticator_validate/webauthn.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | |||||||
|  | from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser | ||||||
|  | from webauthn.webauthn import ( | ||||||
|  |     AuthenticationRejectedException, | ||||||
|  |     RegistrationRejectedException, | ||||||
|  |     WebAuthnUserDataMissing, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | class BeginAssertion(FlowUserRequiredView): | ||||||
|  |     """Send the client a challenge that we'll check later""" | ||||||
|  |  | ||||||
|  |     def post(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         """Send the client a challenge that we'll check later""" | ||||||
|  |         request.session.pop("challenge", None) | ||||||
|  |  | ||||||
|  |         challenge = generate_challenge(32) | ||||||
|  |  | ||||||
|  |         # We strip the padding from the challenge stored in the session | ||||||
|  |         # for the reasons outlined in the comment in webauthn_begin_activate. | ||||||
|  |         request.session["challenge"] = challenge.rstrip("=") | ||||||
|  |  | ||||||
|  |         devices = WebAuthnDevice.objects.filter(user=self.user) | ||||||
|  |         if not devices.exists(): | ||||||
|  |             return HttpResponseBadRequest() | ||||||
|  |         device: WebAuthnDevice = devices.first() | ||||||
|  |  | ||||||
|  |         webauthn_user = WebAuthnUser( | ||||||
|  |             self.user.uid, | ||||||
|  |             self.user.username, | ||||||
|  |             self.user.name, | ||||||
|  |             avatar(self.user), | ||||||
|  |             device.credential_id, | ||||||
|  |             device.public_key, | ||||||
|  |             device.sign_count, | ||||||
|  |             device.rp_id, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge) | ||||||
|  |  | ||||||
|  |         return JsonResponse(webauthn_assertion_options.assertion_dict) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class VerifyAssertion(FlowUserRequiredView): | ||||||
|  |     """Verify assertion result that we've sent to the client""" | ||||||
|  |  | ||||||
|  |     def post(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         """Verify assertion result that we've sent to the client""" | ||||||
|  |         challenge = request.session.get("challenge") | ||||||
|  |         assertion_response = request.POST | ||||||
|  |         credential_id = assertion_response.get("id") | ||||||
|  |  | ||||||
|  |         device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() | ||||||
|  |         if not device: | ||||||
|  |             return JsonResponse({"fail": "Device does not exist."}, status=401) | ||||||
|  |  | ||||||
|  |         webauthn_user = WebAuthnUser( | ||||||
|  |             self.user.uid, | ||||||
|  |             self.user.username, | ||||||
|  |             self.user.name, | ||||||
|  |             avatar(self.user), | ||||||
|  |             device.credential_id, | ||||||
|  |             device.public_key, | ||||||
|  |             device.sign_count, | ||||||
|  |             device.rp_id, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         webauthn_assertion_response = WebAuthnAssertionResponse( | ||||||
|  |             webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False | ||||||
|  |         )  # User Verification | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             sign_count = webauthn_assertion_response.verify() | ||||||
|  |         except ( | ||||||
|  |             AuthenticationRejectedException, | ||||||
|  |             WebAuthnUserDataMissing, | ||||||
|  |             RegistrationRejectedException, | ||||||
|  |         ) as exc: | ||||||
|  |             return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)}) | ||||||
|  |  | ||||||
|  |         device.set_sign_count(sign_count) | ||||||
|  |         request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True | ||||||
|  |         return JsonResponse( | ||||||
|  |             {"success": "Successfully authenticated as {}".format(self.user.username)} | ||||||
|  |         ) | ||||||
| @ -122,7 +122,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|         return AuthenticatorWebAuthnChallenge( |         return AuthenticatorWebAuthnChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "type": ChallengeTypes.native, |                 "type": ChallengeTypes.native, | ||||||
|                 "component": "ak-stage-authenticator-webauthn-register", |                 "component": "ak-stage-authenticator-webauthn", | ||||||
|                 "registration": make_credential_options.registration_dict, |                 "registration": make_credential_options.registration_dict, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -3,22 +3,10 @@ from django.urls import path | |||||||
| from django.views.decorators.csrf import csrf_exempt | from django.views.decorators.csrf import csrf_exempt | ||||||
|  |  | ||||||
| from authentik.stages.authenticator_webauthn.views import ( | from authentik.stages.authenticator_webauthn.views import ( | ||||||
|     BeginAssertion, |  | ||||||
|     UserSettingsView, |     UserSettingsView, | ||||||
|     VerifyAssertion, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path( |  | ||||||
|         "begin-assertion/", |  | ||||||
|         csrf_exempt(BeginAssertion.as_view()), |  | ||||||
|         name="assertion-begin", |  | ||||||
|     ), |  | ||||||
|     path( |  | ||||||
|         "verify-assertion/", |  | ||||||
|         csrf_exempt(VerifyAssertion.as_view()), |  | ||||||
|         name="assertion-verify", |  | ||||||
|     ), |  | ||||||
|     path( |     path( | ||||||
|         "<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings" |         "<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings" | ||||||
|     ), |     ), | ||||||
|  | |||||||
| @ -6,12 +6,6 @@ from django.shortcuts import get_object_or_404 | |||||||
| from django.views import View | from django.views import View | ||||||
| from django.views.generic import TemplateView | from django.views.generic import TemplateView | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser |  | ||||||
| from webauthn.webauthn import ( |  | ||||||
|     AuthenticationRejectedException, |  | ||||||
|     RegistrationRejectedException, |  | ||||||
|     WebAuthnUserDataMissing, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| @ -32,99 +26,6 @@ RP_NAME = "authentik" | |||||||
| ORIGIN = "http://localhost:8000" | ORIGIN = "http://localhost:8000" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowUserRequiredView(View): |  | ||||||
|     """Base class for views which can only be called in the context of a flow.""" |  | ||||||
|  |  | ||||||
|     user: User |  | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |  | ||||||
|         plan = request.session.get(SESSION_KEY_PLAN, None) |  | ||||||
|         if not plan: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         self.user = plan.context.get(PLAN_CONTEXT_PENDING_USER) |  | ||||||
|         if not self.user: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         return super().dispatch(request, *args, **kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BeginAssertion(FlowUserRequiredView): |  | ||||||
|     """Send the client a challenge that we'll check later""" |  | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest) -> HttpResponse: |  | ||||||
|         """Send the client a challenge that we'll check later""" |  | ||||||
|         request.session.pop("challenge", None) |  | ||||||
|  |  | ||||||
|         challenge = generate_challenge(32) |  | ||||||
|  |  | ||||||
|         # We strip the padding from the challenge stored in the session |  | ||||||
|         # for the reasons outlined in the comment in webauthn_begin_activate. |  | ||||||
|         request.session["challenge"] = challenge.rstrip("=") |  | ||||||
|  |  | ||||||
|         devices = WebAuthnDevice.objects.filter(user=self.user) |  | ||||||
|         if not devices.exists(): |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         device: WebAuthnDevice = devices.first() |  | ||||||
|  |  | ||||||
|         webauthn_user = WebAuthnUser( |  | ||||||
|             self.user.uid, |  | ||||||
|             self.user.username, |  | ||||||
|             self.user.name, |  | ||||||
|             avatar(self.user), |  | ||||||
|             device.credential_id, |  | ||||||
|             device.public_key, |  | ||||||
|             device.sign_count, |  | ||||||
|             device.rp_id, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge) |  | ||||||
|  |  | ||||||
|         return JsonResponse(webauthn_assertion_options.assertion_dict) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class VerifyAssertion(FlowUserRequiredView): |  | ||||||
|     """Verify assertion result that we've sent to the client""" |  | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest) -> HttpResponse: |  | ||||||
|         """Verify assertion result that we've sent to the client""" |  | ||||||
|         challenge = request.session.get("challenge") |  | ||||||
|         assertion_response = request.POST |  | ||||||
|         credential_id = assertion_response.get("id") |  | ||||||
|  |  | ||||||
|         device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() |  | ||||||
|         if not device: |  | ||||||
|             return JsonResponse({"fail": "Device does not exist."}, status=401) |  | ||||||
|  |  | ||||||
|         webauthn_user = WebAuthnUser( |  | ||||||
|             self.user.uid, |  | ||||||
|             self.user.username, |  | ||||||
|             self.user.name, |  | ||||||
|             avatar(self.user), |  | ||||||
|             device.credential_id, |  | ||||||
|             device.public_key, |  | ||||||
|             device.sign_count, |  | ||||||
|             device.rp_id, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         webauthn_assertion_response = WebAuthnAssertionResponse( |  | ||||||
|             webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False |  | ||||||
|         )  # User Verification |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             sign_count = webauthn_assertion_response.verify() |  | ||||||
|         except ( |  | ||||||
|             AuthenticationRejectedException, |  | ||||||
|             WebAuthnUserDataMissing, |  | ||||||
|             RegistrationRejectedException, |  | ||||||
|         ) as exc: |  | ||||||
|             return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)}) |  | ||||||
|  |  | ||||||
|         device.set_sign_count(sign_count) |  | ||||||
|         request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True |  | ||||||
|         return JsonResponse( |  | ||||||
|             {"success": "Successfully authenticated as {}".format(self.user.username)} |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSettingsView(LoginRequiredMixin, TemplateView): | class UserSettingsView(LoginRequiredMixin, TemplateView): | ||||||
|     """View for user settings to control WebAuthn devices""" |     """View for user settings to control WebAuthn devices""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -37,16 +37,45 @@ class TestFlowsAuthenticator(SeleniumTestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") |         self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") | ||||||
|         self.driver.find_element(By.ID, "id_uid_field").click() |  | ||||||
|         self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) |         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||||
|         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) |         identification_stage = self.get_shadow_root( | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) |             "ak-stage-identification", flow_executor | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) |         ) | ||||||
|  |  | ||||||
|  |         identification_stage.find_element( | ||||||
|  |             By.CSS_SELECTOR, "input[name=uid_field]" | ||||||
|  |         ).click() | ||||||
|  |         identification_stage.find_element( | ||||||
|  |             By.CSS_SELECTOR, "input[name=uid_field]" | ||||||
|  |         ).send_keys(USER().username) | ||||||
|  |         identification_stage.find_element( | ||||||
|  |             By.CSS_SELECTOR, "input[name=uid_field]" | ||||||
|  |         ).send_keys(Keys.ENTER) | ||||||
|  |  | ||||||
|  |         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||||
|  |         password_stage = self.get_shadow_root("ak-stage-password", flow_executor) | ||||||
|  |         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( | ||||||
|  |             USER().username | ||||||
|  |         ) | ||||||
|  |         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( | ||||||
|  |             Keys.ENTER | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         # Get expected token |         # Get expected token | ||||||
|         totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift) |         totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift) | ||||||
|         self.driver.find_element(By.ID, "id_code").send_keys(totp.token()) |  | ||||||
|         self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER) |         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||||
|  |         identification_stage = self.get_shadow_root( | ||||||
|  |             "ak-stage-identification", flow_executor | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.driver.find_element(By.CSS_SELECTOR, "input[name=code]").send_keys( | ||||||
|  |             totp.token() | ||||||
|  |         ) | ||||||
|  |         self.driver.find_element(By.CSS_SELECTOR, "input[name=code]").send_keys( | ||||||
|  |             Keys.ENTER | ||||||
|  |         ) | ||||||
|         self.wait_for_url(self.shell_url("authentik_core:overview")) |         self.wait_for_url(self.shell_url("authentik_core:overview")) | ||||||
|         self.assert_user(USER()) |         self.assert_user(USER()) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,48 @@ | |||||||
| import { customElement, html, LitElement, TemplateResult } from "lit-element"; | import { customElement, html, property, TemplateResult } from "lit-element"; | ||||||
|  | import { WithUserInfoChallenge } from "../../../api/Flows"; | ||||||
|  | import { BaseStage, StageHost } from "../base"; | ||||||
|  | import "./AuthenticatorValidateStageWebAuthn"; | ||||||
|  |  | ||||||
|  | export enum DeviceClasses { | ||||||
|  |     STATIC = "static", | ||||||
|  |     TOTP = "totp", | ||||||
|  |     WEBAUTHN = "webauthn", | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge { | ||||||
|  |     users_device_classes: DeviceClasses[]; | ||||||
|  |     class_challenges: { [key in DeviceClasses]: unknown }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface AuthenticatorValidateStageChallengeResponse { | ||||||
|  |     device_challenges: { [key in DeviceClasses]: unknown}  ; | ||||||
|  | } | ||||||
|  |  | ||||||
| @customElement("ak-stage-authenticator-validate") | @customElement("ak-stage-authenticator-validate") | ||||||
| export class AuthenticatorValidateStage extends LitElement { | export class AuthenticatorValidateStage extends BaseStage implements StageHost { | ||||||
|  |  | ||||||
|  |     @property({ attribute: false }) | ||||||
|  |     challenge?: AuthenticatorValidateStageChallenge; | ||||||
|  |  | ||||||
|  |     renderDeviceClass(deviceClass: DeviceClasses): TemplateResult { | ||||||
|  |         switch (deviceClass) { | ||||||
|  |         case DeviceClasses.STATIC: | ||||||
|  |         case DeviceClasses.TOTP: | ||||||
|  |             return html``; | ||||||
|  |         case DeviceClasses.WEBAUTHN: | ||||||
|  |             return html`<ak-stage-authenticator-validate-webauthn .host=${this} .challenge=${this.challenge}></ak-stage-authenticator-validate-webauthn>`; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     submit(formData?: FormData): Promise<void> { | ||||||
|  |         return this.host?.submit(formData) || Promise.resolve(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     render(): TemplateResult { |     render(): TemplateResult { | ||||||
|  |         // User only has a single device class, so we don't show a picker | ||||||
|  |         if (this.challenge?.users_device_classes.length === 1) { | ||||||
|  |             return this.renderDeviceClass(this.challenge.users_device_classes[0]); | ||||||
|  |         } | ||||||
|         return html`ak-stage-authenticator-validate`; |         return html`ak-stage-authenticator-validate`; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,10 +1,15 @@ | |||||||
| import { gettext } from "django"; | import { gettext } from "django"; | ||||||
| import { customElement, html, LitElement, property, TemplateResult } from "lit-element"; | import { customElement, html, property, TemplateResult } from "lit-element"; | ||||||
| import { SpinnerSize } from "../../Spinner"; | import { SpinnerSize } from "../../Spinner"; | ||||||
| import { getCredentialRequestOptionsFromServer, postAssertionToServer, transformAssertionForServer, transformCredentialRequestOptions } from "./utils"; | import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils"; | ||||||
|  | import { BaseStage } from "../base"; | ||||||
|  | import { AuthenticatorValidateStageChallenge, DeviceClasses } from "./AuthenticatorValidateStage"; | ||||||
| 
 | 
 | ||||||
| @customElement("ak-stage-webauthn-auth") | @customElement("ak-stage-authenticator-validate-webauthn") | ||||||
| export class WebAuthnAuth extends LitElement { | export class AuthenticatorValidateStageWebAuthn extends BaseStage { | ||||||
|  | 
 | ||||||
|  |     @property({attribute: false}) | ||||||
|  |     challenge?: AuthenticatorValidateStageChallenge; | ||||||
| 
 | 
 | ||||||
|     @property({ type: Boolean }) |     @property({ type: Boolean }) | ||||||
|     authenticateRunning = false; |     authenticateRunning = false; | ||||||
| @ -13,18 +18,10 @@ export class WebAuthnAuth extends LitElement { | |||||||
|     authenticateMessage = ""; |     authenticateMessage = ""; | ||||||
| 
 | 
 | ||||||
|     async authenticate(): Promise<void> { |     async authenticate(): Promise<void> { | ||||||
|         // post the login data to the server to retrieve the PublicKeyCredentialRequestOptions
 |  | ||||||
|         let credentialRequestOptionsFromServer; |  | ||||||
|         try { |  | ||||||
|             credentialRequestOptionsFromServer = await getCredentialRequestOptionsFromServer(); |  | ||||||
|         } catch (err) { |  | ||||||
|             throw new Error(gettext(`Error when getting request options from server: ${err}`)); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // convert certain members of the PublicKeyCredentialRequestOptions into
 |         // convert certain members of the PublicKeyCredentialRequestOptions into
 | ||||||
|         // byte arrays as expected by the spec.
 |         // byte arrays as expected by the spec.
 | ||||||
|         const transformedCredentialRequestOptions = transformCredentialRequestOptions( |         const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.challenge?.class_challenges[DeviceClasses.WEBAUTHN]; | ||||||
|             credentialRequestOptionsFromServer); |         const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions); | ||||||
| 
 | 
 | ||||||
|         // request the authenticator to create an assertion signature using the
 |         // request the authenticator to create an assertion signature using the
 | ||||||
|         // credential private key
 |         // credential private key
 | ||||||
| @ -42,26 +39,16 @@ export class WebAuthnAuth extends LitElement { | |||||||
| 
 | 
 | ||||||
|         // we now have an authentication assertion! encode the byte arrays contained
 |         // we now have an authentication assertion! encode the byte arrays contained
 | ||||||
|         // in the assertion data as strings for posting to the server
 |         // in the assertion data as strings for posting to the server
 | ||||||
|         const transformedAssertionForServer = transformAssertionForServer(assertion); |         const transformedAssertionForServer = transformAssertionForServer(<PublicKeyCredential>assertion); | ||||||
| 
 | 
 | ||||||
|         // post the assertion to the server for verification.
 |         // post the assertion to the server for verification.
 | ||||||
|         try { |         try { | ||||||
|             await postAssertionToServer(transformedAssertionForServer); |             const formData = new FormData(); | ||||||
|  |             formData.set(`response[${DeviceClasses.WEBAUTHN}]`, JSON.stringify(transformedAssertionForServer)); | ||||||
|  |             await this.host?.submit(formData); | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
|             throw new Error(gettext(`Error when validating assertion on server: ${err}`)); |             throw new Error(gettext(`Error when validating assertion on server: ${err}`)); | ||||||
|         } |         } | ||||||
| 
 |  | ||||||
|         this.finishStage(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     finishStage(): void { |  | ||||||
|         // Mark this stage as done
 |  | ||||||
|         this.dispatchEvent( |  | ||||||
|             new CustomEvent("ak-flow-submit", { |  | ||||||
|                 bubbles: true, |  | ||||||
|                 composed: true, |  | ||||||
|             }) |  | ||||||
|         ); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     firstUpdated(): void { |     firstUpdated(): void { | ||||||
| @ -13,7 +13,7 @@ export interface WebAuthnAuthenticatorRegisterChallengeResponse { | |||||||
|     response: Assertion; |     response: Assertion; | ||||||
| } | } | ||||||
|  |  | ||||||
| @customElement("ak-stage-authenticator-webauthn-register") | @customElement("ak-stage-authenticator-webauthn") | ||||||
| export class WebAuthnAuthenticatorRegisterStage extends BaseStage { | export class WebAuthnAuthenticatorRegisterStage extends BaseStage { | ||||||
|  |  | ||||||
|     @property({ attribute: false }) |     @property({ attribute: false }) | ||||||
| @ -58,7 +58,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage { | |||||||
|         // and storing the public key |         // and storing the public key | ||||||
|         try { |         try { | ||||||
|             const formData = new FormData(); |             const formData = new FormData(); | ||||||
|             formData.set("response", JSON.stringify(newAssertionForServer)) |             formData.set("response", JSON.stringify(newAssertionForServer)); | ||||||
|             await this.host?.submit(formData); |             await this.host?.submit(formData); | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
|             throw new Error(gettext(`Server validation of credential failed: ${err}`)); |             throw new Error(gettext(`Server validation of credential failed: ${err}`)); | ||||||
|  | |||||||
| @ -21,20 +21,6 @@ export function hexEncode(buf: Uint8Array): string { | |||||||
|         .join(""); |         .join(""); | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface GenericResponse { |  | ||||||
|     fail?: string; |  | ||||||
|     success?: string; |  | ||||||
|     [key: string]: string | number | GenericResponse | undefined; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function fetchJSON(url: string, options: RequestInit): Promise<GenericResponse> { |  | ||||||
|     const response = await fetch(url, options); |  | ||||||
|     const body = await response.json(); |  | ||||||
|     if (body.fail) |  | ||||||
|         throw body.fail; |  | ||||||
|     return body; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Transforms items in the credentialCreateOptions generated on the server |  * Transforms items in the credentialCreateOptions generated on the server | ||||||
|  * into byte arrays expected by the navigator.credentials.create() call |  * into byte arrays expected by the navigator.credentials.create() call | ||||||
| @ -84,20 +70,6 @@ export function transformNewAssertionForServer(newAssertion: PublicKeyCredential | |||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Get PublicKeyCredentialRequestOptions for this user from the server |  | ||||||
|  * formData of the registration form |  | ||||||
|  * @param {FormData} formData |  | ||||||
|  */ |  | ||||||
| export async function getCredentialRequestOptionsFromServer(): Promise<GenericResponse> { |  | ||||||
|     return await fetchJSON( |  | ||||||
|         "/-/user/authenticator/webauthn/begin-assertion/", |  | ||||||
|         { |  | ||||||
|             method: "POST", |  | ||||||
|         } |  | ||||||
|     ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function u8arr(input: string): Uint8Array { | function u8arr(input: string): Uint8Array { | ||||||
|     return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0)); |     return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0)); | ||||||
| } | } | ||||||
| @ -150,20 +122,3 @@ export function transformAssertionForServer(newAssertion: PublicKeyCredential): | |||||||
|         assertionClientExtensions: JSON.stringify(assertionClientExtensions) |         assertionClientExtensions: JSON.stringify(assertionClientExtensions) | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Post the assertion to the server for validation and logging the user in. |  | ||||||
|  * @param {Object} assertionDataForServer |  | ||||||
|  */ |  | ||||||
| export async function postAssertionToServer(assertionDataForServer: Assertion): Promise<GenericResponse> { |  | ||||||
|     const formData = new FormData(); |  | ||||||
|     Object.entries(assertionDataForServer).forEach(([key, value]) => { |  | ||||||
|         formData.set(key, value); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return await fetchJSON( |  | ||||||
|         "/-/user/authenticator/webauthn/verify-assertion/", { |  | ||||||
|             method: "POST", |  | ||||||
|             body: formData |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -1,13 +1,17 @@ | |||||||
| import { LitElement } from "lit-element"; | import { LitElement } from "lit-element"; | ||||||
| import { FlowExecutor } from "../../pages/generic/FlowExecutor"; |  | ||||||
|  | export interface StageHost { | ||||||
|  |     submit(formData?: FormData): Promise<void>; | ||||||
|  | } | ||||||
|  |  | ||||||
| export class BaseStage extends LitElement { | export class BaseStage extends LitElement { | ||||||
|  |  | ||||||
|     host?: FlowExecutor; |     host?: StageHost; | ||||||
|  |  | ||||||
|     submit(e: Event): void { |     submitForm(e: Event): void { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
|         const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); |         const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); | ||||||
|         this.host?.submit(form); |         this.host?.submit(form); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,9 +24,10 @@ import { AuthenticatorStaticChallenge } from "../../elements/stages/authenticato | |||||||
| import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; | import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; | ||||||
| import { COMMON_STYLES } from "../../common/styles"; | import { COMMON_STYLES } from "../../common/styles"; | ||||||
| import { SpinnerSize } from "../../elements/Spinner"; | import { SpinnerSize } from "../../elements/Spinner"; | ||||||
|  | import { StageHost } from "../../elements/stages/base"; | ||||||
|  |  | ||||||
| @customElement("ak-flow-executor") | @customElement("ak-flow-executor") | ||||||
| export class FlowExecutor extends LitElement { | export class FlowExecutor extends LitElement implements StageHost { | ||||||
|     @property() |     @property() | ||||||
|     flowSlug = ""; |     flowSlug = ""; | ||||||
|  |  | ||||||
| @ -158,8 +159,8 @@ export class FlowExecutor extends LitElement { | |||||||
|                         return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`; |                         return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`; | ||||||
|                     case "ak-stage-authenticator-static": |                     case "ak-stage-authenticator-static": | ||||||
|                         return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></ak-stage-authenticator-static>`; |                         return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></ak-stage-authenticator-static>`; | ||||||
|                     case "ak-stage-authenticator-webauthn-register": |                     case "ak-stage-authenticator-webauthn": | ||||||
|                         return html`<ak-stage-authenticator-webauthn-register .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn-register>`; |                         return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`; | ||||||
|                     default: |                     default: | ||||||
|                         break; |                         break; | ||||||
|                 } |                 } | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer