From ab2944f7fad7e449ab69259323aadc5f4097773a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 27 Jul 2026 17:02:16 +0800 Subject: [PATCH] feat(auth): add staging email password flows --- BLOCKED.md | 3 + .../src/app/api/account/password/route.ts | 97 +++++ frontend/src/app/globals.css | 10 +- frontend/src/app/login/page.tsx | 6 +- frontend/src/components/email-otp-login.tsx | 352 +++++++++++++++++- frontend/src/modules/identity/auth-factory.ts | 11 + frontend/src/modules/identity/client.ts | 109 +++++- frontend/tests/identity-auth-factory.test.ts | 8 + .../tests/identity-auth-integration.test.ts | 333 +++++++++++++---- .../tests/identity-login-provider.test.ts | 160 ++++++-- progress.md | 15 + 11 files changed, 982 insertions(+), 122 deletions(-) create mode 100644 BLOCKED.md create mode 100644 frontend/src/app/api/account/password/route.ts diff --git a/BLOCKED.md b/BLOCKED.md new file mode 100644 index 00000000..dd472a6a --- /dev/null +++ b/BLOCKED.md @@ -0,0 +1,3 @@ +# BLOCKED + +- 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。 diff --git a/frontend/src/app/api/account/password/route.ts b/frontend/src/app/api/account/password/route.ts new file mode 100644 index 00000000..6e8febf8 --- /dev/null +++ b/frontend/src/app/api/account/password/route.ts @@ -0,0 +1,97 @@ +import { getIdentityAuthServices } from "@/modules/identity/auth"; +import { + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; + +export const dynamic = "force-dynamic"; + +async function userSession(request: Request) { + if (!isSelfHostedIdentityEnabled(process.env)) return null; + const config = readSelfHostedIdentityConfig(process.env); + if (resolveIdentitySurface(request.headers.get("host"), config) !== "user") { + return null; + } + const services = getIdentityAuthServices(); + const session = await services.user.api.getSession({ headers: request.headers }); + return session ? { services, session } : null; +} + +async function hasCredentialPassword( + services: ReturnType, + userId: string, +): Promise { + const result = await services.pool.query( + ` + select 1 + from identity.accounts + where user_id = $1 + and provider_id = 'credential' + and password is not null + limit 1 + `, + [userId], + ); + return result.rowCount === 1; +} + +export async function GET(request: Request): Promise { + const context = await userSession(request); + if (!context) { + return Response.json({ error: "请先登录" }, { status: 401 }); + } + return Response.json({ + hasPassword: await hasCredentialPassword( + context.services, + context.session.user.id, + ), + }); +} + +export async function POST(request: Request): Promise { + const context = await userSession(request); + if (!context) { + return Response.json({ error: "请先登录" }, { status: 401 }); + } + + let body: { newPassword?: unknown }; + try { + body = (await request.json()) as { newPassword?: unknown }; + } catch { + return Response.json({ error: "请求格式错误" }, { status: 400 }); + } + if ( + typeof body.newPassword !== "string" || + body.newPassword.length < 8 || + body.newPassword.length > 128 + ) { + return Response.json({ error: "密码长度须为 8–128 位" }, { status: 400 }); + } + if ( + await hasCredentialPassword(context.services, context.session.user.id) + ) { + return Response.json( + { error: "此账户已设置密码,原密码未被更改" }, + { status: 409 }, + ); + } + + try { + await context.services.user.api.setPassword({ + headers: request.headers, + body: { newPassword: body.newPassword }, + }); + return Response.json({ ok: true }); + } catch { + if ( + await hasCredentialPassword(context.services, context.session.user.id) + ) { + return Response.json( + { error: "此账户已设置密码,原密码未被更改" }, + { status: 409 }, + ); + } + return Response.json({ error: "暂时无法设置密码" }, { status: 500 }); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ba3808cb..4e7bb863 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -213,7 +213,7 @@ button:disabled { cursor: default; opacity: .45; } .auth-brand strong { font-weight: 600; } .auth-step { transition: opacity 180ms ease-out, transform 180ms var(--ease-out); } @starting-style { .auth-step { opacity: 0; transform: translateY(6px); } } -.inline-actions { display: flex; justify-content: space-between; gap: 10px; } +.inline-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; } .section-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; } .code-form { display: grid; grid-template-columns: 120px 120px 200px minmax(180px, 1fr) auto; align-items: end; gap: 12px; margin-top: 18px; } .code-form label { display: grid; gap: 7px; } @@ -722,7 +722,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-story > div > p:not(.auth-kicker) { max-width: 480px; margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; } .auth-kicker { margin: 0; color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; text-transform: uppercase; } .auth-footnote { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } -.auth-panel { width: 100%; display: flex; flex-direction: column; justify-content: center; padding: var(--space-12); border: 0; border-radius: 0; background: var(--color-canvas); } +.auth-panel { width: 100%; min-width: 0; display: flex; flex-direction: column; justify-content: center; padding: var(--space-12); border: 0; border-radius: 0; background: var(--color-canvas); } .auth-brand { align-items: center; gap: 10px; font-size: 17px; display: none; margin-bottom: var(--space-10); } .auth-panel h1 { font-size: var(--type-display-md); } .page-intro { line-height: 1.6; margin: var(--space-3) 0 var(--space-8); color: var(--color-ink-secondary); font-size: var(--type-body-md); } @@ -731,6 +731,12 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .stack-form .button-primary { margin-top: var(--space-2); } .otp-input { text-align: center; font-size: 20px; font-variant-numeric: tabular-nums; letter-spacing: .35em; font-family: var(--font-mono); } .inline-actions button { min-height: 44px; padding: 0; border: 0; background: transparent; cursor: pointer; color: var(--color-action); font-size: 13px; } +.auth-mode-nav { display: grid; gap: var(--space-3); margin: calc(var(--space-4) * -1) 0 var(--space-6); } +.auth-mode-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.auth-mode-tabs button, .auth-links button { min-width: 0; min-height: 40px; border: 0; background: transparent; cursor: pointer; color: var(--color-ink-secondary); font: inherit; font-size: 13px; } +.auth-mode-tabs button[aria-pressed="true"] { border-radius: calc(var(--radius-md) - 2px); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-soft); } +.auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } +.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); } .admin-page { background: var(--color-canvas-soft); } .admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 6cc08837..1566eb00 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -13,6 +13,7 @@ export const dynamic = "force-dynamic"; export default async function LoginPage() { const config = readIdentityConfig(process.env); let provider = config.provider; + let passwordEnabled = false; if (isSelfHostedIdentityEnabled(process.env)) { const selfHosted = readSelfHostedIdentityConfig(process.env); const surface = resolveIdentitySurface( @@ -20,6 +21,9 @@ export default async function LoginPage() { selfHosted, ); if (surface === "admin") provider = "self-hosted"; + passwordEnabled = provider === "self-hosted" && surface === "user"; } - return ; + return ( + + ); } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index a2688746..72e1a45f 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -4,9 +4,11 @@ import Image from "next/image"; import { FormEvent, useState } from "react"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; -import { selfHostedOtpActions } from "@/modules/identity/client"; +import { selfHostedAuthActions } from "@/modules/identity/client"; type AuthProvider = "supabase" | "self-hosted"; +type AuthMode = "otp" | "password" | "register" | "forgot"; +type AuthStep = "email" | "otp" | "set-password" | "existing"; function authMessage(caught: unknown) { const message = caught instanceof Error ? caught.message : "暂时无法登录"; @@ -23,14 +25,43 @@ function authMessage(caught: unknown) { return message; } -export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { +function passwordError(password: string, confirmation: string): string { + if (password.length < 8 || password.length > 128) { + return "密码长度须为 8–128 位"; + } + if (password !== confirmation) return "两次输入的密码不一致"; + return ""; +} + +export function EmailOtpLogin({ + provider, + passwordEnabled = false, +}: { + provider: AuthProvider; + passwordEnabled?: boolean; +}) { + const [mode, setMode] = useState("otp"); + const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); const [token, setToken] = useState(""); - const [sent, setSent] = useState(false); + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); + const canUsePassword = provider === "self-hosted" && passwordEnabled; + + function chooseMode(nextMode: AuthMode, nextNotice = "") { + setMode(nextMode); + setStep("email"); + setToken(""); + setPassword(""); + setConfirmation(""); + setError(""); + setNotice(nextNotice); + } + async function sendOtp(event?: FormEvent) { event?.preventDefault(); const normalizedEmail = email.trim(); @@ -40,7 +71,11 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { setNotice(""); try { if (provider === "self-hosted") { - await selfHostedOtpActions.send(normalizedEmail); + if (mode === "forgot") { + await selfHostedAuthActions.requestPasswordReset(normalizedEmail); + } else { + await selfHostedAuthActions.send(normalizedEmail); + } } else { const { error: otpError } = await createBrowserSupabaseClient().auth.signInWithOtp({ @@ -49,8 +84,12 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { }); if (otpError) throw otpError; } - setSent(true); - setNotice(`验证码已发送至 ${normalizedEmail}`); + setStep("otp"); + setNotice( + mode === "forgot" + ? "如果该邮箱可用,验证码已发送,请检查收件箱" + : `验证码已发送至 ${normalizedEmail}`, + ); } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); @@ -66,7 +105,18 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { setError(""); try { if (provider === "self-hosted") { - await selfHostedOtpActions.verify(email, token); + await selfHostedAuthActions.verify(email, token); + const hasPassword = await selfHostedAuthActions.hasPassword(); + if (!hasPassword) { + setStep("set-password"); + setNotice("邮箱验证成功,请设置登录密码"); + return; + } + if (mode === "register") { + setStep("existing"); + setNotice("此邮箱已有账户,您已登录;原密码未被更改"); + return; + } } else { const { error: otpError } = await createBrowserSupabaseClient().auth.verifyOtp({ @@ -80,17 +130,98 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); + } finally { + setBusy(false); + } + } + + async function signInWithPassword(event: FormEvent) { + event.preventDefault(); + if (!email.trim() || !password || busy) return; + setBusy(true); + setError(""); + setNotice(""); + try { + await selfHostedAuthActions.signInWithPassword(email, password); + window.location.assign("/"); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + setBusy(false); + } + } + + async function saveFirstPassword(event: FormEvent) { + event.preventDefault(); + const validationError = passwordError(password, confirmation); + if (validationError) { + setError(validationError); + return; + } + if (busy) return; + setBusy(true); + setError(""); + try { + await selfHostedAuthActions.setPassword(password); + window.location.assign("/"); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + const message = authMessage(caught); + if (message.includes("原密码未被更改")) { + setStep("existing"); + setNotice(message); + } else { + setError(message); + } + setBusy(false); + } + } + + async function resetPassword(event: FormEvent) { + event.preventDefault(); + const validationError = passwordError(password, confirmation); + if (validationError) { + setError(validationError); + return; + } + if (!token || busy) return; + setBusy(true); + setError(""); + try { + await selfHostedAuthActions.resetPassword(email, token, password); + chooseMode("password", "密码已重置,请使用新密码登录"); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + } finally { setBusy(false); } } function changeEmail() { - setSent(false); + setStep("email"); setToken(""); + setPassword(""); + setConfirmation(""); setError(""); setNotice(""); } + const title = + mode === "register" + ? "注册账号" + : mode === "forgot" + ? "忘记密码" + : "欢迎回来"; + const intro = + mode === "password" + ? "使用邮箱和密码登录。" + : mode === "register" + ? "验证邮箱后设置密码,并自动登录。" + : mode === "forgot" + ? "通过邮箱验证码设置新密码,旧会话将失效。" + : "邮箱验证码登录,新邮箱将自动创建账户。"; + return (
@@ -123,13 +254,82 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) {
-

欢迎回来

-

- 邮箱验证码登录, - 新邮箱将自动创建账户。 -

+

{title}

+

{intro}

- {!sent ? ( + {canUsePassword && ( + + )} + + {mode === "password" && step === "email" ? ( +
+ + { + setEmail(event.target.value); + setError(""); + }} + placeholder="you@example.com" + /> + + { + setPassword(event.target.value); + setError(""); + }} + /> + +
+ ) : step === "email" ? (
- ) : ( + ) : step === "otp" && mode === "forgot" ? ( +
+ + { + setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); + setError(""); + }} + /> + + { + setPassword(event.target.value); + setError(""); + }} + /> + + { + setConfirmation(event.target.value); + setError(""); + }} + /> + +
+ + +
+
+ ) : step === "otp" ? (
+ ) : step === "set-password" ? ( +
+ + { + setPassword(event.target.value); + setError(""); + }} + /> + + { + setConfirmation(event.target.value); + setError(""); + }} + /> + +
+ ) : ( +
+ +
)} + {error && (

{error} diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index dbd9f8ec..63dac181 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -100,6 +100,17 @@ export function buildAuthOptions({ path: "/", }, }, + ...(surface === "user" + ? { + emailAndPassword: { + enabled: true, + disableSignUp: true, + minPasswordLength: 8, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }, + } + : {}), plugins: [ emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")), admin({ diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts index 15ec58b1..75bd492a 100644 --- a/frontend/src/modules/identity/client.ts +++ b/frontend/src/modules/identity/client.ts @@ -1,40 +1,62 @@ import { createAuthClient } from "better-auth/react"; import { emailOTPClient } from "better-auth/client/plugins"; -interface OtpClientResult { +interface AuthClientResult { data: unknown; error: unknown; } -export interface SelfHostedOtpClient { +export interface SelfHostedAuthClient { emailOtp: { sendVerificationOtp(input: { email: string; type: "sign-in"; - }): Promise; + }): Promise; + requestPasswordReset?(input: { email: string }): Promise; + resetPassword?(input: { + email: string; + otp: string; + password: string; + }): Promise; }; signIn: { emailOtp(input: { email: string; otp: string; - }): Promise; + }): Promise; + email?(input: { + email: string; + password: string; + }): Promise; }; - signOut?(): Promise; + signOut?(): Promise; } -export interface SelfHostedOtpActions { +export interface SelfHostedAuthActions { send(email: string): Promise; verify(email: string, otp: string): Promise; + signInWithPassword(email: string, password: string): Promise; + requestPasswordReset(email: string): Promise; + resetPassword(email: string, otp: string, password: string): Promise; + hasPassword(): Promise; + setPassword(password: string): Promise; signOut(): Promise; } -export function createSelfHostedOtpActions( - client: SelfHostedOtpClient, -): SelfHostedOtpActions { +type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function createSelfHostedAuthActions( + client: SelfHostedAuthClient, + fetcher: Fetcher = fetch, +): SelfHostedAuthActions { return { async send(email) { const result = await client.emailOtp.sendVerificationOtp({ - email: email.trim().toLowerCase(), + email: normalizeEmail(email), type: "sign-in", }); if (result.error) { @@ -43,13 +65,68 @@ export function createSelfHostedOtpActions( }, async verify(email, otp) { const result = await client.signIn.emailOtp({ - email: email.trim().toLowerCase(), + email: normalizeEmail(email), otp, }); if (result.error) { throw new Error("验证码错误或已过期,请重新获取"); } }, + async signInWithPassword(email, password) { + if (!client.signIn.email) throw new Error("邮箱或密码错误"); + const result = await client.signIn.email({ + email: normalizeEmail(email), + password, + }); + if (result.error) throw new Error("邮箱或密码错误"); + }, + async requestPasswordReset(email) { + if (!client.emailOtp.requestPasswordReset) { + throw new Error("暂时无法发送验证码,请稍后再试"); + } + const result = await client.emailOtp.requestPasswordReset({ + email: normalizeEmail(email), + }); + if (result.error) { + throw new Error("暂时无法发送验证码,请稍后再试"); + } + }, + async resetPassword(email, otp, password) { + if (!client.emailOtp.resetPassword) { + throw new Error("验证码错误或已过期,请重新获取"); + } + const result = await client.emailOtp.resetPassword({ + email: normalizeEmail(email), + otp, + password, + }); + if (result.error) { + throw new Error("验证码错误或已过期,请重新获取"); + } + }, + async hasPassword() { + const response = await fetcher("/api/account/password", { + credentials: "same-origin", + }); + if (!response.ok) throw new Error("暂时无法确认密码状态,请稍后再试"); + const body = (await response.json()) as { hasPassword?: unknown }; + if (typeof body.hasPassword !== "boolean") { + throw new Error("暂时无法确认密码状态,请稍后再试"); + } + return body.hasPassword; + }, + async setPassword(password) { + const response = await fetcher("/api/account/password", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPassword: password }), + }); + if (response.status === 409) { + throw new Error("此账户已设置密码,原密码未被更改"); + } + if (!response.ok) throw new Error("暂时无法设置密码,请稍后再试"); + }, async signOut() { if (!client.signOut) throw new Error("退出失败,请稍后再试"); const result = await client.signOut(); @@ -58,8 +135,14 @@ export function createSelfHostedOtpActions( }; } +export type SelfHostedOtpClient = SelfHostedAuthClient; +export type SelfHostedOtpActions = SelfHostedAuthActions; +export const createSelfHostedOtpActions = createSelfHostedAuthActions; + const authClient = createAuthClient({ plugins: [emailOTPClient()] }); -export const selfHostedOtpActions = createSelfHostedOtpActions( - authClient as SelfHostedOtpClient, +export const selfHostedAuthActions = createSelfHostedAuthActions( + authClient as unknown as SelfHostedAuthClient, ); + +export const selfHostedOtpActions = selfHostedAuthActions; diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts index 5e4bf342..5012c4b9 100644 --- a/frontend/tests/identity-auth-factory.test.ts +++ b/frontend/tests/identity-auth-factory.test.ts @@ -107,6 +107,14 @@ test("user and admin auth surfaces have host-only isolated cookies", () => { assert.equal(adminOptions.secret, config.adminSecret); assert.equal(userOptions.advanced?.cookiePrefix, "jyotisha-user"); assert.equal(adminOptions.advanced?.cookiePrefix, "jyotisha-admin"); + assert.deepEqual(userOptions.emailAndPassword, { + enabled: true, + disableSignUp: true, + minPasswordLength: 8, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }); + assert.equal(adminOptions.emailAndPassword, undefined); for (const options of [userOptions, adminOptions]) { const attributes = options.advanced?.defaultCookieAttributes; assert.equal(attributes?.secure, true); diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index 1021f569..f0bd4380 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -11,6 +11,10 @@ import { import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; import { createHostIsolatedAuthHandlers } from "../src/modules/identity/host.ts"; +import { + GET as getPasswordStatus, + POST as setAccountPassword, +} from "../src/app/api/account/password/route.ts"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath( @@ -19,24 +23,45 @@ const runnerPath = fileURLToPath( const migrationsDirectory = fileURLToPath( new URL("../db/migrations", import.meta.url), ); +const userHost = "staging.jyotisha.chat"; +const adminHost = "admin.staging.jyotisha.chat"; function request( host: string, path: string, - body: Record, + body?: Record, + cookie?: string, ): Request { + const headers: Record = { + host, + origin: `https://${host}`, + }; + if (body) headers["content-type"] = "application/json"; + if (cookie) headers.cookie = cookie; return new Request(`https://${host}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - host, - origin: `https://${host}`, - }, - body: JSON.stringify(body), + method: body ? "POST" : "GET", + headers, + body: body ? JSON.stringify(body) : undefined, }); } -test("Better Auth completes OTP sign-in against the migrated identity schema with isolated cookies", async () => { +function sessionCookie(response: Response): string { + return (response.headers.get("set-cookie") ?? "").split(";", 1)[0]; +} + +const envKeys = [ + "AUTH_PROVIDER", + "SELF_HOSTED_IDENTITY_ENABLED", + "IDENTITY_DATABASE_URL", + "AUTH_USER_ORIGIN", + "AUTH_ADMIN_ORIGIN", + "BETTER_AUTH_USER_SECRET", + "BETTER_AUTH_ADMIN_SECRET", + "RESEND_API_KEY", + "RESEND_FROM_EMAIL", +] as const; + +test("Better Auth supports OTP registration/login, first password, password login, and OTP reset while admin stays OTP-only", async () => { const fixture = startPostgresFixture(); const migration = spawnSync(process.execPath, [runnerPath], { encoding: "utf8", @@ -57,13 +82,33 @@ test("Better Auth completes OTP sign-in against the migrated identity schema wit "identity_runtime", "identity-runtime-test-password", ), - userOrigin: "https://staging.jyotisha.chat", - adminOrigin: "https://admin.staging.jyotisha.chat", + userOrigin: `https://${userHost}`, + adminOrigin: `https://${adminHost}`, userSecret: "user-secret-that-is-at-least-32-bytes-long", adminSecret: "admin-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha ", }; + const previousEnv = new Map( + envKeys.map((key) => [key, process.env[key]] as const), + ); + Object.assign(process.env, { + AUTH_PROVIDER: "self-hosted", + SELF_HOSTED_IDENTITY_ENABLED: "true", + IDENTITY_DATABASE_URL: config.databaseUrl, + AUTH_USER_ORIGIN: config.userOrigin, + AUTH_ADMIN_ORIGIN: config.adminOrigin, + BETTER_AUTH_USER_SECRET: config.userSecret, + BETTER_AUTH_ADMIN_SECRET: config.adminSecret, + RESEND_API_KEY: config.resendApiKey, + RESEND_FROM_EMAIL: config.resendFrom, + }); + + const identityGlobal = globalThis as typeof globalThis & { + jyotishaIdentityAuth?: ReturnType; + }; + delete identityGlobal.jyotishaIdentityAuth; + const sender = new FakeEmailOtpSender(); const pool = createIdentityPool(config.databaseUrl); const services = createIdentityAuthServices(config, { @@ -75,81 +120,239 @@ test("Better Auth completes OTP sign-in against the migrated identity schema wit admin: toNextJsHandler(services.admin), }); - try { - const userSend = await handlers.POST( - request( - "staging.jyotisha.chat", - "/api/auth/email-otp/send-verification-otp", - { email: "person@example.com", type: "sign-in" }, - ), - ); - assert.equal(userSend.status, 200, await userSend.text()); - assert.equal(sender.messages.length, 1); - - const userSignIn = await handlers.POST( - request("staging.jyotisha.chat", "/api/auth/sign-in/email-otp", { - email: "person@example.com", - otp: sender.messages[0].otp, + async function otpSignIn(email: string): Promise { + const send = await handlers.POST( + request(userHost, "/api/auth/email-otp/send-verification-otp", { + email, + type: "sign-in", }), ); - const userCookie = userSignIn.headers.get("set-cookie") ?? ""; - assert.equal(userSignIn.status, 200, await userSignIn.text()); - assert.match(userCookie, /jyotisha-user\.session_token=/); - assert.doesNotMatch(userCookie, /jyotisha-admin/); - assert.match(userCookie, /HttpOnly/i); - assert.match(userCookie, /Secure/i); - assert.match(userCookie, /SameSite=Lax/i); - assert.doesNotMatch(userCookie, /Domain=/i); - assert.equal(fixture.psql("select count(*) from identity.users"), "1"); - assert.equal(fixture.psql("select count(*) from identity.sessions"), "1"); + assert.equal(send.status, 200); + const message = sender.messages.at(-1); + assert.equal(message?.email, email); + assert.equal(message?.type, "sign-in"); - const adminSend = await handlers.POST( + const signIn = await handlers.POST( + request(userHost, "/api/auth/sign-in/email-otp", { + email, + otp: message?.otp, + }), + ); + assert.equal(signIn.status, 200); + const cookie = sessionCookie(signIn); + assert.match(cookie, /^(?:__Secure-)?jyotisha-user\.session_token=/); + return cookie; + } + + async function passwordSignIn( + email: string, + password: string, + ): Promise { + return handlers.POST( + request(userHost, "/api/auth/sign-in/email", { email, password }), + ); + } + + try { + const unauthenticatedSet = await setAccountPassword( + request(userHost, "/api/account/password", { + newPassword: "not-authorized", + }), + ); + assert.equal(unauthenticatedSet.status, 401); + + const newEmail = "new-user@example.com"; + const firstPassword = "first-password"; + const resetPassword = "reset-password"; + const newUserOtpCookie = await otpSignIn(newEmail); + + const initialStatus = await getPasswordStatus( + request(userHost, "/api/account/password", undefined, newUserOtpCookie), + ); + assert.equal(initialStatus.status, 200); + assert.deepEqual(await initialStatus.json(), { hasPassword: false }); + + const firstSet = await setAccountPassword( request( - "admin.staging.jyotisha.chat", - "/api/auth/email-otp/send-verification-otp", - { email: "person@example.com", type: "sign-in" }, + userHost, + "/api/account/password", + { newPassword: firstPassword }, + newUserOtpCookie, ), ); - assert.equal(adminSend.status, 200, await adminSend.text()); - const deniedAdminSignIn = await handlers.POST( + assert.equal(firstSet.status, 200); + + const secondSet = await setAccountPassword( request( - "admin.staging.jyotisha.chat", - "/api/auth/sign-in/email-otp", - { email: "person@example.com", otp: sender.messages[1].otp }, + userHost, + "/api/account/password", + { newPassword: "must-not-overwrite" }, + newUserOtpCookie, ), ); - assert.equal(deniedAdminSignIn.status, 403); - assert.equal(deniedAdminSignIn.headers.has("set-cookie"), false); - assert.equal(fixture.psql("select count(*) from identity.sessions"), "1"); + assert.equal(secondSet.status, 409); + + const storedHash = fixture.psql( + "select password from identity.accounts where provider_id = 'credential' and user_id = (select id from identity.users where email = 'new-user@example.com')", + ); + assert.notEqual(storedHash, firstPassword); + assert.match(storedHash, /^[0-9a-f]{32}:[0-9a-f]{128}$/); + + const passwordLogin = await passwordSignIn(newEmail, firstPassword); + assert.equal(passwordLogin.status, 200); + const passwordCookie = sessionCookie(passwordLogin); + assert.match(passwordCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/); + + const wrongPassword = await passwordSignIn(newEmail, "wrong-password"); + assert.notEqual(wrongPassword.status, 200); + assert.equal(wrongPassword.headers.has("set-cookie"), false); + + const otpLoginCookie = await otpSignIn(newEmail); + assert.match(otpLoginCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/); + + const oldOtpEmail = "otp-only@example.com"; + const firstOldOtpCookie = await otpSignIn(oldOtpEmail); + const signOut = await handlers.POST( + request( + userHost, + "/api/auth/sign-out", + {}, + firstOldOtpCookie, + ), + ); + assert.equal(signOut.status, 200); + const returningOldOtpCookie = await otpSignIn(oldOtpEmail); + const oldOtpStatus = await getPasswordStatus( + request( + userHost, + "/api/account/password", + undefined, + returningOldOtpCookie, + ), + ); + assert.deepEqual(await oldOtpStatus.json(), { hasPassword: false }); + const oldOtpSet = await setAccountPassword( + request( + userHost, + "/api/account/password", + { newPassword: "old-user-password" }, + returningOldOtpCookie, + ), + ); + assert.equal(oldOtpSet.status, 200); + assert.equal( + (await passwordSignIn(oldOtpEmail, "old-user-password")).status, + 200, + ); + + const unknownResetMessageCount = sender.messages.length; + const unknownReset = await handlers.POST( + request(userHost, "/api/auth/email-otp/request-password-reset", { + email: "missing@example.com", + }), + ); + assert.equal(unknownReset.status, 200); + assert.equal(sender.messages.length, unknownResetMessageCount); + + const resetRequest = await handlers.POST( + request(userHost, "/api/auth/email-otp/request-password-reset", { + email: newEmail, + }), + ); + assert.equal(resetRequest.status, 200); + const resetMessage = sender.messages.at(-1); + assert.equal(resetMessage?.type, "forget-password"); + + const reset = await handlers.POST( + request(userHost, "/api/auth/email-otp/reset-password", { + email: newEmail, + otp: resetMessage?.otp, + password: resetPassword, + }), + ); + assert.equal(reset.status, 200); + + const newUserId = fixture.psql( + "select id from identity.users where email = 'new-user@example.com'", + ); + assert.equal( + fixture.psql( + `select count(*) from identity.sessions where user_id = '${newUserId}'`, + ), + "0", + ); + for (const cookie of [newUserOtpCookie, passwordCookie, otpLoginCookie]) { + assert.equal( + await services.user.api.getSession({ + headers: new Headers({ cookie }), + }), + null, + ); + } + + const oldPasswordAfterReset = await passwordSignIn(newEmail, firstPassword); + assert.notEqual(oldPasswordAfterReset.status, 200); + assert.equal(oldPasswordAfterReset.headers.has("set-cookie"), false); + const newPasswordAfterReset = await passwordSignIn(newEmail, resetPassword); + assert.equal(newPasswordAfterReset.status, 200); + assert.match( + sessionCookie(newPasswordAfterReset), + /^(?:__Secure-)?jyotisha-user\.session_token=/, + ); fixture.psqlAs( "identity_runtime", "identity-runtime-test-password", - "update identity.users set role = 'user,admin' where email = 'person@example.com'", + "update identity.users set role = 'user,admin' where email = 'new-user@example.com'", ); - const promotedSend = await handlers.POST( - request( - "admin.staging.jyotisha.chat", - "/api/auth/email-otp/send-verification-otp", - { email: "person@example.com", type: "sign-in" }, - ), + const adminPasswordLogin = await handlers.POST( + request(adminHost, "/api/auth/sign-in/email", { + email: newEmail, + password: resetPassword, + }), ); - assert.equal(promotedSend.status, 200, await promotedSend.text()); + assert.notEqual(adminPasswordLogin.status, 200); + assert.equal(adminPasswordLogin.headers.has("set-cookie"), false); + + const adminSend = await handlers.POST( + request(adminHost, "/api/auth/email-otp/send-verification-otp", { + email: newEmail, + type: "sign-in", + }), + ); + assert.equal(adminSend.status, 200); + const adminMessage = sender.messages.at(-1); + assert.equal(adminMessage?.type, "sign-in"); const adminSignIn = await handlers.POST( + request(adminHost, "/api/auth/sign-in/email-otp", { + email: newEmail, + otp: adminMessage?.otp, + }), + ); + assert.equal(adminSignIn.status, 200); + assert.match(sessionCookie(adminSignIn), /^(?:__Secure-)?jyotisha-admin\.session_token=/); + + const adminPasswordRoute = await setAccountPassword( request( - "admin.staging.jyotisha.chat", - "/api/auth/sign-in/email-otp", - { email: "person@example.com", otp: sender.messages[2].otp }, + adminHost, + "/api/account/password", + { newPassword: "admin-must-not-set-password" }, + sessionCookie(adminSignIn), ), ); - const adminCookie = adminSignIn.headers.get("set-cookie") ?? ""; - assert.equal(adminSignIn.status, 200, await adminSignIn.text()); - assert.match(adminCookie, /jyotisha-admin\.session_token=/); - assert.doesNotMatch(adminCookie, /jyotisha-user/); - assert.doesNotMatch(adminCookie, /Domain=/i); - assert.equal(fixture.psql("select count(*) from identity.sessions"), "2"); + assert.equal(adminPasswordRoute.status, 401); } finally { + const globalServices = identityGlobal.jyotishaIdentityAuth; + if (globalServices) { + await globalServices.pool.end(); + delete identityGlobal.jyotishaIdentityAuth; + } await pool.end(); fixture.stop(); + for (const key of envKeys) { + const value = previousEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } }); diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts index b1236641..7c2a89a0 100644 --- a/frontend/tests/identity-login-provider.test.ts +++ b/frontend/tests/identity-login-provider.test.ts @@ -2,9 +2,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { createSelfHostedOtpActions } from "../src/modules/identity/client.ts"; +import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts"; -test("login page selects the auth provider from server-only validated config", () => { +test("login page selects the auth provider and limits passwords to the user surface", () => { const page = readFileSync( new URL("../src/app/login/page.tsx", import.meta.url), "utf8", @@ -16,29 +16,67 @@ test("login page selects the auth provider from server-only validated config", ( assert.match(page, /isSelfHostedIdentityEnabled\(process\.env\)/); assert.match(page, /resolveIdentitySurface/); assert.match(page, /surface === "admin"/); - assert.match(page, /provider=\{provider\}/); + assert.match( + page, + /passwordEnabled = provider === "self-hosted" && surface === "user"/, + ); + assert.match(page, /passwordEnabled=\{passwordEnabled\}/); assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/); }); -test("self-hosted OTP actions call Better Auth without browser token storage", async () => { - const calls: Array<{ operation: string; input: Record }> = []; - const actions = createSelfHostedOtpActions({ - emailOtp: { - async sendVerificationOtp(input) { - calls.push({ operation: "send", input }); - return { data: { success: true }, error: null }; +test("self-hosted auth actions call Better Auth without browser token storage", async () => { + const calls: Array<{ operation: string; input?: Record }> = []; + const fetchCalls: Array<{ path: string; method: string; body: string }> = []; + const actions = createSelfHostedAuthActions( + { + emailOtp: { + async sendVerificationOtp(input) { + calls.push({ operation: "send", input }); + return { data: { success: true }, error: null }; + }, + async requestPasswordReset(input) { + calls.push({ operation: "request-reset", input }); + return { data: { success: true }, error: null }; + }, + async resetPassword(input) { + calls.push({ operation: "reset", input }); + return { data: { success: true }, error: null }; + }, + }, + signIn: { + async emailOtp(input) { + calls.push({ operation: "verify", input }); + return { data: { user: { id: "user-id" } }, error: null }; + }, + async email(input) { + calls.push({ operation: "password", input }); + return { data: { user: { id: "user-id" } }, error: null }; + }, }, }, - signIn: { - async emailOtp(input) { - calls.push({ operation: "verify", input }); - return { data: { user: { id: "user-id" } }, error: null }; - }, + async (input, init) => { + fetchCalls.push({ + path: String(input), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : "", + }); + return Response.json( + init?.method === "POST" ? { ok: true } : { hasPassword: false }, + ); }, - }); + ); await actions.send(" Person@Example.com "); await actions.verify(" Person@Example.com ", "123456"); + await actions.signInWithPassword(" Person@Example.com ", "password-1"); + await actions.requestPasswordReset(" Person@Example.com "); + await actions.resetPassword( + " Person@Example.com ", + "654321", + "password-2", + ); + assert.equal(await actions.hasPassword(), false); + await actions.setPassword("password-3"); assert.deepEqual(calls, [ { @@ -49,7 +87,32 @@ test("self-hosted OTP actions call Better Auth without browser token storage", a operation: "verify", input: { email: "person@example.com", otp: "123456" }, }, + { + operation: "password", + input: { email: "person@example.com", password: "password-1" }, + }, + { + operation: "request-reset", + input: { email: "person@example.com" }, + }, + { + operation: "reset", + input: { + email: "person@example.com", + otp: "654321", + password: "password-2", + }, + }, ]); + assert.deepEqual(fetchCalls, [ + { path: "/api/account/password", method: "GET", body: "" }, + { + path: "/api/account/password", + method: "POST", + body: JSON.stringify({ newPassword: "password-3" }), + }, + ]); + const clientSource = readFileSync( new URL("../src/modules/identity/client.ts", import.meta.url), "utf8", @@ -57,22 +120,26 @@ test("self-hosted OTP actions call Better Auth without browser token storage", a assert.doesNotMatch(clientSource, /localStorage|sessionStorage/); }); -test("self-hosted OTP actions expose generic enumeration-safe errors", async () => { - const actions = createSelfHostedOtpActions({ +test("self-hosted auth actions expose generic enumeration-safe errors", async () => { + const failed = { data: null, error: { message: "internal account detail" } }; + const actions = createSelfHostedAuthActions({ emailOtp: { async sendVerificationOtp() { - return { - data: null, - error: { message: "database says account does not exist" }, - }; + return failed; + }, + async requestPasswordReset() { + return failed; + }, + async resetPassword() { + return failed; }, }, signIn: { async emailOtp() { - return { - data: null, - error: { message: "internal OTP hash 123456 mismatch" }, - }; + return failed; + }, + async email() { + return failed; }, }, }); @@ -85,4 +152,45 @@ test("self-hosted OTP actions expose generic enumeration-safe errors", async () actions.verify("missing@example.com", "123456"), new Error("验证码错误或已过期,请重新获取"), ); + await assert.rejects( + actions.signInWithPassword("missing@example.com", "password"), + new Error("邮箱或密码错误"), + ); + await assert.rejects( + actions.requestPasswordReset("missing@example.com"), + new Error("暂时无法发送验证码,请稍后再试"), + ); + await assert.rejects( + actions.resetPassword("missing@example.com", "123456", "password"), + new Error("验证码错误或已过期,请重新获取"), + ); +}); + +test("login UI preserves accessible OTP, password, registration, and reset inputs", () => { + const component = readFileSync( + new URL("../src/components/email-otp-login.tsx", import.meta.url), + "utf8", + ); + for (const label of ["验证码登录", "密码登录", "注册账号", "忘记密码"]) { + assert.match(component, new RegExp(label)); + } + for (const autocomplete of [ + "email", + "current-password", + "new-password", + "one-time-code", + ]) { + assert.match(component, new RegExp(`autoComplete="${autocomplete}"`)); + } + assert.match(component, /role="alert"/); + assert.match(component, /role="status"/); + + const route = readFileSync( + new URL("../src/app/api/account/password/route.ts", import.meta.url), + "utf8", + ); + assert.match(route, /services\.user\.api\.getSession/); + assert.match(route, /services\.user\.api\.setPassword/); + assert.match(route, /provider_id = 'credential'/); + assert.doesNotMatch(route, /update\s+identity\.accounts/i); }); diff --git a/progress.md b/progress.md index 0cfe0cfb..4f178a99 100644 --- a/progress.md +++ b/progress.md @@ -972,3 +972,18 @@ - 2026-07-11:补跑年度尺度控制日期;主链 timing gate 降级为 `unvalidated_broad_window`,并按领域标记 career blocked、marriage partial candidate。 - 2026-07-16:Prashna guarded evidence、Rangacharya knowledge-only/source gates 与 clean-checkout governance 已提交;主域 score/verdict 不受 Prashna context 影响。 - 2026-07-16:VedAstro preview/metadata 已移除认证 header,实际 HTTP 发送前才注入 API key;此前暴露的 key 必须轮换。 + +## 2026-07-27 - staging 普通用户邮箱密码认证 + +- 目标:仅 staging 普通用户启用 Better Auth 邮箱注册、OTP/密码登录、首次设密与 OTP 重置密码;不改 admin、production Supabase、数据库结构或依赖。 +- 顺序:基线验证 → 后端原生密码能力 → 单页登录/注册/重置 UI → 测试与反向验证 → 精确提交 → main/staging 同 SHA 发布验收。 +- 基线:fetch 后 origin/main=origin/staging=f402e6f79c7c8c65de9137770b3041c6eb55da42,双向祖先检查通过;独立 worktree/分支创建完成。 +- 基线验证:npm ci 成功;identity 单元 35/35、PostgreSQL OTP 集成 1/1、npm run build 全绿,skipped=todo=0。 +- 最大风险:Better Auth 1.6.23 的 OTP 新用户/设密/重置会话语义、真实 staging 收信条件、main 分支保护与同 SHA 发布控制器。 +- 实现:仅 user surface 启用原生 emailAndPassword;新增登录/注册/首次设密/OTP 重置单页流程与受 session 保护的设密接口,admin/Supabase 保持 OTP-only。 +- 新集成测试:1/1 通过,覆盖新旧 OTP 用户设密、密码/OTP 登录、重置撤销旧会话、哈希、错误密码无 Cookie、admin 无密码登录;skipped=todo=0。 +- 第 1 轮完整验证:identity 36/36、PostgreSQL 集成 1/1、staging 契约 20/20、ESLint 0 warning/error、Next build 成功、git diff --check 通过,全部 skipped=todo=0。 +- 反向验证:临时将 user emailAndPassword.enabled=false 后新增集成测试按预期失败(400 != 200);trap 恢复文件后同测试 1/1 全绿,未保留临时破坏。 +- 第 2 轮最终验证:兼容保留既有 selfHostedOtpActions/createSelfHostedOtpActions 导出后,identity 36/36、集成 1/1、staging 契约 20/20、ESLint、build、diff check 再次全绿。 +- 发布前:再次 fetch 后 main/staging 仍同为 f402e6f79c7c8c65de9137770b3041c6eb55da42,main 未受保护;允许范围审计通过,依赖/lockfile/migration/deploy/workflow diff=0。 +- 阻塞:执行环境无受控 staging 测试邮箱/收件箱,真实收信验收写入 BLOCKED.md;不使用他人邮箱,代码与部署继续。