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:
Jens L
2021-02-17 20:49:58 +01:00
committed by GitHub
parent e020b8bf32
commit 8708e487ae
128 changed files with 2949 additions and 874 deletions

View File

@ -56,7 +56,7 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
${gettext("Edit")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>&nbsp;
</ak-modal-button>
<ak-modal-button href="${PolicyBinding.adminUrl(`${item.pk}/delete/`)}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
${gettext("Delete")}

View 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>`;
}
}

View File

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

View 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
});
}

View File

@ -194,11 +194,11 @@ export abstract class Table<T> extends LitElement {
}
renderToolbar(): TemplateResult {
return html`&nbsp;<button
return html`<button
@click=${() => { this.fetch(); }}
class="pf-c-button pf-m-primary">
${gettext("Refresh")}
</button>&nbsp;`;
</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()}&nbsp;
${this.renderSearch()}
<div class="pf-c-toolbar__bulk-select">
${this.renderToolbar()}
</div>&nbsp;
</div>
${this.renderToolbarAfter()}
<ak-table-pagination
class="pf-c-toolbar__item pf-m-pagination"