diff --git a/authentik/flows/tests/test_inspector.py b/authentik/flows/tests/test_inspector.py index 59ca809066..2a01ea370c 100644 --- a/authentik/flows/tests/test_inspector.py +++ b/authentik/flows/tests/test_inspector.py @@ -45,6 +45,7 @@ class TestFlowInspector(APITestCase): self.assertJSONEqual( res.content, { + "allow_show_password": False, "component": "ak-stage-identification", "flow_info": { "background": flow.background_url, diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py index 323b93ebf1..27cfcb92f1 100644 --- a/authentik/stages/identification/models.py +++ b/authentik/stages/identification/models.py @@ -38,10 +38,11 @@ class IdentificationStage(Stage): help_text=_( ( "When set, shows a password field, instead of showing the " - "password field as seaprate step." + "password field as separate step." ), ), ) + case_insensitive_matching = models.BooleanField( default=True, help_text=_("When enabled, user fields are matched regardless of their casing."), diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 129cd14b9a..8c5ecbcd3c 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -64,6 +64,7 @@ class IdentificationChallenge(Challenge): user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True) password_fields = BooleanField() + allow_show_password = BooleanField(default=False) application_pre = CharField(required=False) flow_designation = ChoiceField(FlowDesignation.choices) @@ -197,6 +198,8 @@ class IdentificationStageView(ChallengeStageView): "primary_action": self.get_primary_action(), "user_fields": current_stage.user_fields, "password_fields": bool(current_stage.password_stage), + "allow_show_password": bool(current_stage.password_stage) + and current_stage.password_stage.allow_show_password, "show_source_labels": current_stage.show_source_labels, "flow_designation": self.executor.flow.designation, } diff --git a/authentik/stages/password/api.py b/authentik/stages/password/api.py index 7ede2ade8f..d56dd2b557 100644 --- a/authentik/stages/password/api.py +++ b/authentik/stages/password/api.py @@ -16,6 +16,7 @@ class PasswordStageSerializer(StageSerializer): "backends", "configure_flow", "failed_attempts_before_cancel", + "allow_show_password", ] @@ -28,6 +29,7 @@ class PasswordStageViewSet(UsedByMixin, ModelViewSet): "name", "configure_flow", "failed_attempts_before_cancel", + "allow_show_password", ] search_fields = ["name"] ordering = ["name"] diff --git a/authentik/stages/password/migrations/0009_passwordstage_allow_show_password.py b/authentik/stages/password/migrations/0009_passwordstage_allow_show_password.py new file mode 100644 index 0000000000..643edd2b72 --- /dev/null +++ b/authentik/stages/password/migrations/0009_passwordstage_allow_show_password.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-07-02 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_password", "0008_replace_inbuilt"), + ] + + operations = [ + migrations.AddField( + model_name="passwordstage", + name="allow_show_password", + field=models.BooleanField( + default=False, + help_text="When enabled, provides a 'show password' button with the password input field.", + ), + ), + ] diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py index 9887b68543..a88318b039 100644 --- a/authentik/stages/password/models.py +++ b/authentik/stages/password/models.py @@ -43,6 +43,12 @@ class PasswordStage(ConfigurableStage, Stage): "To lock the user out, use a reputation policy and a user_write stage." ), ) + allow_show_password = models.BooleanField( + default=False, + help_text=_( + "When enabled, provides a 'show password' button with the password input field." + ), + ) @property def serializer(self) -> type[BaseSerializer]: diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index 03a6040ded..e8c0bde3e2 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse from django.urls import reverse from django.utils.translation import gettext as _ from rest_framework.exceptions import ValidationError -from rest_framework.fields import CharField +from rest_framework.fields import BooleanField, CharField from sentry_sdk.hub import Hub from structlog.stdlib import get_logger @@ -76,6 +76,8 @@ class PasswordChallenge(WithUserInfoChallenge): component = CharField(default="ak-stage-password") + allow_show_password = BooleanField(default=False) + class PasswordChallengeResponse(ChallengeResponse): """Password challenge response""" @@ -134,7 +136,11 @@ class PasswordStageView(ChallengeStageView): response_class = PasswordChallengeResponse def get_challenge(self) -> Challenge: - challenge = PasswordChallenge(data={}) + challenge = PasswordChallenge( + data={ + "allow_show_password": self.executor.current_stage.allow_show_password, + } + ) recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY) if recovery_flow.exists(): recover_url = reverse( diff --git a/blueprints/schema.json b/blueprints/schema.json index ab61d8ca93..a98aa64a0f 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6905,7 +6905,7 @@ "password_stage": { "type": "integer", "title": "Password stage", - "description": "When set, shows a password field, instead of showing the password field as seaprate step." + "description": "When set, shows a password field, instead of showing the password field as separate step." }, "case_insensitive_matching": { "type": "boolean", @@ -7207,6 +7207,11 @@ "maximum": 2147483647, "title": "Failed attempts before cancel", "description": "How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage." + }, + "allow_show_password": { + "type": "boolean", + "title": "Allow show password", + "description": "When enabled, provides a 'show password' button with the password input field." } }, "required": [] diff --git a/schema.yml b/schema.yml index f459e3453e..35b30701c1 100644 --- a/schema.yml +++ b/schema.yml @@ -29993,6 +29993,10 @@ paths: operationId: stages_password_list description: PasswordStage Viewset parameters: + - in: query + name: allow_show_password + schema: + type: boolean - in: query name: configure_flow schema: @@ -37067,6 +37071,9 @@ components: nullable: true password_fields: type: boolean + allow_show_password: + type: boolean + default: false application_pre: type: string flow_designation: @@ -37149,7 +37156,7 @@ components: format: uuid nullable: true description: When set, shows a password field, instead of showing the password - field as seaprate step. + field as separate step. case_insensitive_matching: type: boolean description: When enabled, user fields are matched regardless of their casing. @@ -37217,7 +37224,7 @@ components: format: uuid nullable: true description: When set, shows a password field, instead of showing the password - field as seaprate step. + field as separate step. case_insensitive_matching: type: boolean description: When enabled, user fields are matched regardless of their casing. @@ -40953,6 +40960,9 @@ components: type: string recovery_url: type: string + allow_show_password: + type: boolean + default: false required: - pending_user - pending_user_avatar @@ -41235,6 +41245,10 @@ components: minimum: -2147483648 description: How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage. + allow_show_password: + type: boolean + description: When enabled, provides a 'show password' button with the password + input field. required: - backends - component @@ -41271,6 +41285,10 @@ components: minimum: -2147483648 description: How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage. + allow_show_password: + type: boolean + description: When enabled, provides a 'show password' button with the password + input field. required: - backends - name @@ -42092,7 +42110,7 @@ components: format: uuid nullable: true description: When set, shows a password field, instead of showing the password - field as seaprate step. + field as separate step. case_insensitive_matching: type: boolean description: When enabled, user fields are matched regardless of their casing. @@ -42804,6 +42822,10 @@ components: minimum: -2147483648 description: How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage. + allow_show_password: + type: boolean + description: When enabled, provides a 'show password' button with the password + input field. PatchedPermissionAssignRequest: type: object description: Request to assign a new permission diff --git a/web/src/admin/stages/password/PasswordStageForm.ts b/web/src/admin/stages/password/PasswordStageForm.ts index 339542c973..81c861bdf0 100644 --- a/web/src/admin/stages/password/PasswordStageForm.ts +++ b/web/src/admin/stages/password/PasswordStageForm.ts @@ -1,7 +1,7 @@ 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/components/ak-switch-input.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -9,7 +9,6 @@ import "@goauthentik/elements/forms/SearchSelect"; import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; import { BackendsEnum, @@ -72,10 +71,10 @@ export class PasswordStageForm extends BaseStageForm { return html` ${msg("Validate the user's password against the selected backend(s).")} - + @@ -158,7 +157,7 @@ export class PasswordStageForm extends BaseStageForm { > @@ -168,6 +167,12 @@ export class PasswordStageForm extends BaseStageForm { )}

+ `; } diff --git a/web/src/flow/components/ak-flow-password-input.ts b/web/src/flow/components/ak-flow-password-input.ts new file mode 100644 index 0000000000..59c31a0699 --- /dev/null +++ b/web/src/flow/components/ak-flow-password-input.ts @@ -0,0 +1,181 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import "@goauthentik/elements/forms/FormElement"; + +import { msg } from "@lit/localize"; +import { html, nothing, render } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +@customElement("ak-flow-input-password") +export class InputPassword extends AKElement { + static get styles() { + return [PFBase, PFInputGroup, PFFormControl, PFButton]; + } + + @property({ type: String, attribute: "input-id" }) + inputId = "ak-stage-password-input"; + + @property({ type: String }) + name = "password"; + + @property({ type: String }) + label = msg("Password"); + + @property({ type: String }) + placeholder = msg("Please enter your password"); + + @property({ type: String, attribute: "prefill" }) + passwordPrefill = ""; + + @property({ type: Object }) + errors: Record = {}; + + /** + * Forwarded to the input tag's aria-invalid attribute, if set + * @attr + */ + @property({ type: String }) + invalid?: string; + + @property({ type: Boolean, attribute: "allow-show-password" }) + allowShowPassword = false; + + /** + * Automatically grab focus after rendering. + * @attr + */ + @property({ type: Boolean, attribute: "grab-focus" }) + grabFocus = false; + + timer?: number; + + input?: HTMLInputElement; + + cleanup(): void { + if (this.timer) { + console.debug("authentik/stages/password: cleared focus timer"); + window.clearInterval(this.timer); + this.timer = undefined; + } + } + + // Must support both older browsers and shadyDom; we'll keep using this in-line, but it'll still + // be in the scope of the parent element, not an independent shadowDOM. + createRenderRoot() { + return this; + } + + // State is saved in the DOM, and read from the DOM. Directly affects the DOM, + // so no `.requestUpdate()` required. Effect is immediately visible. + togglePasswordVisibility(ev: PointerEvent) { + const passwordField = this.renderRoot.querySelector(`#${this.inputId}`) as HTMLInputElement; + ev.stopPropagation(); + ev.preventDefault(); + + if (!passwordField) { + throw new Error("ak-flow-password-input: unable to identify input field"); + } + + passwordField.type = passwordField.type === "password" ? "text" : "password"; + this.renderPasswordVisibilityFeatures(passwordField); + } + + // In the unlikely event that we want to make "show password" the _default_ behavior, this + // effect handler is broken out into its own method. The current behavior in the main + // `.render()` method assumes the field is of type "password." To have this effect, er, take + // effect, call it in an `.updated()` method. + renderPasswordVisibilityFeatures(passwordField: HTMLInputElement) { + const toggleId = `#${this.inputId}-visibility-toggle`; + const visibilityToggle = this.renderRoot.querySelector(toggleId) as HTMLButtonElement; + if (!visibilityToggle) { + return; + } + const show = passwordField.type === "password"; + visibilityToggle?.setAttribute( + "aria-label", + show ? msg("Show password") : msg("Hide password"), + ); + visibilityToggle?.querySelector("i")?.remove(); + render( + show + ? html`` + : html``, + visibilityToggle, + ); + } + + renderInput(): HTMLInputElement { + this.input = document.createElement("input"); + this.input.id = `${this.inputId}`; + this.input.type = "password"; + this.input.name = this.name; + this.input.placeholder = this.placeholder; + this.input.autofocus = true; + this.input.autocomplete = "current-password"; + this.input.classList.add("pf-c-form-control"); + this.input.required = true; + this.input.value = this.passwordPrefill ?? ""; + if (this.invalid) { + this.input.setAttribute("aria-invalid", this.invalid); + } + // This is somewhat of a crude way to get autofocus, but in most cases the `autofocus` attribute + // isn't enough, due to timing within shadow doms and such. + + if (this.grabFocus) { + this.timer = window.setInterval(() => { + if (!this.input) { + return; + } + // Because activeElement behaves differently with shadow dom + // we need to recursively check + const rootEl = document.activeElement; + const isActive = (el: Element | null): boolean => { + if (!rootEl) return false; + if (!("shadowRoot" in rootEl)) return false; + if (rootEl.shadowRoot === null) return false; + if (rootEl.shadowRoot.activeElement === el) return true; + return isActive(rootEl.shadowRoot.activeElement); + }; + if (isActive(this.input)) { + this.cleanup(); + } + this.input.focus(); + }, 10); + console.debug("authentik/stages/password: started focus timer"); + } + return this.input; + } + + render() { + return html` +
+ ${this.renderInput()} + ${this.allowShowPassword + ? html` ` + : nothing} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-flow-input-password": InputPassword; + } +} diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 9d1035bb9d..b60b1d9fb0 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -2,6 +2,7 @@ import { renderSourceIcon } from "@goauthentik/admin/sources/utils"; import "@goauthentik/elements/Divider"; import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/forms/FormElement"; +import "@goauthentik/flow/components/ak-flow-password-input.js"; import { BaseStage } from "@goauthentik/flow/stages/base"; import { msg, str } from "@lit/localize"; @@ -12,6 +13,7 @@ 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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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"; @@ -45,22 +47,32 @@ export class IdentificationStage extends BaseStage< form?: HTMLFormElement; static get styles(): CSSResult[] { - return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton].concat(css` + return [ + PFBase, + PFAlert, + PFInputGroup, + PFLogin, + PFForm, + PFFormControl, + PFTitle, + PFButton, /* login page's icons */ - .pf-c-login__main-footer-links-item button { - background-color: transparent; - border: 0; - display: flex; - align-items: stretch; - } - .pf-c-login__main-footer-links-item img { - fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill); - width: 100px; - max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width); - height: 100%; - max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height); - } - `); + css` + .pf-c-login__main-footer-links-item button { + background-color: transparent; + border: 0; + display: flex; + align-items: stretch; + } + .pf-c-login__main-footer-links-item img { + fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill); + width: 100px; + max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width); + height: 100%; + max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height); + } + `, + ]; } updated(changedProperties: PropertyValues) { @@ -250,22 +262,16 @@ export class IdentificationStage extends BaseStage< ${this.challenge.passwordFields ? html` - - - + .errors=${(this.challenge?.responseErrors || {})["password"]} + ?allow-show-password=${this.challenge.allowShowPassword} + prefill=${PasswordManagerPrefill["password"] ?? ""} + > ` : nothing} ${"non_field_errors" in (this.challenge?.responseErrors || {}) diff --git a/web/src/flow/stages/password/PasswordStage.ts b/web/src/flow/stages/password/PasswordStage.ts index 7bf22bacf1..0787d2782d 100644 --- a/web/src/flow/stages/password/PasswordStage.ts +++ b/web/src/flow/stages/password/PasswordStage.ts @@ -1,6 +1,7 @@ import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/flow/FormStatic"; +import "@goauthentik/flow/components/ak-flow-password-input.js"; import { BaseStage } from "@goauthentik/flow/stages/base"; import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage"; @@ -12,6 +13,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; 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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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"; @@ -21,62 +23,14 @@ import { PasswordChallenge, PasswordChallengeResponseRequest } from "@goauthenti @customElement("ak-stage-password") export class PasswordStage extends BaseStage { static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFForm, PFFormControl, PFButton, PFTitle]; + return [PFBase, PFLogin, PFInputGroup, PFForm, PFFormControl, PFButton, PFTitle]; } - input?: HTMLInputElement; - - timer?: number; - hasError(field: string): boolean { const errors = (this.challenge?.responseErrors || {})[field]; return (errors || []).length > 0; } - renderInput(): HTMLInputElement { - this.input = document.createElement("input"); - this.input.type = "password"; - this.input.name = "password"; - this.input.placeholder = msg("Please enter your password"); - this.input.autofocus = true; - this.input.autocomplete = "current-password"; - this.input.classList.add("pf-c-form-control"); - this.input.required = true; - this.input.value = PasswordManagerPrefill.password || ""; - this.input.setAttribute("aria-invalid", this.hasError("password").toString()); - // This is somewhat of a crude way to get autofocus, but in most cases the `autofocus` attribute - // isn't enough, due to timing within shadow doms and such. - this.timer = window.setInterval(() => { - if (!this.input) { - return; - } - // Because activeElement behaves differently with shadow dom - // we need to recursively check - const rootEl = document.activeElement; - const isActive = (el: Element | null): boolean => { - if (!rootEl) return false; - if (!("shadowRoot" in rootEl)) return false; - if (rootEl.shadowRoot === null) return false; - if (rootEl.shadowRoot.activeElement === el) return true; - return isActive(rootEl.shadowRoot.activeElement); - }; - if (isActive(this.input)) { - this.cleanup(); - } - this.input.focus(); - }, 10); - console.debug("authentik/stages/password: started focus timer"); - return this.input; - } - - cleanup(): void { - if (this.timer) { - console.debug("authentik/stages/password: cleared focus timer"); - window.clearInterval(this.timer); - this.timer = undefined; - } - } - render(): TemplateResult { if (!this.challenge) { return html` @@ -109,14 +63,16 @@ export class PasswordStage extends BaseStage - - ${this.renderInput()} - + ?allow-show-password=${this.challenge.allowShowPassword} + invalid=${this.hasError("password").toString()} + prefill=${PasswordManagerPrefill["password"] ?? ""} + > ${this.challenge.recoveryUrl ? html`