diff --git a/authentik/stages/authenticator_static/stage.py b/authentik/stages/authenticator_static/stage.py index 697653702..caa5ad6d2 100644 --- a/authentik/stages/authenticator_static/stage.py +++ b/authentik/stages/authenticator_static/stage.py @@ -1,7 +1,7 @@ """Static OTP Setup stage""" from django.http import HttpRequest, HttpResponse from django_otp.plugins.otp_static.models import StaticDevice, StaticToken -from rest_framework.fields import CharField, IntegerField, ListField +from rest_framework.fields import CharField, ListField from structlog.stdlib import get_logger from authentik.flows.challenge import ( diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py index 30eb1b7e3..2fb6c89af 100644 --- a/authentik/stages/authenticator_webauthn/models.py +++ b/authentik/stages/authenticator_webauthn/models.py @@ -27,10 +27,10 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage): @property def type(self) -> Type[View]: from authentik.stages.authenticator_webauthn.stage import ( - AuthenticateWebAuthnStageView, + AuthenticatorWebAuthnStageView, ) - return AuthenticateWebAuthnStageView + return AuthenticatorWebAuthnStageView @property def form(self) -> Type[ModelForm]: diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py index 3116dc161..581cac6da 100644 --- a/authentik/stages/authenticator_webauthn/stage.py +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -1,13 +1,27 @@ """WebAuthn stage""" from django.http import HttpRequest, HttpResponse -from django.shortcuts import render -from django.views.generic import FormView +from django.http.request import QueryDict +from rest_framework.fields import JSONField +from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger +from webauthn.webauthn import ( + RegistrationRejectedException, + WebAuthnMakeCredentialOptions, + WebAuthnRegistrationResponse, +) +from authentik.core.models import User +from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.stage import StageView +from authentik.flows.stage import ChallengeStageView +from authentik.lib.templatetags.authentik_utils import avatar from authentik.stages.authenticator_webauthn.models import WebAuthnDevice +from authentik.stages.authenticator_webauthn.utils import generate_challenge + +RP_ID = "localhost" +RP_NAME = "authentik" +ORIGIN = "http://localhost:8000" LOGGER = get_logger() @@ -16,29 +30,135 @@ SESSION_KEY_WEBAUTHN_AUTHENTICATED = ( ) -class AuthenticateWebAuthnStageView(FormView, StageView): +class AuthenticatorWebAuthnChallenge(Challenge): + """WebAuthn Challenge""" + + registration = JSONField() + + +class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): + """WebAuthn Challenge response""" + + response = JSONField() + + request: HttpRequest + user: User + + def validate_response(self, response: dict) -> dict: + """Validate webauthn challenge response""" + challenge = self.request.session["challenge"] + + trusted_attestation_cert_required = True + self_attestation_permitted = True + none_attestation_permitted = True + + webauthn_registration_response = WebAuthnRegistrationResponse( + RP_ID, + ORIGIN, + response, + challenge, + trusted_attestation_cert_required=trusted_attestation_cert_required, + self_attestation_permitted=self_attestation_permitted, + none_attestation_permitted=none_attestation_permitted, + uv_required=False, + ) # User Verification + + try: + webauthn_credential = webauthn_registration_response.verify() + except RegistrationRejectedException as exc: + LOGGER.warning("registration failed", exc=exc) + raise ValidationError("Registration failed. Error: {}".format(exc)) + + # Step 17. + # + # Check that the credentialId is not yet registered to any other user. + # If registration is requested for a credential that is already registered + # to a different user, the Relying Party SHOULD fail this registration + # ceremony, or it MAY decide to accept the registration, e.g. while deleting + # the older registration. + credential_id_exists = WebAuthnDevice.objects.filter( + credential_id=webauthn_credential.credential_id + ).first() + if credential_id_exists: + raise ValidationError("Credential ID already exists.") + + webauthn_credential.credential_id = str( + webauthn_credential.credential_id, "utf-8" + ) + webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8") + + return webauthn_registration_response + + +class AuthenticatorWebAuthnStageView(ChallengeStageView): """WebAuthn stage""" + response_class = AuthenticatorWebAuthnChallengeResponse + + def get_challenge(self, *args, **kwargs) -> Challenge: + # clear session variables prior to starting a new registration + self.request.session.pop("challenge", None) + + challenge = generate_challenge(32) + + # We strip the saved challenge of padding, so that we can do a byte + # comparison on the URL-safe-without-padding challenge we get back + # from the browser. + # We will still pass the padded version down to the browser so that the JS + # can decode the challenge into binary without too much trouble. + self.request.session["challenge"] = challenge.rstrip("=") + user = self.get_pending_user() + make_credential_options = WebAuthnMakeCredentialOptions( + challenge, + RP_NAME, + RP_ID, + user.uid, + user.username, + user.name, + avatar(user or User()), + ) + + return AuthenticatorWebAuthnChallenge( + data={ + "type": ChallengeTypes.native, + "component": "ak-stage-authenticator-webauthn-register", + "registration": make_credential_options.registration_dict, + } + ) + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) if not user: LOGGER.debug("No pending user, continuing") return self.executor.stage_ok() - devices = WebAuthnDevice.objects.filter(user=user) - # If the current user is logged in already, or the pending user - # has no devices, show setup - if self.request.user == user: - # Because the user is already authenticated, skip the later check - self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True - return render(request, "stages/authenticator_webauthn/setup.html") - if not devices.exists(): - return self.executor.stage_ok() - self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = False - return render(request, "stages/authenticator_webauthn/auth.html") + return super().get(request, *args, **kwargs) - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Since the client can't directly indicate when a stage is done, - we use the post handler for this""" - if request.session.pop(SESSION_KEY_WEBAUTHN_AUTHENTICATED, False): - return self.executor.stage_ok() - return self.executor.stage_invalid() + def get_response_instance( + self, data: QueryDict + ) -> AuthenticatorWebAuthnChallengeResponse: + response: AuthenticatorWebAuthnChallengeResponse = ( + super().get_response_instance(data) + ) + response.request = self.request + response.user = self.get_pending_user() + return response + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + # Webauthn Challenge has already been validated + webauthn_credential = response.validated_data["response"] + existing_device = WebAuthnDevice.objects.filter( + credential_id=webauthn_credential.credential_id + ).first() + if not existing_device: + WebAuthnDevice.objects.create( + user=self.get_pending_user(), + public_key=webauthn_credential.public_key, + credential_id=webauthn_credential.credential_id, + sign_count=webauthn_credential.sign_count, + rp_id=RP_ID, + ) + else: + return self.executor.stage_invalid( + "Device with Credential ID already exists." + ) + return self.executor.stage_ok() diff --git a/authentik/stages/authenticator_webauthn/urls.py b/authentik/stages/authenticator_webauthn/urls.py index d2d7e5c91..aadba3c76 100644 --- a/authentik/stages/authenticator_webauthn/urls.py +++ b/authentik/stages/authenticator_webauthn/urls.py @@ -3,30 +3,17 @@ from django.urls import path from django.views.decorators.csrf import csrf_exempt from authentik.stages.authenticator_webauthn.views import ( - BeginActivateView, BeginAssertion, UserSettingsView, VerifyAssertion, - VerifyCredentialInfo, ) -# TODO: Move to API views so we don't need csrf_exempt urlpatterns = [ - path( - "begin-activate/", - csrf_exempt(BeginActivateView.as_view()), - name="activate-begin", - ), path( "begin-assertion/", csrf_exempt(BeginAssertion.as_view()), name="assertion-begin", ), - path( - "verify-credential-info/", - csrf_exempt(VerifyCredentialInfo.as_view()), - name="credential-info-verify", - ), path( "verify-assertion/", csrf_exempt(VerifyAssertion.as_view()), diff --git a/authentik/stages/authenticator_webauthn/views.py b/authentik/stages/authenticator_webauthn/views.py index b9a4a4c57..4c3095236 100644 --- a/authentik/stages/authenticator_webauthn/views.py +++ b/authentik/stages/authenticator_webauthn/views.py @@ -9,8 +9,6 @@ from structlog.stdlib import get_logger from webauthn import ( WebAuthnAssertionOptions, WebAuthnAssertionResponse, - WebAuthnMakeCredentialOptions, - WebAuthnRegistrationResponse, WebAuthnUser, ) from webauthn.webauthn import ( @@ -53,101 +51,6 @@ class FlowUserRequiredView(View): return super().dispatch(request, *args, **kwargs) -class BeginActivateView(FlowUserRequiredView): - """Initial device registration view""" - - def post(self, request: HttpRequest) -> HttpResponse: - """Initial device registration view""" - # clear session variables prior to starting a new registration - request.session.pop("challenge", None) - - challenge = generate_challenge(32) - - # We strip the saved challenge of padding, so that we can do a byte - # comparison on the URL-safe-without-padding challenge we get back - # from the browser. - # We will still pass the padded version down to the browser so that the JS - # can decode the challenge into binary without too much trouble. - request.session["challenge"] = challenge.rstrip("=") - - make_credential_options = WebAuthnMakeCredentialOptions( - challenge, - RP_NAME, - RP_ID, - self.user.uid, - self.user.username, - self.user.name, - avatar(self.user), - ) - - return JsonResponse(make_credential_options.registration_dict) - - -class VerifyCredentialInfo(FlowUserRequiredView): - """Finish device registration""" - - def post(self, request: HttpRequest) -> HttpResponse: - """Finish device registration""" - challenge = request.session["challenge"] - - registration_response = request.POST - trusted_attestation_cert_required = True - self_attestation_permitted = True - none_attestation_permitted = True - - webauthn_registration_response = WebAuthnRegistrationResponse( - RP_ID, - ORIGIN, - registration_response, - challenge, - trusted_attestation_cert_required=trusted_attestation_cert_required, - self_attestation_permitted=self_attestation_permitted, - none_attestation_permitted=none_attestation_permitted, - uv_required=False, - ) # User Verification - - try: - webauthn_credential = webauthn_registration_response.verify() - except RegistrationRejectedException as exc: - LOGGER.warning("registration failed", exc=exc) - return JsonResponse({"fail": "Registration failed. Error: {}".format(exc)}) - - # Step 17. - # - # Check that the credentialId is not yet registered to any other user. - # If registration is requested for a credential that is already registered - # to a different user, the Relying Party SHOULD fail this registration - # ceremony, or it MAY decide to accept the registration, e.g. while deleting - # the older registration. - credential_id_exists = WebAuthnDevice.objects.filter( - credential_id=webauthn_credential.credential_id - ).first() - if credential_id_exists: - return JsonResponse({"fail": "Credential ID already exists."}, status=401) - - webauthn_credential.credential_id = str( - webauthn_credential.credential_id, "utf-8" - ) - webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8") - existing_device = WebAuthnDevice.objects.filter( - credential_id=webauthn_credential.credential_id - ).first() - if not existing_device: - user = WebAuthnDevice.objects.create( - user=self.user, - public_key=webauthn_credential.public_key, - credential_id=webauthn_credential.credential_id, - sign_count=webauthn_credential.sign_count, - rp_id=RP_ID, - ) - else: - return JsonResponse({"fail": "User already exists."}, status=401) - - LOGGER.debug("Successfully registered.", user=user) - - return JsonResponse({"success": "User successfully registered."}) - - class BeginAssertion(FlowUserRequiredView): """Send the client a challenge that we'll check later""" diff --git a/web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts b/web/src/elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage.ts similarity index 76% rename from web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts rename to web/src/elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage.ts index 393bccdf2..cd7201b11 100644 --- a/web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts +++ b/web/src/elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage.ts @@ -1,10 +1,23 @@ import { gettext } from "django"; -import { customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { customElement, html, property, TemplateResult } from "lit-element"; +import { WithUserInfoChallenge } from "../../../api/Flows"; import { SpinnerSize } from "../../Spinner"; -import { getCredentialCreateOptionsFromServer, postNewAssertionToServer, transformCredentialCreateOptions, transformNewAssertionForServer } from "./utils"; +import { BaseStage } from "../base"; +import { Assertion, transformCredentialCreateOptions, transformNewAssertionForServer } from "./utils"; -@customElement("ak-stage-webauthn-register") -export class WebAuthnRegister extends LitElement { +export interface WebAuthnAuthenticatorRegisterChallenge extends WithUserInfoChallenge { + registration: PublicKeyCredentialCreationOptions; +} + +export interface WebAuthnAuthenticatorRegisterChallengeResponse { + response: Assertion; +} + +@customElement("ak-stage-authenticator-webauthn-register") +export class WebAuthnAuthenticatorRegisterStage extends BaseStage { + + @property({ attribute: false }) + challenge?: WebAuthnAuthenticatorRegisterChallenge; @property({type: Boolean}) registerRunning = false; @@ -17,17 +30,12 @@ export class WebAuthnRegister extends LitElement { } async register(): Promise { - // post the data to the server to generate the PublicKeyCredentialCreateOptions - let credentialCreateOptionsFromServer; - try { - credentialCreateOptionsFromServer = await getCredentialCreateOptionsFromServer(); - } catch (err) { - throw new Error(gettext(`Failed to generate credential request options: ${err}`)); + if (!this.challenge) { + return; } - // convert certain members of the PublicKeyCredentialCreateOptions into // byte arrays as expected by the spec. - const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer); + const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(this.challenge?.registration); // request the authenticator(s) to create a new credential keypair. let credential; @@ -49,7 +57,10 @@ export class WebAuthnRegister extends LitElement { // post the transformed credential data to the server for validation // and storing the public key try { - await postNewAssertionToServer(newAssertionForServer); + const response = { + response: newAssertionForServer + }; + await this.host?.submit(JSON.stringify(response)); } catch (err) { throw new Error(gettext(`Server validation of credential failed: ${err}`)); } diff --git a/web/src/elements/stages/authenticator_webauthn/utils.ts b/web/src/elements/stages/authenticator_webauthn/utils.ts index 686f0e441..c614df4d7 100644 --- a/web/src/elements/stages/authenticator_webauthn/utils.ts +++ b/web/src/elements/stages/authenticator_webauthn/utils.ts @@ -84,38 +84,6 @@ export function transformNewAssertionForServer(newAssertion: PublicKeyCredential }; } -/** - * Post the assertion to the server for validation and logging the user in. - * @param {Object} assertionDataForServer - */ -export async function postNewAssertionToServer(assertionDataForServer: Assertion): Promise { - const formData = new FormData(); - Object.entries(assertionDataForServer).forEach(([key, value]) => { - formData.set(key, value); - }); - - return await fetchJSON( - "/-/user/authenticator/webauthn/verify-credential-info/", { - method: "POST", - body: formData - }); -} - -/** - * Get PublicKeyCredentialRequestOptions for this user from the server - * formData of the registration form - * @param {FormData} formData - */ -export async function getCredentialCreateOptionsFromServer(): Promise { - return await fetchJSON( - "/-/user/authenticator/webauthn/begin-activate/", - { - method: "POST", - } - ); -} - - /** * Get PublicKeyCredentialRequestOptions for this user from the server * formData of the registration form diff --git a/web/src/main.ts b/web/src/main.ts index 05f54d56d..9d0176228 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -31,9 +31,4 @@ import "./pages/applications/ApplicationViewPage"; import "./pages/tokens/UserTokenList"; import "./pages/LibraryPage"; -import "./elements/stages/authenticator_webauthn/WebAuthnRegister"; -import "./elements/stages/authenticator_webauthn/WebAuthnAuth"; -import "./elements/stages/authenticator_validate/AuthenticatorValidateStage"; -import "./elements/stages/identification/IdentificationStage"; - import "./interfaces/AdminInterface"; diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index f3e58a3f6..13ad721d4 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -10,6 +10,7 @@ import "../../elements/stages/autosubmit/AutosubmitStage"; import "../../elements/stages/prompt/PromptStage"; import "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; import "../../elements/stages/authenticator_static/AuthenticatorStaticStage"; +import "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; import { DefaultClient } from "../../api/Client"; import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; @@ -20,6 +21,7 @@ import { AutosubmitChallenge } from "../../elements/stages/autosubmit/Autosubmit import { PromptChallenge } from "../../elements/stages/prompt/PromptStage"; import { AuthenticatorTOTPChallenge } from "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; import { AuthenticatorStaticChallenge } from "../../elements/stages/authenticator_static/AuthenticatorStaticStage"; +import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement { @@ -40,14 +42,14 @@ export class FlowExecutor extends LitElement { }); } - submit(formData?: FormData): void { + submit(formData?: string | FormData): Promise { const csrftoken = getCookie("authentik_csrf"); const request = new Request(DefaultClient.makeUrl(["flows", "executor", this.flowSlug]), { headers: { "X-CSRFToken": csrftoken, }, }); - fetch(request, { + return fetch(request, { method: "POST", mode: "same-origin", body: formData, @@ -132,6 +134,8 @@ export class FlowExecutor extends LitElement { return html``; case "ak-stage-authenticator-static": return html``; + case "ak-stage-authenticator-webauthn-register": + return html``; default: break; }