stages/authenticator_webauthn: migrate to SPA
This commit is contained in:
parent
0904fea109
commit
76c572cf7c
|
@ -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 (
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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):
|
||||
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()
|
||||
return self.executor.stage_invalid()
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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<void> {
|
||||
// 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 = <WebAuthnAuthenticatorRegisterChallengeResponse>{
|
||||
response: newAssertionForServer
|
||||
};
|
||||
await this.host?.submit(JSON.stringify(response));
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
||||
}
|
|
@ -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<GenericResponse> {
|
||||
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<GenericResponse> {
|
||||
return await fetchJSON(
|
||||
"/-/user/authenticator/webauthn/begin-activate/",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get PublicKeyCredentialRequestOptions for this user from the server
|
||||
* formData of the registration form
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<void> {
|
||||
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`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`;
|
||||
case "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":
|
||||
return html`<ak-stage-authenticator-webauthn-register .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn-register>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
Reference in New Issue