stages/authenticator_validate: create challenge per device, implement class switcher

This commit is contained in:
Jens Langhammer 2021-02-23 23:43:13 +01:00
parent e8259791f0
commit 3cdb81c5ba
5 changed files with 209 additions and 34 deletions

View file

@ -19,10 +19,14 @@ from authentik.stages.authenticator_validate.challenge import (
get_challenge_for_device,
validate_challenge,
)
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
LOGGER = get_logger()
PER_DEVICE_CLASSES = [
DeviceClasses.WEBAUTHN
]
class AuthenticatorChallenge(WithUserInfoChallenge):
"""Authenticator challenge"""
@ -48,7 +52,36 @@ class AuthenticatorValidateStageView(ChallengeStageView):
response_class = AuthenticatorChallengeResponse
challenges: list[DeviceChallenge]
def get_device_challenges(self) -> list[dict]:
challenges = []
user_devices = devices_for_user(self.get_pending_user())
# static and totp are only shown once
# since their challenges are device-independant
seen_classes = []
stage: AuthenticatorValidateStage = self.executor.current_stage
for device in user_devices:
device_class = device.__class__.__name__.lower().replace("device", "")
if device_class not in stage.device_classes:
continue
# Ensure only classes in PER_DEVICE_CLASSES are returned per device
# otherwise only return a single challenge
if device_class in seen_classes and device_class not in PER_DEVICE_CLASSES:
continue
if device_class not in seen_classes:
seen_classes.append(device_class)
challenges.append(
DeviceChallenge(
data={
"device_class": device_class,
"device_uid": device.pk,
"challenge": get_challenge_for_device(self.request, device),
}
).initial_data
)
return challenges
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check if a user is set, and check if the user has any devices
@ -58,26 +91,11 @@ class AuthenticatorValidateStageView(ChallengeStageView):
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
stage: AuthenticatorValidateStage = self.executor.current_stage
self.challenges = []
user_devices = devices_for_user(self.get_pending_user())
for device in user_devices:
device_class = device.__class__.__name__.lower().replace("device", "")
if device_class not in stage.device_classes:
continue
self.challenges.append(
DeviceChallenge(
data={
"device_class": device_class,
"device_uid": device.pk,
"challenge": get_challenge_for_device(request, device),
}
)
)
challenges = self.get_device_challenges()
self.request.session["device_challenges"] = challenges
# No allowed devices
if len(self.challenges) < 1:
if len(challenges) < 1:
if stage.not_configured_action == NotConfiguredAction.SKIP:
LOGGER.debug("Authenticator not configured, skipping stage")
return self.executor.stage_ok()
@ -87,11 +105,12 @@ class AuthenticatorValidateStageView(ChallengeStageView):
return super().get(request, *args, **kwargs)
def get_challenge(self) -> AuthenticatorChallenge:
challenges = self.request.session["device_challenges"]
return AuthenticatorChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-validate",
"device_challenges": self.challenges,
"device_challenges": challenges,
}
)

View file

@ -230,7 +230,9 @@ select[multiple] {
.pf-c-login__main {
background-color: var(--ak-dark-background);
}
.pf-c-login__main-body {
.pf-c-login__main-body,
.pf-c-login__main-header,
.pf-c-login__main-header-desc {
color: var(--ak-dark-foreground);
}
.pf-c-login__main-footer-links-item-link > img {

View file

@ -1,7 +1,10 @@
import { customElement, html, property, TemplateResult } from "lit-element";
import { gettext } from "django";
import { css, CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { WithUserInfoChallenge } from "../../../api/Flows";
import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage, StageHost } from "../base";
import "./AuthenticatorValidateStageWebAuthn";
import "./AuthenticatorValidateStageCode";
export enum DeviceClasses {
STATIC = "static",
@ -32,6 +35,79 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
@property({attribute: false})
selectedDeviceChallenge?: DeviceChallenge;
submit(formData?: FormData): Promise<void> {
return this.host?.submit(formData) || Promise.resolve();
}
static get styles(): CSSResult[] {
return COMMON_STYLES.concat(css`
ul > li:not(:last-child) {
padding-bottom: 1rem;
}
.authenticator-button {
display: flex;
align-items: center;
width: 100%;
}
i {
font-size: 1.5rem;
padding: 1rem 0;
width: 5rem;
}
.right {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
text-align: left;
}
.right > * {
height: 50%;
}
`);
}
renderDevicePickerSingle(deviceChallenge: DeviceChallenge): TemplateResult {
switch (deviceChallenge.device_class) {
case DeviceClasses.WEBAUTHN:
return html`<i class="fas fa-mobile-alt"></i>
<div class="right">
<p>${gettext("Authenticator")}</p>
<small>${gettext("Use a security key to prove your identity.")}</small>
</div>`;
case DeviceClasses.TOTP:
return html`<i class="fas fa-clock"></i>
<div class="right">
<p>${gettext("Traditional authenticator")}</p>
<small>${gettext("Use a code-based authenticator.")}</small>
</div>`;
case DeviceClasses.STATIC:
return html`<i class="fas fa-key"></i>
<div class="right">
<p>${gettext("Recovery keys")}</p>
<small>${gettext("In case you can't access any other method.")}</small>
</div>`;
default:
break;
}
return html``;
}
renderDevicePicker(): TemplateResult {
return html`
<ul>
${this.challenge?.device_challenges.map((challenges) => {
return html`<li>
<button class="pf-c-button authenticator-button" type="button" @click=${() => {
this.selectedDeviceChallenge = challenges;
}}>
${this.renderDevicePickerSingle(challenges)}
</button>
</li>`;
})}
</ul>`;
}
renderDeviceChallenge(): TemplateResult {
if (!this.selectedDeviceChallenge) {
return html``;
@ -39,8 +115,11 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
switch (this.selectedDeviceChallenge?.device_class) {
case DeviceClasses.STATIC:
case DeviceClasses.TOTP:
// TODO: Create input for code
return html``;
return html`<ak-stage-authenticator-validate-code
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}>
</ak-stage-authenticator-validate-code>`;
case DeviceClasses.WEBAUTHN:
return html`<ak-stage-authenticator-validate-webauthn
.host=${this}
@ -50,20 +129,29 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
}
}
submit(formData?: FormData): Promise<void> {
return this.host?.submit(formData) || Promise.resolve();
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-loading-state></ak-loading-state>`;
}
// User only has a single device class, so we don't show a picker
if (this.challenge?.device_challenges.length === 1) {
this.selectedDeviceChallenge = this.challenge.device_challenges[0];
}
if (this.selectedDeviceChallenge) {
return this.renderDeviceChallenge();
}
// TODO: Create picker between challenges
return html`ak-stage-authenticator-validate`;
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
${this.challenge.title}
</h1>
${this.selectedDeviceChallenge ? "" : html`<p class="pf-c-login__main-header-desc">
${gettext("Select an identification method.")}
</p>`}
</header>
<div class="pf-c-login__main-body">
${this.selectedDeviceChallenge ? this.renderDeviceChallenge() : this.renderDevicePicker()}
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
</ul>
</footer>`;
}
}

View file

@ -0,0 +1,62 @@
import { gettext } from "django";
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage } from "../base";
import { AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage";
@customElement("ak-stage-authenticator-validate-code")
export class AuthenticatorValidateStageWebCode extends BaseStage {
@property({ attribute: false })
challenge?: AuthenticatorValidateStageChallenge;
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-loading-state></ak-loading-state>`;
}
return html`<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
${this.challenge.pending_user}
</div>
<div class="right">
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
</div>
</div>
</div>
<ak-form-element
label="${gettext("Code")}"
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge?.response_errors || {})["code"]}>
<!-- @ts-ignore -->
<input type="text"
name="code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="${gettext("Please enter your TOTP Code")}"
autofocus=""
autocomplete="one-time-code"
class="pf-c-form-control"
required="">
</ak-form-element>
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${gettext("Continue")}
</button>
</div>
</form>`;
}
}

View file

@ -11,6 +11,7 @@ import "../../elements/stages/prompt/PromptStage";
import "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage";
import "../../elements/stages/authenticator_static/AuthenticatorStaticStage";
import "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
import "../../elements/stages/authenticator_validate/AuthenticatorValidateStage";
import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows";
import { DefaultClient } from "../../api/Client";
import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage";
@ -21,6 +22,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 { AuthenticatorValidateStageChallenge } from "../../elements/stages/authenticator_validate/AuthenticatorValidateStage";
import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
import { COMMON_STYLES } from "../../common/styles";
import { SpinnerSize } from "../../elements/Spinner";
@ -161,6 +163,8 @@ export class FlowExecutor extends LitElement implements StageHost {
return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></ak-stage-authenticator-static>`;
case "ak-stage-authenticator-webauthn":
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
case "ak-stage-authenticator-validate":
return html`<ak-stage-authenticator-validate .host=${this} .challenge=${this.challenge as AuthenticatorValidateStageChallenge}></ak-stage-authenticator-validate>`;
default:
break;
}