From 21fb080530155dd775ce9ec8972e0f30a86648e0 Mon Sep 17 00:00:00 2001 From: ShaoLOWu <3034261766@qq.com> Date: Sun, 25 Oct 2026 22:09:58 +0800 Subject: [PATCH] fix(frontend): allow non-ASCII local parts in auth password forms (#7488) (#7498) * fix(frontend): allow non-ASCII local parts in auth password forms (#7488) The sign-in, register, admin-setup and change-password forms relied on the browser's type="email" constraint validation, which rejects any non-ASCII local part before the submit handler runs. The backend identity schema deliberately accepts such addresses (see emailSchema in @/core/auth/types), so an account created via the API, an administrator or OIDC could never sign in through the password form. Forms are now noValidate, and the permissive isValidEmail helper (one @, non-empty local part and domain, no whitespace) runs in the handlers instead, staying strictly weaker than what the backend accepts. * fix(auth): normalize emails and restore form password checks --------- Co-authored-by: Willem Jiang --- README.md | 5 + frontend/AGENTS.md | 2 + frontend/src/app/(auth)/login/page.tsx | 30 ++- frontend/src/app/(auth)/setup/page.tsx | 33 ++- frontend/src/core/auth/email.ts | 32 +++ frontend/src/core/i18n/locales/en-US.ts | 4 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 3 + .../auth/password-forms.dom.test.tsx | 229 ++++++++++++++++++ frontend/tests/unit/core/auth/email.test.ts | 36 +++ 10 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 frontend/src/core/auth/email.ts create mode 100644 frontend/tests/unit/components/auth/password-forms.dom.test.tsx create mode 100644 frontend/tests/unit/core/auth/email.test.ts diff --git a/README.md b/README.md index e03379b9e3..6234c78971 100644 --- a/README.md +++ b/README.md @@ -633,6 +633,11 @@ The bundled Docker, local and Helm nginx configs proxy Live Browser WebSocket up Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address. +Password forms accept non-ASCII email addresses and trim surrounding email whitespace. +Sign-in requires at least six password characters; creating an account or choosing +a new password requires eight. These checks run before sending a request. +Passwords, including surrounding spaces, are submitted unchanged. + OIDC/SSO authorization endpoints may include provider query parameters such as `?prompt=login`. DeerFlow preserves those parameters when adding the login request, including for configured `authorization_endpoint` overrides. Administrators can suspend and restore accounts from **Settings → Users** (admin-only; the section is hidden for ordinary users). The page lists each account's email, role, and status and exposes the same enable/disable operation as `PATCH /api/v1/admin/users/{id}`; the last remaining active admin cannot be disabled, and the Gateway's rejection detail is shown verbatim. A disabled account is rejected by every authentication surface, and its live sessions end on the next request with a login page that states the account has been disabled. Password login for a disabled account deliberately still answers "incorrect email or password" so the state is not disclosed to whoever holds the credentials. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6589ef9a8d..bc440ec4a2 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -113,6 +113,8 @@ Confirmed logout unmounts query consumers immediately; public and unknown-auth routes still render with an independent client. Keep auth outside the query provider. Superseded auth refreshes cannot restore an earlier owner. +Auth: trim emails; validate raw passwords. + See [project structure](README.md#project-structure) for the directory map and [src/AGENTS.md](src/AGENTS.md) for component and domain ownership. diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index ef956e2d5c..d6d857ea1e 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { Input } from "@/components/ui/input"; import { AuthDisabledContext, useAuth } from "@/core/auth/AuthProvider"; +import { isValidEmail } from "@/core/auth/email"; import { resolveAuthNextPath } from "@/core/auth/next-path"; import { loadRememberLoginPreference, @@ -151,14 +152,31 @@ export default function LoginPage() { e.preventDefault(); setError(""); setShowSsoHint(false); - setLoading(true); if (!isLogin && !regularSignupAllowed) { setError(t.login.adminSetupRequiredDescription); - setLoading(false); return; } + // The form is `noValidate`, so the browser no longer rejects addresses + // its `type="email"` rule dislikes (e.g. a non-ASCII local part, which + // the backend accepts — #7488). Apply the permissive JS check instead. + const address = email.trim(); + if (!isValidEmail(address)) { + setError(t.login.errors.invalid_email); + return; + } + + if (password.length < (isLogin ? 6 : 8)) { + setError( + isLogin + ? t.login.errors.password_too_short + : t.login.errors.registration_password_too_short, + ); + return; + } + + setLoading(true); try { const endpoint = isLogin ? "/api/v1/auth/login/local" @@ -167,9 +185,9 @@ export default function LoginPage() { ? new URLSearchParams({ password, remember_me: String(rememberMe), - username: email, + username: address, }) - : JSON.stringify({ email, password, remember_me: rememberMe }); + : JSON.stringify({ email: address, password, remember_me: rememberMe }); const headers: HeadersInit = isLogin ? { "Content-Type": "application/x-www-form-urlencoded" } @@ -202,7 +220,7 @@ export default function LoginPage() { return; } - saveRememberLoginPreference({ email, rememberMe }); + saveRememberLoginPreference({ email: address, rememberMe }); // Both login and register set a cookie — redirect to workspace router.push(redirectPath); @@ -276,7 +294,7 @@ export default function LoginPage() { )} -
+
- +
- + ({ + useRouter: () => router, + useSearchParams: () => new URLSearchParams(), +})); +rs.mock("next-themes", () => ({ + useTheme: () => ({ theme: "light", resolvedTheme: "light" }), +})); +rs.mock("@/components/ui/flickering-grid", () => ({ + FlickeringGrid: () => null, +})); +rs.mock("@/core/auth/AuthProvider", () => ({ + AuthDisabledContext: createContext(false), + useAuth: () => ({ + isAuthenticated: mode === "change-password", + user: mode === "change-password" ? { needs_setup: true } : null, + }), +})); +rs.mock("@/core/api/fetcher", () => ({ getCsrfHeaders: () => ({}) })); +rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) })); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); +const fetchMock = rs.fn(); +const posts = () => + fetchMock.mock.calls.filter(([, init]) => init?.method === "POST"); + +beforeEach(() => { + mode = "login"; + localStorage.clear(); + router.push.mockReset(); + router.replace.mockReset(); + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + if (input === "/api/v1/auth/setup-status") { + return jsonResponse({ + needs_setup: mode === "initialize", + registration_enabled: true, + }); + } + if (input === "/api/v1/auth/providers") + return jsonResponse({ providers: [] }); + if (init?.method === "POST") return jsonResponse({}); + throw new Error("Unexpected auth request"); + }); + rs.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + rs.unstubAllGlobals(); +}); + +async function mountForm( + nextMode: Mode, + email: string, + password = " secret9 ", +) { + mode = nextMode; + if (mode === "login") { + localStorage.setItem("deerflow.auth.remembered_email", email); + } + render( + mode === "login" || mode === "register" ? : , + ); + if (mode === "register") { + fireEvent.click( + await screen.findByRole("button", { name: enUS.login.noAccountSignUp }), + ); + } + const addressInput = + mode === "change-password" + ? await screen.findByPlaceholderText("Your email") + : await screen.findByLabelText("Email"); + // Preserve a remembered raw value in React state; type=email sanitizes + // typed outer spaces before onChange and would hide the submission bug. + if (mode !== "login") + fireEvent.change(addressInput, { target: { value: email } }); + const passwordInput = + mode === "change-password" + ? screen.getByPlaceholderText("New password") + : screen.getByLabelText("Password"); + fireEvent.change(passwordInput, { target: { value: password } }); + if (mode === "initialize" || mode === "change-password") { + fireEvent.change( + mode === "initialize" + ? screen.getByLabelText("Confirm Password") + : screen.getByPlaceholderText("Confirm new password"), + { target: { value: password } }, + ); + } + if (mode === "change-password") { + fireEvent.change(screen.getByPlaceholderText("Current password"), { + target: { value: "current-password" }, + }); + } + const form = addressInput.closest("form")!; + expect(form.noValidate).toBe(true); + return form; +} + +test.each(modes)( + "%s submits the same trimmed Unicode address it validates", + async (formMode) => { + const password = " safe "; + const form = await mountForm(formMode, " 用户@example.com ", password); + await act(async () => { + fireEvent.submit(form); + }); + await waitFor(() => expect(router.push).toHaveBeenCalledWith("/workspace")); + expect(posts()).toHaveLength(1); + const [endpoint, request] = posts()[0]!; + if (formMode === "login") { + expect(endpoint).toBe("/api/v1/auth/login/local"); + expect(request!.body).toBeInstanceOf(URLSearchParams); + const body = request!.body as URLSearchParams; + expect(body.get("username")).toBe("用户@example.com"); + expect(body.get("password")).toBe(password); + expect(localStorage.getItem("deerflow.auth.remembered_email")).toBe( + "用户@example.com", + ); + } else { + expect(typeof request!.body).toBe("string"); + const body = JSON.parse(request!.body as string) as Record< + string, + unknown + >; + expect(endpoint).toBe( + `/api/v1/auth/${formMode === "register" ? "register" : formMode === "initialize" ? "initialize" : "change-password"}`, + ); + expect(body[formMode === "change-password" ? "new_email" : "email"]).toBe( + "用户@example.com", + ); + expect( + body[formMode === "change-password" ? "new_password" : "password"], + ).toBe(password); + } + }, +); + +const invalidPasswords: [Mode, string][] = modes.flatMap((formMode) => [ + [formMode, ""], + [formMode, formMode === "login" ? "12345" : "1234567"], +]); +test.each(invalidPasswords)( + "%s blocks password %j before making a request", + async (formMode, password) => { + const form = await mountForm(formMode, "用户@example.com", password); + await act(async () => { + fireEvent.submit(form); + }); + expect(posts()).toHaveLength(0); + expect(screen.getByText(/Password must be at least/)).toBeTruthy(); + expect(router.push).not.toHaveBeenCalled(); + }, +); + +test.each(modes)( + "%s rejects malformed email before making a request", + async (formMode) => { + const form = await mountForm(formMode, "not-an-email"); + await act(async () => { + fireEvent.submit(form); + }); + expect(posts()).toHaveLength(0); + expect(screen.getByText(/Please enter a valid email address/)).toBeTruthy(); + }, +); + +test("login keeps supporting a six-character password", async () => { + const form = await mountForm("login", "用户@example.com", "secret"); + await act(async () => { + fireEvent.submit(form); + }); + await waitFor(() => expect(router.push).toHaveBeenCalledWith("/workspace")); + expect(posts()).toHaveLength(1); +}); + +test("password change requires the current password before making a request", async () => { + const form = await mountForm("change-password", "用户@example.com"); + fireEvent.change(screen.getByPlaceholderText("Current password"), { + target: { value: "" }, + }); + await act(async () => { + fireEvent.submit(form); + }); + expect(posts()).toHaveLength(0); + expect(screen.getByText("Please enter your current password")).toBeTruthy(); +}); + +test.each(["initialize", "change-password"] as const)( + "%s still rejects a password confirmation mismatch", + async (formMode) => { + const form = await mountForm(formMode, "用户@example.com"); + fireEvent.change( + formMode === "initialize" + ? screen.getByLabelText("Confirm Password") + : screen.getByPlaceholderText("Confirm new password"), + { target: { value: "different-password" } }, + ); + await act(async () => { + fireEvent.submit(form); + }); + expect(posts()).toHaveLength(0); + expect(screen.getByText("Passwords do not match")).toBeTruthy(); + }, +); diff --git a/frontend/tests/unit/core/auth/email.test.ts b/frontend/tests/unit/core/auth/email.test.ts new file mode 100644 index 0000000000..74f6a4b348 --- /dev/null +++ b/frontend/tests/unit/core/auth/email.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "@rstest/core"; + +import { isValidEmail } from "@/core/auth/email"; + +// The backend identity schema deliberately accepts addresses stricter +// validators reject (`emailSchema` in @/core/auth/types is a bare +// `z.string().min(1)`). The auth forms used to hand the decision to the +// browser's `type="email"` constraint instead, which rejects any non-ASCII +// local part before the submit handler runs — an account like +// `用户@example.com` (creatable via the API, an admin, or OIDC) could never +// sign in through the password form (#7488). +describe("auth email validation", () => { + test("accepts addresses the backend accepts, including non-ASCII local parts", () => { + expect(isValidEmail("用户@example.com")).toBe(true); + expect(isValidEmail("wíérd@name.example")).toBe(true); + expect(isValidEmail("someone@example.com")).toBe(true); + expect(isValidEmail("a@b")).toBe(true); + }); + + test("accepts surrounding whitespace (trimmed before the check)", () => { + expect(isValidEmail(" user@example.com ")).toBe(true); + }); + + test("rejects values without a usable local@domain shape", () => { + expect(isValidEmail("not-an-email")).toBe(false); + expect(isValidEmail("@example.com")).toBe(false); + expect(isValidEmail("user@")).toBe(false); + expect(isValidEmail("a@b@c.example")).toBe(false); + expect(isValidEmail("a b@example.com")).toBe(false); + }); + + test("rejects empty and blank input", () => { + expect(isValidEmail("")).toBe(false); + expect(isValidEmail(" ")).toBe(false); + }); +});