stages/authenticator_validate: create challenge per device, implement class switcher
This commit is contained in:
parent
e8259791f0
commit
3cdb81c5ba
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Reference in a new issue