mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
2 Commits
sdko/stage
...
pr-21647
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5050a94f16 | ||
|
|
f5dd1b62ef |
42
web/src/common/storage.ts
Normal file
42
web/src/common/storage.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file Storage utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
|
||||
*/
|
||||
export class StorageAccessor {
|
||||
constructor(
|
||||
public readonly key: string,
|
||||
protected readonly storage: Storage,
|
||||
) {}
|
||||
|
||||
public static local = (key: string) => new StorageAccessor(key, localStorage);
|
||||
public static session = (key: string) => new StorageAccessor(key, sessionStorage);
|
||||
|
||||
public read(): string | null {
|
||||
try {
|
||||
return this.storage.getItem(this.key);
|
||||
} catch (_error: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public write(value: string): boolean {
|
||||
try {
|
||||
this.storage.setItem(this.key, value);
|
||||
return true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public delete(): boolean {
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
return true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isDefaultAvatar } from "#elements/utils/images";
|
||||
|
||||
import Styles from "#flow/FormStatic.css";
|
||||
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import { StageChallengeLike } from "#flow/types";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@@ -69,7 +70,11 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
|
||||
${flowInfo?.cancelUrl
|
||||
? html`
|
||||
<div slot="link">
|
||||
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
|
||||
<a
|
||||
href=${flowInfo.cancelUrl}
|
||||
@click=${RememberMeStorage.reset}
|
||||
>${msg("Not you?")}</a
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { StorageAccessor } from "#common/storage";
|
||||
import { getCookie } from "#common/utils";
|
||||
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { css, html, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
|
||||
export class RememberMeStorage {
|
||||
static readonly username = StorageAccessor.local("authentik-remember-me-user");
|
||||
static readonly session = StorageAccessor.local("authentik-remember-me-session");
|
||||
static reset = () => {
|
||||
this.username.delete();
|
||||
this.session.delete();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's `username` "on this device."
|
||||
*
|
||||
@@ -35,31 +47,32 @@ export class RememberMe implements ReactiveController {
|
||||
`,
|
||||
];
|
||||
|
||||
public username?: string;
|
||||
public username: string | null = null;
|
||||
|
||||
#trackRememberMe = () => {
|
||||
if (!this.#usernameField || this.#usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.#usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
RememberMeStorage.username.write(this.username);
|
||||
};
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
#toggleRememberMe = () => {
|
||||
if (!this.#rememberMeToggle || !this.#rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
RememberMeStorage.reset();
|
||||
this.username = null;
|
||||
this.#usernameField?.removeEventListener("keyup", this.#trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.#usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.#usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
|
||||
RememberMeStorage.username.write(this.#usernameField.value);
|
||||
RememberMeStorage.session.write(this.#localSession);
|
||||
|
||||
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
|
||||
};
|
||||
|
||||
@@ -68,17 +81,14 @@ export class RememberMe implements ReactiveController {
|
||||
// Record a stable token that we can use between requests to track if we've
|
||||
// been here before. If we can't, clear out the username.
|
||||
public hostConnected() {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("authentik-remember-me-session");
|
||||
if (!!this.#localSession && sessionId === this.#localSession) {
|
||||
this.username = undefined;
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
const sessionID = RememberMeStorage.session.read();
|
||||
|
||||
if (this.#localSession && sessionID === this.#localSession) {
|
||||
this.username = null;
|
||||
RememberMeStorage.username.delete();
|
||||
}
|
||||
|
||||
RememberMeStorage.session.write(this.#localSession);
|
||||
}
|
||||
|
||||
get #localSession() {
|
||||
@@ -101,15 +111,15 @@ export class RememberMe implements ReactiveController {
|
||||
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
get #isEnabled() {
|
||||
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
|
||||
get enabled(): boolean {
|
||||
return !!(this.host.challenge?.enableRememberMe && localStorage);
|
||||
}
|
||||
|
||||
get #canAutoSubmit() {
|
||||
return (
|
||||
!!this.host.challenge &&
|
||||
!!this.username &&
|
||||
!!this.#usernameField?.value &&
|
||||
get #canAutoSubmit(): boolean {
|
||||
return !!(
|
||||
this.host.challenge &&
|
||||
this.username &&
|
||||
this.#usernameField?.value &&
|
||||
!this.host.challenge.passwordFields &&
|
||||
!this.host.challenge.passwordlessUrl
|
||||
);
|
||||
@@ -117,38 +127,35 @@ export class RememberMe implements ReactiveController {
|
||||
|
||||
// Before the page is updated, try to extract the username from localstorage.
|
||||
public hostUpdate() {
|
||||
if (!this.#isEnabled) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
this.username = RememberMeStorage.username.read();
|
||||
}
|
||||
|
||||
// After the page is updated, if everything is ready to go, do the autosubmit.
|
||||
public hostUpdated() {
|
||||
if (this.#isEnabled && this.#canAutoSubmit) {
|
||||
if (this.enabled && this.#canAutoSubmit) {
|
||||
this.#submitButton?.click();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.#isEnabled
|
||||
? html` <label class="pf-c-switch remember-me-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
id="authentik-remember-me"
|
||||
@click=${this.#toggleRememberMe}
|
||||
type="checkbox"
|
||||
?checked=${!!this.username}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`
|
||||
: nothing;
|
||||
public render(): SlottedTemplateResult {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`<label class="pf-c-switch remember-me-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
id="authentik-remember-me"
|
||||
@click=${this.#toggleRememberMe}
|
||||
type="checkbox"
|
||||
?checked=${!!this.username}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
168
web/test/browser/remember-me.test.ts
Normal file
168
web/test/browser/remember-me.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import {
|
||||
GOOD_PASSWORD,
|
||||
GOOD_USERNAME,
|
||||
SessionFixture,
|
||||
} from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
|
||||
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
|
||||
|
||||
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
|
||||
|
||||
const REMEMBER_ME_ADMIN_TOGGLE =
|
||||
'ak-stage-identification-form ak-switch-input[name="enableRememberMe"] input[type="checkbox"]';
|
||||
|
||||
const readStoredUsername = (page: Page) =>
|
||||
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
|
||||
|
||||
const readStoredSession = (page: Page) =>
|
||||
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_SESSION_KEY);
|
||||
|
||||
test.describe('Remember me — "Not you?" clears stored identity (regression #21571)', () => {
|
||||
test.beforeAll(
|
||||
'Ensure "Enable Remember me on this device" is on for the default identification stage',
|
||||
async ({ browser }) => {
|
||||
test.setTimeout(120_000);
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(SessionFixture.pathname);
|
||||
await page.getByLabel("Username").fill(GOOD_USERNAME);
|
||||
|
||||
if (!(await page.getByLabel("Password").isVisible())) {
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.getByLabel("Password").waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
await page.getByLabel("Password").fill(GOOD_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.startsWith("/if/flow/"), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.goto("/if/admin/#/flow/stages");
|
||||
|
||||
const $search = page.locator('input[name="search"][type="search"]');
|
||||
await $search.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await $search.fill(IDENTIFICATION_STAGE_NAME);
|
||||
await $search.press("Enter");
|
||||
|
||||
const $row = page.getByRole("row", {
|
||||
name: new RegExp(IDENTIFICATION_STAGE_NAME),
|
||||
});
|
||||
await expect($row, "Identification stage row visible").toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await $row.getByRole("button", { name: "Edit Stage" }).click();
|
||||
|
||||
const $dialog = page.locator("dialog:has(ak-stage-identification-form)");
|
||||
await expect($dialog, "Edit modal opens").toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const $toggle = $dialog.locator(REMEMBER_ME_ADMIN_TOGGLE);
|
||||
await $toggle.waitFor({ state: "attached", timeout: 10_000 });
|
||||
await $toggle.scrollIntoViewIfNeeded();
|
||||
|
||||
const wasChecked = await $toggle.isChecked();
|
||||
|
||||
if (!wasChecked) {
|
||||
await $toggle.check({ force: true });
|
||||
await expect($toggle).toBeChecked();
|
||||
}
|
||||
|
||||
await $dialog.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect($dialog, "Edit modal closes after save").toBeHidden({
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ session, page }) => {
|
||||
await session.toLoginPage();
|
||||
|
||||
await page.evaluate(
|
||||
([userKey, sessionKey]) => {
|
||||
localStorage.removeItem(userKey);
|
||||
localStorage.removeItem(sessionKey);
|
||||
},
|
||||
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
test('Clicking "Not you?" clears the remembered username before navigation', async ({
|
||||
session,
|
||||
page,
|
||||
}) => {
|
||||
const $rememberMe = page.locator("#authentik-remember-me");
|
||||
const $notYouLink = page.getByRole("link", { name: "Not you?" });
|
||||
|
||||
await test.step("Remember me checkbox is offered", async () => {
|
||||
await expect($rememberMe).toBeVisible();
|
||||
await expect($rememberMe).not.toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("Identify with remember-me enabled", async () => {
|
||||
await session.$usernameField.fill(GOOD_USERNAME);
|
||||
await $rememberMe.check();
|
||||
await expect($rememberMe).toBeChecked();
|
||||
|
||||
expect(
|
||||
await readStoredUsername(page),
|
||||
"username persists to localStorage when remember-me is checked",
|
||||
).toBe(GOOD_USERNAME);
|
||||
|
||||
await session.$submitButton.click();
|
||||
await session.$passwordField.waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
await test.step('"Not you?" link renders cleanly (no stray ">" character)', async () => {
|
||||
await expect($notYouLink).toBeVisible();
|
||||
await expect($notYouLink).toHaveText("Not you?");
|
||||
});
|
||||
|
||||
await test.step('Clicking "Not you?" returns to identification with empty fields', async () => {
|
||||
await $notYouLink.click();
|
||||
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
await session.$usernameField.waitFor({ state: "visible" });
|
||||
|
||||
await expect(
|
||||
session.$usernameField,
|
||||
"username field must not be auto-populated from prior session",
|
||||
).toHaveValue("");
|
||||
|
||||
await expect(
|
||||
page.locator("#authentik-remember-me"),
|
||||
"remember-me toggle must be reset",
|
||||
).not.toBeChecked();
|
||||
|
||||
expect(
|
||||
await readStoredUsername(page),
|
||||
"remembered username must be cleared from localStorage",
|
||||
).toBeNull();
|
||||
|
||||
// Session token is rewritten by the controller on hostConnected; assert it does not leak the username.
|
||||
expect(
|
||||
await readStoredSession(page),
|
||||
"session token must not retain remembered identity",
|
||||
).not.toBe(GOOD_USERNAME);
|
||||
});
|
||||
|
||||
await test.step("Identification does not auto-resubmit", async () => {
|
||||
await expect(
|
||||
session.$passwordField,
|
||||
"password field must not appear without explicit submit",
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user