stages/authenticator_sms: Add SMS Authenticator Stage (#1577)
* stages/authenticator_sms: initial implementation Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/admin: add initial stage UI Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/elements: clear invalid state when old input was invalid but new input is correct Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * stages/authenticator_sms: add more logic Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/user: add basic SMS settings Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * stages/authenticator_sms: initial working version Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * stages/authenticator_sms: add tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/flows: optimise totp password manager entry on authenticator_validation stage Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/elements: add grouping support for table Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/admin: allow sms class in authenticator stage Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/admin: add grouping to more pages Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * stages/authenticator_validate: add SMS support Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * api: add throttling for flow executor based on session key and pending user Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: fix style issues Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * ci: add workflow to compile backend translations Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
@ -36,6 +36,7 @@ import "./access_denied/FlowAccessDenied";
|
||||
import "./sources/plex/PlexLoginInit";
|
||||
import "./stages/RedirectStage";
|
||||
import "./stages/authenticator_duo/AuthenticatorDuoStage";
|
||||
import "./stages/authenticator_sms/AuthenticatorSMSStage";
|
||||
import "./stages/authenticator_static/AuthenticatorStaticStage";
|
||||
import "./stages/authenticator_totp/AuthenticatorTOTPStage";
|
||||
import "./stages/authenticator_validate/AuthenticatorValidateStage";
|
||||
@ -311,6 +312,11 @@ export class FlowExecutor extends LitElement implements StageHost {
|
||||
.host=${this as StageHost}
|
||||
.challenge=${this.challenge}
|
||||
></ak-stage-authenticator-validate>`;
|
||||
case "ak-stage-authenticator-sms":
|
||||
return html`<ak-stage-authenticator-sms
|
||||
.host=${this as StageHost}
|
||||
.challenge=${this.challenge}
|
||||
></ak-stage-authenticator-sms>`;
|
||||
case "ak-flow-sources-plex":
|
||||
return html`<ak-flow-sources-plex
|
||||
.host=${this as StageHost}
|
||||
|
||||
158
web/src/flows/stages/authenticator_sms/AuthenticatorSMSStage.ts
Normal file
158
web/src/flows/stages/authenticator_sms/AuthenticatorSMSStage.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
|
||||
import AKGlobal from "../../../authentik.css";
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import {
|
||||
AuthenticatorSMSChallenge,
|
||||
AuthenticatorSMSChallengeResponseRequest,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import "../../../elements/EmptyState";
|
||||
import "../../../elements/forms/FormElement";
|
||||
import "../../FormStatic";
|
||||
import { BaseStage } from "../base";
|
||||
|
||||
@customElement("ak-stage-authenticator-sms")
|
||||
export class AuthenticatorSMSStage extends BaseStage<
|
||||
AuthenticatorSMSChallenge,
|
||||
AuthenticatorSMSChallengeResponseRequest
|
||||
> {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal];
|
||||
}
|
||||
|
||||
renderPhoneNumber(): TemplateResult {
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form
|
||||
class="pf-c-form"
|
||||
@submit=${(e: Event) => {
|
||||
this.submitForm(e);
|
||||
}}
|
||||
>
|
||||
<ak-form-static
|
||||
class="pf-c-form__group"
|
||||
userAvatar="${this.challenge.pendingUserAvatar}"
|
||||
user=${this.challenge.pendingUser}
|
||||
>
|
||||
<div slot="link">
|
||||
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
|
||||
>${t`Not you?`}</a
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element
|
||||
label="${t`Phone number`}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})["phone_number"]}
|
||||
>
|
||||
<!-- @ts-ignore -->
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
placeholder="${t`Please enter your Phone number.`}"
|
||||
autofocus=""
|
||||
autocomplete="tel"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${"non_field_errors" in (this.challenge?.responseErrors || {})
|
||||
? this.renderNonFieldErrors(
|
||||
this.challenge?.responseErrors?.non_field_errors || [],
|
||||
)
|
||||
: html``}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${t`Continue`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links"></ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
renderCode(): TemplateResult {
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form
|
||||
class="pf-c-form"
|
||||
@submit=${(e: Event) => {
|
||||
this.submitForm(e);
|
||||
}}
|
||||
>
|
||||
<ak-form-static
|
||||
class="pf-c-form__group"
|
||||
userAvatar="${this.challenge.pendingUserAvatar}"
|
||||
user=${this.challenge.pendingUser}
|
||||
>
|
||||
<div slot="link">
|
||||
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
|
||||
>${t`Not you?`}</a
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element
|
||||
label="${t`Code`}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})["code"]}
|
||||
>
|
||||
<!-- @ts-ignore -->
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="${t`Please enter your TOTP Code`}"
|
||||
autofocus=""
|
||||
autocomplete="one-time-code"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${"non_field_errors" in (this.challenge?.responseErrors || {})
|
||||
? this.renderNonFieldErrors(
|
||||
this.challenge?.responseErrors?.non_field_errors || [],
|
||||
)
|
||||
: html``}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${t`Continue`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links"></ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
|
||||
}
|
||||
if (this.challenge.phoneNumberRequired) {
|
||||
return this.renderPhoneNumber();
|
||||
}
|
||||
return this.renderCode();
|
||||
}
|
||||
}
|
||||
@ -15,21 +15,17 @@ import {
|
||||
AuthenticatorValidationChallenge,
|
||||
AuthenticatorValidationChallengeResponseRequest,
|
||||
DeviceChallenge,
|
||||
DeviceClassesEnum,
|
||||
FlowsApi,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||
import { BaseStage, StageHost } from "../base";
|
||||
import { PasswordManagerPrefill } from "../identification/IdentificationStage";
|
||||
import "./AuthenticatorValidateStageCode";
|
||||
import "./AuthenticatorValidateStageDuo";
|
||||
import "./AuthenticatorValidateStageWebAuthn";
|
||||
|
||||
export enum DeviceClasses {
|
||||
STATIC = "static",
|
||||
TOTP = "totp",
|
||||
WEBAUTHN = "webauthn",
|
||||
DUO = "duo",
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-validate")
|
||||
export class AuthenticatorValidateStage
|
||||
extends BaseStage<
|
||||
@ -38,8 +34,29 @@ export class AuthenticatorValidateStage
|
||||
>
|
||||
implements StageHost
|
||||
{
|
||||
flowSlug = "";
|
||||
|
||||
_selectedDeviceChallenge?: DeviceChallenge;
|
||||
|
||||
@property({ attribute: false })
|
||||
selectedDeviceChallenge?: DeviceChallenge;
|
||||
set selectedDeviceChallenge(value: DeviceChallenge | undefined) {
|
||||
this._selectedDeviceChallenge = value;
|
||||
// We don't use this.submit here, as we don't want to advance the flow.
|
||||
// We just want to notify the backend which challenge has been selected.
|
||||
new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
|
||||
flowSlug: this.host.flowSlug,
|
||||
query: window.location.search.substring(1),
|
||||
flowChallengeResponseRequest: {
|
||||
// @ts-ignore
|
||||
component: this.challenge.component || "",
|
||||
selectedChallenge: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get selectedDeviceChallenge(): DeviceChallenge | undefined {
|
||||
return this._selectedDeviceChallenge;
|
||||
}
|
||||
|
||||
submit(payload: AuthenticatorValidationChallengeResponseRequest): Promise<void> {
|
||||
return this.host?.submit(payload) || Promise.resolve();
|
||||
@ -74,7 +91,7 @@ export class AuthenticatorValidateStage
|
||||
|
||||
renderDevicePickerSingle(deviceChallenge: DeviceChallenge): TemplateResult {
|
||||
switch (deviceChallenge.deviceClass) {
|
||||
case DeviceClasses.DUO:
|
||||
case DeviceClassesEnum.Duo:
|
||||
return html`<i class="fas fa-mobile-alt"></i>
|
||||
<div class="right">
|
||||
<p>${t`Duo push-notifications`}</p>
|
||||
@ -82,37 +99,30 @@ export class AuthenticatorValidateStage
|
||||
>${t`Receive a push notification on your phone to prove your identity.`}</small
|
||||
>
|
||||
</div>`;
|
||||
case DeviceClasses.WEBAUTHN:
|
||||
case DeviceClassesEnum.Webauthn:
|
||||
return html`<i class="fas fa-mobile-alt"></i>
|
||||
<div class="right">
|
||||
<p>${t`Authenticator`}</p>
|
||||
<small>${t`Use a security key to prove your identity.`}</small>
|
||||
</div>`;
|
||||
case DeviceClasses.TOTP:
|
||||
// TOTP is a bit special, assuming that TOTP is allowed from the backend,
|
||||
// and we have a pre-filled value from the password manager,
|
||||
// directly set the the TOTP device Challenge as active.
|
||||
if (PasswordManagerPrefill.totp) {
|
||||
console.debug(
|
||||
"authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge",
|
||||
);
|
||||
this.selectedDeviceChallenge = deviceChallenge;
|
||||
// Delay the update as a re-render isn't triggered from here
|
||||
setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, 100);
|
||||
}
|
||||
case DeviceClassesEnum.Totp:
|
||||
return html`<i class="fas fa-clock"></i>
|
||||
<div class="right">
|
||||
<p>${t`Traditional authenticator`}</p>
|
||||
<small>${t`Use a code-based authenticator.`}</small>
|
||||
</div>`;
|
||||
case DeviceClasses.STATIC:
|
||||
case DeviceClassesEnum.Static:
|
||||
return html`<i class="fas fa-key"></i>
|
||||
<div class="right">
|
||||
<p>${t`Recovery keys`}</p>
|
||||
<small>${t`In case you can't access any other method.`}</small>
|
||||
</div>`;
|
||||
case DeviceClassesEnum.Sms:
|
||||
return html`<i class="fas fa-mobile"></i>
|
||||
<div class="right">
|
||||
<p>${t`SMS`}</p>
|
||||
<small>${t`Tokens sent via SMS.`}</small>
|
||||
</div>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -142,8 +152,9 @@ export class AuthenticatorValidateStage
|
||||
return html``;
|
||||
}
|
||||
switch (this.selectedDeviceChallenge?.deviceClass) {
|
||||
case DeviceClasses.STATIC:
|
||||
case DeviceClasses.TOTP:
|
||||
case DeviceClassesEnum.Static:
|
||||
case DeviceClassesEnum.Totp:
|
||||
case DeviceClassesEnum.Sms:
|
||||
return html`<ak-stage-authenticator-validate-code
|
||||
.host=${this}
|
||||
.challenge=${this.challenge}
|
||||
@ -151,7 +162,7 @@ export class AuthenticatorValidateStage
|
||||
.showBackButton=${(this.challenge?.deviceChallenges.length || []) > 1}
|
||||
>
|
||||
</ak-stage-authenticator-validate-code>`;
|
||||
case DeviceClasses.WEBAUTHN:
|
||||
case DeviceClassesEnum.Webauthn:
|
||||
return html`<ak-stage-authenticator-validate-webauthn
|
||||
.host=${this}
|
||||
.challenge=${this.challenge}
|
||||
@ -159,7 +170,7 @@ export class AuthenticatorValidateStage
|
||||
.showBackButton=${(this.challenge?.deviceChallenges.length || []) > 1}
|
||||
>
|
||||
</ak-stage-authenticator-validate-webauthn>`;
|
||||
case DeviceClasses.DUO:
|
||||
case DeviceClassesEnum.Duo:
|
||||
return html`<ak-stage-authenticator-validate-duo
|
||||
.host=${this}
|
||||
.challenge=${this.challenge}
|
||||
@ -179,6 +190,18 @@ export class AuthenticatorValidateStage
|
||||
if (this.challenge?.deviceChallenges.length === 1) {
|
||||
this.selectedDeviceChallenge = this.challenge.deviceChallenges[0];
|
||||
}
|
||||
// TOTP is a bit special, assuming that TOTP is allowed from the backend,
|
||||
// and we have a pre-filled value from the password manager,
|
||||
// directly set the the TOTP device Challenge as active.
|
||||
const totpChallenge = this.challenge.deviceChallenges.find(
|
||||
(challenge) => challenge.deviceClass === DeviceClassesEnum.Totp,
|
||||
);
|
||||
if (PasswordManagerPrefill.totp && totpChallenge) {
|
||||
console.debug(
|
||||
"authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge",
|
||||
);
|
||||
this.selectedDeviceChallenge = totpChallenge;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
${this.selectedDeviceChallenge
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
AuthenticatorValidationChallenge,
|
||||
AuthenticatorValidationChallengeResponseRequest,
|
||||
DeviceChallenge,
|
||||
DeviceClassesEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import "../../../elements/EmptyState";
|
||||
@ -62,6 +63,9 @@ export class AuthenticatorValidateStageWebCode extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
|
||||
? html`<p>${t`A code has been sent to you via SMS.`}</p>`
|
||||
: html``}
|
||||
<ak-form-element
|
||||
label="${t`Code`}"
|
||||
?required="${true}"
|
||||
@ -74,7 +78,7 @@ export class AuthenticatorValidateStageWebCode extends BaseStage<
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="${t`Please enter your TOTP Code`}"
|
||||
placeholder="${t`Please enter your Code`}"
|
||||
autofocus=""
|
||||
autocomplete="one-time-code"
|
||||
class="pf-c-form-control"
|
||||
|
||||
@ -5,6 +5,7 @@ import { ErrorDetail } from "@goauthentik/api";
|
||||
|
||||
export interface StageHost {
|
||||
challenge?: unknown;
|
||||
flowSlug: string;
|
||||
submit(payload: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user