feat(auth): add staging email password flows
This commit is contained in:
@@ -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: "密码长度须为 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 });
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;不使用他人邮箱,代码与部署继续。
|
||||
|
||||
Reference in New Issue
Block a user