stages/authenticator_webauthn: migrate to SPA

This commit is contained in:
Jens Langhammer 2021-02-21 20:53:05 +01:00
parent 0904fea109
commit 76c572cf7c
9 changed files with 174 additions and 186 deletions

View file

@ -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 (

View file

@ -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]:

View file

@ -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()

View file

@ -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()),

View file

@ -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"""

View file

@ -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}`));
}

View file

@ -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

View file

@ -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";

View file

@ -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;
}