feat(auth): add staging email password flows

This commit is contained in:
Jesse_Chen
2026-07-27 17:02:16 +08:00
parent f402e6f79c
commit ab2944f7fa
11 changed files with 982 additions and 122 deletions
+3
View File
@@ -0,0 +1,3 @@
# BLOCKED
- 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。
@@ -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<typeof getIdentityAuthServices>,
userId: string,
): Promise<boolean> {
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<Response> {
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<Response> {
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: "密码长度须为 8128 位" }, { 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 });
}
}
+8 -2
View File
@@ -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); }
+5 -1
View File
@@ -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 <EmailOtpLogin provider={provider} />;
return (
<EmailOtpLogin provider={provider} passwordEnabled={passwordEnabled} />
);
}
+337 -15
View File
@@ -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 "密码长度须为 8128 位";
}
if (password !== confirmation) return "两次输入的密码不一致";
return "";
}
export function EmailOtpLogin({
provider,
passwordEnabled = false,
}: {
provider: AuthProvider;
passwordEnabled?: boolean;
}) {
const [mode, setMode] = useState<AuthMode>("otp");
const [step, setStep] = useState<AuthStep>("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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="standalone-page auth-page">
<div className="auth-shell">
@@ -123,13 +254,82 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) {
<span aria-hidden="true" />
<strong>Jyotisha</strong>
</div>
<h1 id="login-title"></h1>
<p className="page-intro">
<span className="phrase-nowrap"></span>
</p>
<h1 id="login-title">{title}</h1>
<p className="page-intro">{intro}</p>
{!sent ? (
{canUsePassword && (
<nav className="auth-mode-nav" aria-label="登录方式">
<div className="auth-mode-tabs">
<button
type="button"
aria-pressed={mode === "otp"}
onClick={() => chooseMode("otp")}
>
</button>
<button
type="button"
aria-pressed={mode === "password"}
onClick={() => chooseMode("password")}
>
</button>
</div>
<div className="auth-links">
<button type="button" onClick={() => chooseMode("register")}>
</button>
<button type="button" onClick={() => chooseMode("forgot")}>
</button>
</div>
</nav>
)}
{mode === "password" && step === "email" ? (
<form
key="password-login"
className="stack-form auth-step"
onSubmit={signInWithPassword}
>
<label htmlFor="password-email"></label>
<input
id="password-email"
type="email"
autoComplete="email"
inputMode="email"
required
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
setError("");
}}
placeholder="you@example.com"
/>
<label htmlFor="current-password"></label>
<input
id="current-password"
type="password"
autoComplete="current-password"
required
minLength={8}
maxLength={128}
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError("");
}}
/>
<button
className="button-primary"
type="submit"
disabled={!email.trim() || !password || busy}
>
{busy ? "登录中" : "密码登录"}
</button>
</form>
) : step === "email" ? (
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
<label htmlFor="login-email"></label>
<input
@@ -155,7 +355,75 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) {
{busy ? "发送中" : "发送验证码"}
</button>
</form>
) : (
) : step === "otp" && mode === "forgot" ? (
<form
key="reset-password"
className="stack-form auth-step"
onSubmit={resetPassword}
>
<label htmlFor="reset-token"></label>
<input
id="reset-token"
className="otp-input"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
minLength={6}
maxLength={6}
required
autoFocus
value={token}
onChange={(event) => {
setToken(event.target.value.replace(/\D/g, "").slice(0, 6));
setError("");
}}
/>
<label htmlFor="reset-password-new"></label>
<input
id="reset-password-new"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError("");
}}
/>
<label htmlFor="reset-password-confirm"></label>
<input
id="reset-password-confirm"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
value={confirmation}
onChange={(event) => {
setConfirmation(event.target.value);
setError("");
}}
/>
<button
className="button-primary"
type="submit"
disabled={!token || !password || !confirmation || busy}
>
{busy ? "重置中" : "重置密码"}
</button>
<div className="inline-actions">
<button type="button" disabled={busy} onClick={() => void sendOtp()}>
{busy ? "发送中" : "重新发送验证码"}
</button>
<button type="button" disabled={busy} onClick={changeEmail}>
</button>
</div>
</form>
) : step === "otp" ? (
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
<label htmlFor="login-token"></label>
<input
@@ -191,7 +459,61 @@ export function EmailOtpLogin({ provider }: { provider: AuthProvider }) {
</button>
</div>
</form>
) : step === "set-password" ? (
<form
key="set-password"
className="stack-form auth-step"
onSubmit={saveFirstPassword}
>
<label htmlFor="new-password"></label>
<input
id="new-password"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
autoFocus
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError("");
}}
/>
<label htmlFor="confirm-password"></label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
value={confirmation}
onChange={(event) => {
setConfirmation(event.target.value);
setError("");
}}
/>
<button
className="button-primary"
type="submit"
disabled={!password || !confirmation || busy}
>
{busy ? "保存中" : "设置密码并继续"}
</button>
</form>
) : (
<div className="stack-form auth-step">
<button
className="button-primary"
type="button"
onClick={() => window.location.assign("/")}
>
</button>
</div>
)}
{error && (
<p className="form-error" role="alert">
{error}
@@ -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({
+96 -13
View File
@@ -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<OtpClientResult>;
}): Promise<AuthClientResult>;
requestPasswordReset?(input: { email: string }): Promise<AuthClientResult>;
resetPassword?(input: {
email: string;
otp: string;
password: string;
}): Promise<AuthClientResult>;
};
signIn: {
emailOtp(input: {
email: string;
otp: string;
}): Promise<OtpClientResult>;
}): Promise<AuthClientResult>;
email?(input: {
email: string;
password: string;
}): Promise<AuthClientResult>;
};
signOut?(): Promise<OtpClientResult>;
signOut?(): Promise<AuthClientResult>;
}
export interface SelfHostedOtpActions {
export interface SelfHostedAuthActions {
send(email: string): Promise<void>;
verify(email: string, otp: string): Promise<void>;
signInWithPassword(email: string, password: string): Promise<void>;
requestPasswordReset(email: string): Promise<void>;
resetPassword(email: string, otp: string, password: string): Promise<void>;
hasPassword(): Promise<boolean>;
setPassword(password: string): Promise<void>;
signOut(): Promise<void>;
}
export function createSelfHostedOtpActions(
client: SelfHostedOtpClient,
): SelfHostedOtpActions {
type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
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;
@@ -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);
+268 -65
View File
@@ -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<string, unknown>,
body?: Record<string, unknown>,
cookie?: string,
): Request {
const headers: Record<string, string> = {
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 <login@staging.jyotisha.chat>",
};
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<typeof createIdentityAuthServices>;
};
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<string> {
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<Response> {
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;
}
}
});
+134 -26
View File
@@ -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<string, string> }> = [];
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<string, string> }> = [];
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);
});
+15
View File
@@ -972,3 +972,18 @@
- 2026-07-11:补跑年度尺度控制日期;主链 timing gate 降级为 `unvalidated_broad_window`,并按领域标记 career blocked、marriage partial candidate。
- 2026-07-16Prashna guarded evidence、Rangacharya knowledge-only/source gates 与 clean-checkout governance 已提交;主域 score/verdict 不受 Prashna context 影响。
- 2026-07-16VedAstro 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 仍同为 f402e6f79c7c8c65de9137770b3041c6eb55da42main 未受保护;允许范围审计通过,依赖/lockfile/migration/deploy/workflow diff=0。
- 阻塞:执行环境无受控 staging 测试邮箱/收件箱,真实收信验收写入 BLOCKED.md;不使用他人邮箱,代码与部署继续。