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:
Jens L
2021-10-11 17:51:49 +02:00
committed by GitHub
parent 7bf587af24
commit aef9d27706
48 changed files with 2425 additions and 93 deletions

View File

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

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

View File

@ -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

View File

@ -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"

View File

@ -5,6 +5,7 @@ import { ErrorDetail } from "@goauthentik/api";
export interface StageHost {
challenge?: unknown;
flowSlug: string;
submit(payload: unknown): Promise<void>;
}