stages/authenticator_email: Email OTP (#12630)

* stages/authenticator_email: Add basic structure for stages/authenticator_email

* stages/authenticator_email: Add stages/authenticator_email django app to settings.py

* stages/authenticator_email: Fix imports due changes introduced in #12598

* stages/authenticator_email: fix linting

* stages/authenticator_email: Add tests for token verification

* Add UI structure for authenticator_email

* Add autheticator_email to AuthenticatorValidateStageForm.ts and create AuthenticatorEmailStageForm.ts

* Add serializer property to emaildevice

* Add DeviceClasses.EMAIL to DeviceClasses

* Add migration file for DeviceClasses change (added email)

* Add new schema.yml and blueprints/schema.json to refelct email authenticator

* Fix UI to show the Email Authenticator

* Add support for email templates for the email authenticator

* Add templates

* Add DeviceClasses.EMAIL option to authenticator_validate/stage.py

* Fix logic for sending emails in stage.py and use the proper class AuthenticatorEmailStage in tasks.py

* Fix token expiration display in the email templates

* Fix authenticator email stage set up

* Add template and email to api response for Authenticator Email stage

* Fix  Authenticator Email stage set up form

* Use different flow if the user has an email configured or not for Authenticator Email stage UI

* Use the correct field for the token in AuthenticatorEmailStage.ts

* Fix linting and code style

* Use the correct assertions in tests

* Fix mask email helper

* Add missing cases for Email Authenticator in the UI

* Fix email sending, add _compose_email() method to EmailDevice

* Fix cosmetic changes

* Add support for email device challenge validation in validate_selected_challenge

* Fix tests

* Add from_address to email template

* Refactor tests

* Update API Schema

* Refactor AuthenticatorEmailStage UI for cleaner code

* Fix saving token_expiry in the stage configuration

* Remove debug statements

* Add email connection settings to the Email authenticator stage configuration UI

* Remove unused field activate_on_success from AuthenticatorEmailStage

* Add tests for duplicate email, token expiration and template error

* cosmetic/styling changes

* Use authentik's GroupMemberSerializer and ManagedAppConfig in api and apps for email authenticathor

* stages/authenticator_email: Fix typos, styling and unused fields

* stages/authenticator_email: remove unused field responseStatus

* stages/authenticator_email: regen migrations

* Fix linting issues

* Fix app label issue, typos, missing user field

* Add a trailing space in email_otp.txt RFC 3676 sec. 4.3

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* Move mask_email method to a helper function in authentik.lib.utils.email

* Remove unused function

* Use authentik.stages.email.tasks instead of authentik.stages.authenticator_email.tasks, delete authentik.stages.authenticator_email.tasks

* Fix use global settings not using the global setting if there's a default

* Revert "Fix use global settings not using the global setting if there's a default"

This reverts commit 3825248bb4.

* Use user email from user attributes if exists

* Show masked email in AuthenticatorValidateStageCode

* Remove unused base.html template

* Fix linting issues

* Change token_expiry from integer to TextField, use timedelta_string_validator where necessary to process the change

* Move 'use global connection settings' up in the Email Authenticator Stage Configuration

* Show expanded connections settings when 'use global settings' is not activated for better UX

* Fix migration file, add missing validator

* Fix test for no prefilled email address

* Add tests to check session management, challenge generation and challenge response validation

* fix linting

* Add default value EmailStage for stage_class in stage.email.tasks.send_mail

* Change string representation for EmailDevice to handle authentik/events/tests/test_models.py::TestModels, add tests for the new __str__ method

* Add #nosec to skip false positive in linting validation

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* Change Email Authenticator Setup Stage name for consistency with other authenticators

* Add tests to test properties and methods of EmailDevice and AuthenticatorEmailStage, add test for email tasks

* Add tests for email challenge in authenticator_validate

* Update migration to reflect new verbose name for AuthenticatorEmailStage

* Update schema.yml to reflect new verbose name for AuthenticatorEmailStage

* Add default email subject in Email Authenticator Setup Stage configuration

* Remove from_address from email template to ensure global settings use if use global settings is on

* Add flow-default-authenticator-email-setup.yaml blueprint

* Move email authenticator blueprint to the examples folder

* Update authentik/stages/authenticator_email/models.py

Signed-off-by: Jens L. <jens@beryju.org>

* Change self.user_pk to self.user_id because user_pk doesn't exists here

* Remove unused logger import

* Remove more unused logger import

* Add error handling to authentik.lib.utils.email.mask_email

* fix linting

* don't catch Exception

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update icons

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Signed-off-by: Jens L. <jens@beryju.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Marcelo Elizeche Landó
2025-02-17 11:16:58 -03:00
committed by GitHub
parent a8fd0c376f
commit 4ba360e7af
33 changed files with 3286 additions and 18 deletions

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/rbac/ObjectPermissionModal";
import "@goauthentik/admin/stages/StageWizard";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "@goauthentik/admin/stages/authenticator_duo/DuoDeviceImportForm";
import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm";
import "@goauthentik/admin/stages/authenticator_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm";
import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/common/ak-license-notice";
import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm";
import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";

View File

@ -0,0 +1,283 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/Radio";
import "@goauthentik/elements/forms/SearchSelect";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
AuthenticatorEmailStage,
Flow,
FlowsApi,
FlowsInstancesListDesignationEnum,
FlowsInstancesListRequest,
StagesApi,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-email-form")
export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmailStage> {
async loadInstance(pk: string): Promise<AuthenticatorEmailStage> {
const stage = await new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailRetrieve({
stageUuid: pk,
});
this.showConnectionSettings = !stage.useGlobalSettings;
return stage;
}
@property({ type: Boolean })
showConnectionSettings = false;
async send(data: AuthenticatorEmailStage): Promise<AuthenticatorEmailStage> {
if (this.instance) {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailUpdate({
stageUuid: this.instance.pk || "",
authenticatorEmailStageRequest: data,
});
} else {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailCreate({
authenticatorEmailStageRequest: data,
});
}
}
renderConnectionSettings(): TemplateResult {
if (!this.showConnectionSettings) {
return html``;
}
return html`<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Connection settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("SMTP Host")} ?required=${true} name="host">
<input
type="text"
value="${ifDefined(this.instance?.host || "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("SMTP Port")} ?required=${true} name="port">
<input
type="number"
value="${first(this.instance?.port, 25)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("SMTP Username")} name="username">
<input
type="text"
value="${ifDefined(this.instance?.username || "")}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("SMTP Password")}
?writeOnly=${this.instance !== undefined}
name="password"
>
<input type="text" value="" class="pf-c-form-control" />
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useTls">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useTls, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use TLS")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useSsl">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useSsl, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use SSL")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Timeout")}
?required=${true}
name="timeout"
>
<input
type="number"
value="${first(this.instance?.timeout, 30)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("From address")}
?required=${true}
name="fromAddress"
>
<input
type="text"
value="${ifDefined(this.instance?.fromAddress || "system@authentik.local")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Email address the verification email will be sent from.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
renderForm(): TemplateResult {
return html` <span> ${msg("Stage used to configure an email-based authenticator.")} </span>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${first(this.instance?.name, "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Authenticator type name")}
?required=${false}
name="friendlyName"
>
<input
type="text"
value="${first(this.instance?.friendlyName, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Display name of this authenticator, used by users when they enroll an authenticator.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useGlobalSettings">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useGlobalSettings, true)}
@change=${(ev: Event) => {
const target = ev.target as HTMLInputElement;
this.showConnectionSettings = !target.checked;
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use global connection settings")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, global email connection settings will be used and connection settings below will be ignored.",
)}
</p>
</ak-form-element-horizontal>
${this.renderConnectionSettings()}
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Stage-specific settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Subject")}
?required=${true}
name="subject"
>
<input
type="text"
value="${first(this.instance?.subject, "authentik Sign-in code")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Subject of the verification email.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Token expiration")}
?required=${true}
name="tokenExpiry"
>
<input
type="text"
value="${first(this.instance?.tokenExpiry, "minutes=15")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Configuration flow")}
name="configureFlow"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Flow[]> => {
const args: FlowsInstancesListRequest = {
ordering: "slug",
designation:
FlowsInstancesListDesignationEnum.StageConfiguration,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
args,
);
return flows.results;
}}
.renderElement=${(flow: Flow): string => {
return RenderFlowOption(flow);
}}
.renderDescription=${(flow: Flow): TemplateResult => {
return html`${flow.name}`;
}}
.value=${(flow: Flow | undefined): string | undefined => {
return flow?.pk;
}}
.selected=${(flow: Flow): boolean => {
return this.instance?.configureFlow === flow.pk;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-email-form": AuthenticatorEmailStageForm;
}
}

View File

@ -79,6 +79,7 @@ export class AuthenticatorValidateStageForm extends BaseStageForm<AuthenticatorV
[DeviceClassesEnum.Webauthn, msg("WebAuthn Authenticators")],
[DeviceClassesEnum.Duo, msg("Duo Authenticators")],
[DeviceClassesEnum.Sms, msg("SMS-based Authenticators")],
[DeviceClassesEnum.Email, msg("Email-based Authenticators")],
];
return html`

View File

@ -58,6 +58,8 @@ export class UserDeviceTable extends Table<Device> {
switch (device.type) {
case "authentik_stages_authenticator_duo.DuoDevice":
return api.authenticatorsAdminDuoDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_email.EmailDevice":
return api.authenticatorsAdminEmailDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_sms.SMSDevice":
return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_totp.TOTPDevice":