diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 56b779524..c83cc190b 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -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, } ) diff --git a/web/src/authentik.css b/web/src/authentik.css index 7ad666e7f..8f0b17320 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -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 { diff --git a/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStage.ts index 4f9aed8da..8cf305848 100644 --- a/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -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 { + 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` +
+

${gettext("Authenticator")}

+ ${gettext("Use a security key to prove your identity.")} +
`; + case DeviceClasses.TOTP: + return html` +
+

${gettext("Traditional authenticator")}

+ ${gettext("Use a code-based authenticator.")} +
`; + case DeviceClasses.STATIC: + return html` +
+

${gettext("Recovery keys")}

+ ${gettext("In case you can't access any other method.")} +
`; + default: + break; + } + return html``; + } + + renderDevicePicker(): TemplateResult { + return html` + `; + } + 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` + `; case DeviceClasses.WEBAUTHN: return html` { - return this.host?.submit(formData) || Promise.resolve(); - } - render(): TemplateResult { + if (!this.challenge) { + return html``; + } // 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` + +
+ +
`; } } diff --git a/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStageCode.ts b/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStageCode.ts new file mode 100644 index 000000000..7d1ad13ba --- /dev/null +++ b/web/src/elements/stages/authenticator_validate/AuthenticatorValidateStageCode.ts @@ -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``; + } + return html`
{ this.submitForm(e); }}> +
+
+
+ ${gettext( + ${this.challenge.pending_user} +
+ +
+
+ + + + + + +
+ +
+
`; + } + +} diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index 8e73546dd..dd7b03a8a 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -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``; case "ak-stage-authenticator-webauthn": return html``; + case "ak-stage-authenticator-validate": + return html``; default: break; }