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 <willem.jiang@gmail.com>
This commit is contained in:
ShaoLOWu 2026-10-25 22:09:58 +08:00 committed by GitHub
parent 79399d0c22
commit 21fb080530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 367 additions and 10 deletions

View File

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

View File

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

View File

@ -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() {
</div>
)}
<form onSubmit={handleSubmit} className="space-y-2">
<form onSubmit={handleSubmit} noValidate className="space-y-2">
<div className="flex flex-col space-y-1">
<label htmlFor="email" className="text-sm font-medium">
{t.login.email}

View File

@ -10,6 +10,7 @@ import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { Input } from "@/components/ui/input";
import { getCsrfHeaders } from "@/core/api/fetcher";
import { useAuth } from "@/core/auth/AuthProvider";
import { isValidEmail } from "@/core/auth/email";
import { loadRememberLoginPreference } from "@/core/auth/remember-login";
import {
fetchSetupStatus,
@ -78,10 +79,22 @@ export default function SetupPage() {
e.preventDefault();
setError("");
// `noValidate` + JS check: the browser's `type="email"` rule would reject
// addresses the backend accepts, e.g. a non-ASCII local part (#7488).
const address = email.trim();
if (!isValidEmail(address)) {
setError("Please enter a valid email address");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (newPassword.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
@ -90,7 +103,7 @@ export default function SetupPage() {
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email,
email: address,
password: newPassword,
remember_me: rememberMe,
}),
@ -120,6 +133,13 @@ export default function SetupPage() {
e.preventDefault();
setError("");
// The address field also carries the account's existing email here, so
// the same permissive check applies (#7488).
const address = email.trim();
if (!isValidEmail(address)) {
setError("Please enter a valid email address");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
@ -129,6 +149,11 @@ export default function SetupPage() {
return;
}
if (!currentPassword) {
setError("Please enter your current password");
return;
}
setLoading(true);
try {
const res = await fetch("/api/v1/auth/change-password", {
@ -141,7 +166,7 @@ export default function SetupPage() {
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
new_email: email || undefined,
new_email: address,
remember_me: rememberMe,
}),
});
@ -226,7 +251,7 @@ export default function SetupPage() {
Set up the administrator account to get started.
</p>
</div>
<form onSubmit={handleInitAdmin} className="space-y-2">
<form onSubmit={handleInitAdmin} noValidate className="space-y-2">
<div className="flex flex-col space-y-1">
<label htmlFor="email" className="text-sm font-medium">
Email
@ -303,7 +328,7 @@ export default function SetupPage() {
Set your real email and a new password.
</p>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
<form onSubmit={handleChangePassword} noValidate className="space-y-4">
<Input
type="email"
placeholder="Your email"

View File

@ -0,0 +1,32 @@
/**
* Permissive email syntax check used by the auth password forms.
* Surrounding whitespace is accepted; callers must submit the trimmed address.
*
* The backend identity schema deliberately accepts addresses that stricter
* validators reject — see `emailSchema` in `@/core/auth/types`, which is a
* bare `z.string().min(1)` because API-created accounts, administrators and
* OIDC identities may carry addresses such as a non-ASCII local part.
*
* The browser's `type="email"` constraint validation, however, rejects any
* non-ASCII local part before the form's submit handler ever runs, so an
* account like `用户@example.com` could never sign in through the password
* form even though the auth API happily issues a session for it (#7488).
*
* The auth forms therefore disable native constraint validation (`noValidate`)
* and apply this check in JS instead. It must stay strictly weaker than what
* the backend accepts: exactly one "@", a non-empty local part and domain,
* and no whitespace. Anything beyond that is left to the backend, which is
* the authority on which addresses can hold an account.
*/
export function isValidEmail(value: string): boolean {
const address = value.trim();
const at = address.indexOf("@");
if (at <= 0 || at !== address.lastIndexOf("@")) {
return false;
}
const domain = address.slice(at + 1);
if (!domain) {
return false;
}
return !/\s/.test(address);
}

View File

@ -2565,6 +2565,10 @@ export const enUS: Translations = {
"SSO login is not allowed for your account. Contact your administrator.",
account_disabled:
"This account has been disabled. Contact your administrator.",
invalid_email: "Please enter a valid email address.",
password_too_short: "Password must be at least 6 characters.",
registration_password_too_short:
"Password must be at least 8 characters.",
},
},
};

View File

@ -2055,6 +2055,9 @@ export interface Translations {
sso_account_exists: string;
sso_not_allowed: string;
account_disabled: string;
invalid_email: string;
password_too_short: string;
registration_password_too_short: string;
};
};
}

View File

@ -2406,6 +2406,9 @@ export const zhCN: Translations = {
"该邮箱对应的账号已存在。请使用密码登录或联系管理员。",
sso_not_allowed: "你的账号不允许使用 SSO 登录。请联系管理员。",
account_disabled: "账号已停用,请联系管理员。",
invalid_email: "请输入有效的邮箱地址。",
password_too_short: "密码至少需要 6 个字符。",
registration_password_too_short: "密码至少需要 8 个字符。",
},
},
};

View File

@ -0,0 +1,229 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { createContext } from "react";
import LoginPage from "@/app/(auth)/login/page";
import SetupPage from "@/app/(auth)/setup/page";
import { enUS } from "@/core/i18n/locales/en-US";
const router = { push: rs.fn(), replace: rs.fn() };
type Mode = "login" | "register" | "initialize" | "change-password";
const modes: Mode[] = ["login", "register", "initialize", "change-password"];
let mode: Mode = "login";
rs.mock("next/navigation", () => ({
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<typeof fetch>();
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" ? <LoginPage /> : <SetupPage />,
);
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();
},
);

View File

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