stages: add WebAuthn stage (#550)
* core: add User.uid for globally unique user ID * admin: fix ?next for Flow list * stages: add initial webauthn implementation * web: add ak-flow-submit event to submit flow stage * web: show error message for webauthn registration * admin: fix next param not redirecting correctly * stages/webauthn: remove form * stages/webauthn: add API * web: update flow diagram on ak-refresh * stages/webauthn: add initial authentication * stages/webauthn: initial authentication implementation * web: cleanup webauthn utils * stages: rename otp_* to authenticator and move webauthn to authenticator * docs: fix broken links * stages/authenticator_*: fix template paths * stages/authenticator_validate: add device classes * stages/authenticator_webauthn: implement django_otp.devices * stages/authenticator_*: update default stage names * web: add button to create stage on flow page * web: don't minify HTML, remove nbsp * admin: fix typo in stage list * stages/*: use common base class for stage serializer * stages/authenticator_*: create default objects after rename * tests/e2e: adjust stage order
This commit is contained in:
@ -56,7 +56,7 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
|
||||
${gettext("Edit")}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="${PolicyBinding.adminUrl(`${item.pk}/delete/`)}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-danger">
|
||||
${gettext("Delete")}
|
||||
|
||||
106
web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts
Normal file
106
web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { gettext } from "django";
|
||||
import { customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
import { SpinnerSize } from "../../Spinner";
|
||||
import { getCredentialRequestOptionsFromServer, postAssertionToServer, transformAssertionForServer, transformCredentialRequestOptions } from "./utils";
|
||||
|
||||
@customElement("ak-stage-webauthn-auth")
|
||||
export class WebAuthnAuth extends LitElement {
|
||||
|
||||
@property({ type: Boolean })
|
||||
authenticateRunning = false;
|
||||
|
||||
@property()
|
||||
authenticateMessage = "";
|
||||
|
||||
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
|
||||
// byte arrays as expected by the spec.
|
||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(
|
||||
credentialRequestOptionsFromServer);
|
||||
|
||||
// request the authenticator to create an assertion signature using the
|
||||
// credential private key
|
||||
let assertion;
|
||||
try {
|
||||
assertion = await navigator.credentials.get({
|
||||
publicKey: transformedCredentialRequestOptions,
|
||||
});
|
||||
if (!assertion) {
|
||||
throw new Error(gettext("Assertions is empty"));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error when creating credential: ${err}`));
|
||||
}
|
||||
|
||||
// we now have an authentication assertion! encode the byte arrays contained
|
||||
// in the assertion data as strings for posting to the server
|
||||
const transformedAssertionForServer = transformAssertionForServer(assertion);
|
||||
|
||||
// post the assertion to the server for verification.
|
||||
try {
|
||||
await postAssertionToServer(transformedAssertionForServer);
|
||||
} catch (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 {
|
||||
this.authenticateWrapper();
|
||||
}
|
||||
|
||||
async authenticateWrapper(): Promise<void> {
|
||||
if (this.authenticateRunning) {
|
||||
return;
|
||||
}
|
||||
this.authenticateRunning = true;
|
||||
this.authenticate().catch((e) => {
|
||||
console.error(gettext(e));
|
||||
this.authenticateMessage = e.toString();
|
||||
}).finally(() => {
|
||||
this.authenticateRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="">
|
||||
${this.authenticateRunning ?
|
||||
html`<div class="pf-c-empty-state__content">
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<ak-spinner size="${SpinnerSize.XLarge}"></ak-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>`:
|
||||
html`
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<p class="pf-m-block">${this.authenticateMessage}</p>
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" @click=${() => {
|
||||
this.authenticateWrapper();
|
||||
}}>
|
||||
${gettext("Retry authentication")}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import { gettext } from "django";
|
||||
import { customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
import { SpinnerSize } from "../../Spinner";
|
||||
import { getCredentialCreateOptionsFromServer, postNewAssertionToServer, transformCredentialCreateOptions, transformNewAssertionForServer } from "./utils";
|
||||
|
||||
@customElement("ak-stage-webauthn-register")
|
||||
export class WebAuthnRegister extends LitElement {
|
||||
|
||||
@property({type: Boolean})
|
||||
registerRunning = false;
|
||||
|
||||
@property()
|
||||
registerMessage = "";
|
||||
|
||||
createRenderRoot(): Element | ShadowRoot {
|
||||
return this;
|
||||
}
|
||||
|
||||
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}`));
|
||||
}
|
||||
|
||||
// convert certain members of the PublicKeyCredentialCreateOptions into
|
||||
// byte arrays as expected by the spec.
|
||||
const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer);
|
||||
|
||||
// request the authenticator(s) to create a new credential keypair.
|
||||
let credential;
|
||||
try {
|
||||
credential = <PublicKeyCredential> await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreateOptions
|
||||
});
|
||||
if (!credential) {
|
||||
throw new Error("Credential is empty");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error creating credential: ${err}`));
|
||||
}
|
||||
|
||||
// we now have a new credential! We now need to encode the byte arrays
|
||||
// in the credential into strings, for posting to our server.
|
||||
const newAssertionForServer = transformNewAssertionForServer(credential);
|
||||
|
||||
// post the transformed credential data to the server for validation
|
||||
// and storing the public key
|
||||
try {
|
||||
await postNewAssertionToServer(newAssertionForServer);
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
||||
}
|
||||
this.finishStage();
|
||||
}
|
||||
|
||||
async registerWrapper(): Promise<void> {
|
||||
if (this.registerRunning) {
|
||||
return;
|
||||
}
|
||||
this.registerRunning = true;
|
||||
this.register().catch((e) => {
|
||||
console.error(e);
|
||||
this.registerMessage = e.toString();
|
||||
}).finally(() => {
|
||||
this.registerRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
finishStage(): void {
|
||||
// Mark this stage as done
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-flow-submit", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.registerWrapper();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="">
|
||||
${this.registerRunning ?
|
||||
html`<div class="pf-c-empty-state__content">
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<ak-spinner size="${SpinnerSize.XLarge}"></ak-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>`:
|
||||
html`
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<p class="pf-m-block">${this.registerMessage}</p>
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" @click=${() => {
|
||||
this.registerWrapper();
|
||||
}}>
|
||||
${gettext("Register device")}
|
||||
</button>
|
||||
<button class="pf-c-button pf-m-secondary pf-m-block" @click=${() => {
|
||||
this.finishStage();
|
||||
}}>
|
||||
${gettext("Skip")}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
}
|
||||
201
web/src/elements/stages/authenticator_webauthn/utils.ts
Normal file
201
web/src/elements/stages/authenticator_webauthn/utils.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import * as base64js from "base64-js";
|
||||
|
||||
export function b64enc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
export function b64RawEnc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export function hexEncode(buf: Uint8Array): string {
|
||||
return Array.from(buf)
|
||||
.map(function (x) {
|
||||
return ("0" + x.toString(16)).substr(-2);
|
||||
})
|
||||
.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
|
||||
* into byte arrays expected by the navigator.credentials.create() call
|
||||
*/
|
||||
export function transformCredentialCreateOptions(credentialCreateOptions: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions {
|
||||
const user = credentialCreateOptions.user;
|
||||
user.id = u8arr(credentialCreateOptions.user.id.toString());
|
||||
const challenge = u8arr(credentialCreateOptions.challenge.toString());
|
||||
|
||||
const transformedCredentialCreateOptions = Object.assign(
|
||||
{}, credentialCreateOptions,
|
||||
{ challenge, user });
|
||||
|
||||
return transformedCredentialCreateOptions;
|
||||
}
|
||||
|
||||
export interface Assertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
attObj: string;
|
||||
clientData: string;
|
||||
registrationClientExtensions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the binary data in the credential into base64 strings
|
||||
* for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
|
||||
const attObj = new Uint8Array(
|
||||
(<AuthenticatorAttestationResponse>newAssertion.response).attestationObject);
|
||||
const clientDataJSON = new Uint8Array(
|
||||
newAssertion.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(
|
||||
newAssertion.rawId);
|
||||
|
||||
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
attObj: b64enc(attObj),
|
||||
clientData: b64enc(clientDataJSON),
|
||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {FormData} formData
|
||||
*/
|
||||
export async function getCredentialRequestOptionsFromServer(): Promise<GenericResponse> {
|
||||
return await fetchJSON(
|
||||
"/-/user/authenticator/webauthn/begin-assertion/",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function u8arr(input: string): Uint8Array {
|
||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function transformCredentialRequestOptions(credentialRequestOptions: PublicKeyCredentialRequestOptions): PublicKeyCredentialRequestOptions {
|
||||
const challenge = u8arr(credentialRequestOptions.challenge.toString());
|
||||
|
||||
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(credentialDescriptor => {
|
||||
const id = u8arr(credentialDescriptor.id.toString());
|
||||
return Object.assign({}, credentialDescriptor, { id });
|
||||
});
|
||||
|
||||
const transformedCredentialRequestOptions = Object.assign(
|
||||
{},
|
||||
credentialRequestOptions,
|
||||
{ challenge, allowCredentials });
|
||||
|
||||
return transformedCredentialRequestOptions;
|
||||
}
|
||||
|
||||
export interface AuthAssertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
clientData: string;
|
||||
authData: string;
|
||||
signature: string;
|
||||
assertionClientExtensions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the binary data in the assertion into strings for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion{
|
||||
const response = <AuthenticatorAssertionResponse> newAssertion.response;
|
||||
const authData = new Uint8Array(response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
const sig = new Uint8Array(response.signature);
|
||||
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
||||
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
authData: b64RawEnc(authData),
|
||||
clientData: b64RawEnc(clientDataJSON),
|
||||
signature: hexEncode(sig),
|
||||
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
|
||||
});
|
||||
}
|
||||
@ -194,11 +194,11 @@ export abstract class Table<T> extends LitElement {
|
||||
}
|
||||
|
||||
renderToolbar(): TemplateResult {
|
||||
return html` <button
|
||||
return html`<button
|
||||
@click=${() => { this.fetch(); }}
|
||||
class="pf-c-button pf-m-primary">
|
||||
${gettext("Refresh")}
|
||||
</button> `;
|
||||
</button>`;
|
||||
}
|
||||
|
||||
renderToolbarAfter(): TemplateResult {
|
||||
@ -216,10 +216,10 @@ export abstract class Table<T> extends LitElement {
|
||||
renderTable(): TemplateResult {
|
||||
return html`<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
${this.renderSearch()}
|
||||
${this.renderSearch()}
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
${this.renderToolbar()}
|
||||
</div>
|
||||
</div>
|
||||
${this.renderToolbarAfter()}
|
||||
<ak-table-pagination
|
||||
class="pf-c-toolbar__item pf-m-pagination"
|
||||
|
||||
Reference in New Issue
Block a user