Skip to content

Commit 0e8ef10

Browse files
feat(login): 连接登录方式 + 新用户邮箱验证页 + 登录 CSRF 防护 (#394)
* feat(settings): 登录方式加"连接"按钮(M2b 绑定入口) 配套后端的 /oauth/bind/{provider}。此前设置页只有"解绑",没有绑定入口—— 组件注释自己也写着"现在加会把用户登成新账号(分叉)",因为绑定流程还没做。 现在绑定走 /oauth/bind/{provider}(后端要求已登录,把身份挂到当前账号, 不建号、不换会话),所以按钮可以放出来了。 可绑列表来自新端点 GET /api/user-center/identities/providers,返回后端已注册的 provider——前端不再维护"有哪些登录方式"的写死列表,接入新 provider 时按钮会自动 出现。PROVIDER_LABEL 退化成纯展示名,查不到就显示原始 key,保证新 provider 不会 静默消失(顺带用 Object.hasOwn 查表,避免原型链键)。 绑定回调结果 ?bind=ok / ?bind_error=<code> 给出可区分文案,其中 bind_taken (该第三方账号已绑到别人)必须说清楚"先去那个账号解绑",否则用户只会看到 一个无法自助解决的失败。 用 Suspense 包裹(新增 useSearchParams)。settings 页改动前后均为 ƒ 动态, 非本次引入。72 测试通过 / typecheck / lint 0 error / build 通过。 * fix(settings): "连接"改为 POST /oauth/bind 取授权地址,绑定结果不再被冲掉 - 连接按钮改为带 satoken header POST /oauth/bind/{provider},拿到 data.url 后 location.assign 跳转;GET 入口后端已下线(GET 可被跨站触发)。401 提示登录 已过期并给出 /login 链接,其它失败显示后端 message 或通用文案。 - ?bind=ok / ?bind_error= 改为渲染时从 URL 派生,不再写进 error state: load() 成功后 setError(null) 会在约 200ms 后把绑定失败提示冲掉。 - bindTaken 文案不再让用户去另一个账号解绑(后端禁止解绑最后一种登录方式, 照做也走不通),改为说明该登录方式属于另一个账号、合并请联系管理员。 - 导出 labelOf 供新用户邮箱验证页复用。 * fix(auth): #token 只接受本标签页发起的登录,堵住 login CSRF 此前 use-auth 无条件接受任意 /#token=,攻击者发一个带自己 token 的链接就能把 受害者静默登进攻击者账号;配合设置页的"连接",受害者会把自己的第三方身份绑到 攻击者账号上。 - SignInButton 跳 /oauth/render/{provider} 前往 sessionStorage 写一次性 nonce (发起时间戳,10 分钟有效)。 - AuthProvider 只有在 nonce 存在且未过期时才存 #token,取出即删;否则丢弃 token,仍从 URL 清掉 fragment,继续走已存 token 的正常流程。 - SSO 授权页的 takePending 续接不受影响:它在登录确认之后才执行。 - 新增 tests/login-nonce.test.ts 覆盖接受 / 一次性 / 无 nonce / 过期 / 损坏值。 * feat(login): 新用户邮箱验证页 /login/verify(OTP 注册) 后端对全新用户(身份、用户名、已验证邮箱都没命中)不再直接建号,而是 302 到 /login/verify 并下发 httpOnly 的 ih_reg_pending cookie,验证完邮箱才创建账号。 - page.tsx:SSG 壳,满足 next-intl 三条件(await params / setRequestLocale 先于 getTranslations / generateStaticParams),robots noindex。 - VerifyEmailForm:loading → email → code → expired。GET /auth/register/pending 预填第三方邮箱并显示 provider;POST send-otp 发码;POST verify 成功后 arm 登录 nonce 再 location.replace('/{locale}#token=…'),复用 use-auth 的 #token 流程 (含 SSO 续接)。email / code 两步始终提示"已有账号请用原方式登录后在设置里连接 {provider}", 这是邮箱为空的老账号防分叉的唯一手段。 - 导出纯函数 resolveVerifyError 把 400/409/410/429/503 映射到 i18n key, tests/verify-email.test.ts 覆盖映射、key 存在性和 zh/en key 集合一致。 - 新增 zh/en verifyEmail 命名空间。 - LinkedAccounts 的 /login 链接改用 next/link:@/i18n/navigation 在 vitest node 环境下无法解析,而验证页要复用它导出的 labelOf。 * feat(login): 登录页识别 account_disabled 错误码 后端 OAuth 回调遇到被停用的账号会 302 到 /login?error=account_disabled, 此前会落到通用的"请重试",而重试永远不会成功。新增 zh/en 文案提示账号已停用、 联系管理员;映射仍走显式白名单 + Object.hasOwn。 * fix(login): 验证页 / 设置页的 /login 链接带上当前 locale 改用 next/link 后 href="/login" 不带 locale 前缀,要靠 middleware 再按 cookie / Accept-Language 猜一次语言并 307:英文页面上的用户可能被送到 /zh/login。 直接拼 /{locale}/login,和当前页面语言保持一致,也省一次跳转。 * fix(login): 邮箱验证返回 409 时进入终态,不再留下必然失败的按钮 后端在 409(邮箱已被占用)前已消费掉注册会话,之后"验证"/"重发"/"换邮箱" 只会拿到 410,并把"该邮箱已有账号"的提示换成错误的"会话已过期"。409 改为 与 410 一样切到 expired 步骤,保留 errorEmailTaken 文案和返回登录链接。 * fix(auth): 登录 nonce 改为随机值并由后端回传逐次比对,被拒的 #token 跳回登录页 时间戳 nonce 只能证明"本标签页 10 分钟内发起过登录":登录未带回 #token (报错、新用户验证页、授权页停留)时 nonce 一直有效,持有窗口句柄的打开者 可在此期间把标签页导向 /#token=<攻击者 token> 完成 login CSRF。 - SignInButton 生成 16 字节随机 nonce(base64url)存 sessionStorage, 跳 /oauth/render/{provider}?n=<nonce>;后端登录回调回跳 #token=…&n=…。 - AuthProvider 只在 n 与存的 nonce 完全一致时接受 token;只要带 #token 就 消费掉已存 nonce,并从 URL 清掉 token 和 n。 - 被拒时 location.replace 到 /<locale>/login?error=oauth_state,而不是静默 以未登录继续;不取 SSO pending,重新登录成功后照常续接授权请求。 - 新用户验证页验证成功后同样生成 nonce,带 &n= 跳回。 - 测试覆盖:完全一致才接受 / 缺失或不一致拒绝 / 任何一次校验都消费 nonce / 格式与长度 / 被拒后 SSO pending 仍在。 需先部署后端(回传 n),再部署前端。 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 63da533 commit 0e8ef10

12 files changed

Lines changed: 572 additions & 28 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"use client";
2+
3+
import { type FormEvent, useEffect, useState } from "react";
4+
import { useLocale, useTranslations } from "next-intl";
5+
import Link from "next/link";
6+
import { Button } from "@/app/components/ui/button";
7+
import { labelOf } from "@/app/[locale]/settings/LinkedAccounts";
8+
import { armLoginNonce } from "@/lib/use-auth";
9+
10+
type Step = "loading" | "email" | "code" | "expired";
11+
12+
// /auth/register/* 用状态码区分失败原因,这里映射成 verifyEmail 下的 i18n key
13+
export function resolveVerifyError(
14+
status: number,
15+
step: "send" | "verify",
16+
): string {
17+
switch (status) {
18+
case 400:
19+
return step === "send" ? "errorInvalidEmail" : "errorInvalidCode";
20+
case 409:
21+
return "errorEmailTaken";
22+
case 410:
23+
return "errorExpired";
24+
case 429:
25+
return "errorRateLimited";
26+
default:
27+
return step === "send" ? "errorSendFailed" : "errorGeneric";
28+
}
29+
}
30+
31+
const ALERT_CLASS =
32+
"w-full rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200";
33+
const INPUT_CLASS =
34+
"mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm";
35+
36+
export function VerifyEmailForm() {
37+
const t = useTranslations("verifyEmail");
38+
const locale = useLocale();
39+
const [step, setStep] = useState<Step>("loading");
40+
const [provider, setProvider] = useState("");
41+
const [email, setEmail] = useState("");
42+
const [code, setCode] = useState("");
43+
const [error, setError] = useState<string | null>(null);
44+
const [busy, setBusy] = useState(false);
45+
const vars = { provider: labelOf(provider), email };
46+
47+
// 会话凭据是 httpOnly 的 ih_reg_pending cookie,同源 fetch 自动带上
48+
useEffect(() => {
49+
fetch("/auth/register/pending")
50+
.then((res) => (res.ok ? res.json() : Promise.reject()))
51+
.then(({ data }) => {
52+
setProvider(data.provider);
53+
setEmail(data.providerEmail ?? "");
54+
setStep("email");
55+
})
56+
.catch(() => setStep("expired"));
57+
}, []);
58+
59+
async function post(kind: "send" | "verify", body: object) {
60+
setBusy(true);
61+
setError(null);
62+
const res = await fetch(
63+
kind === "send" ? "/auth/register/send-otp" : "/auth/register/verify",
64+
{
65+
method: "POST",
66+
headers: { "Content-Type": "application/json" },
67+
body: JSON.stringify(body),
68+
},
69+
).catch(() => null);
70+
if (res?.ok) return res.json();
71+
setBusy(false);
72+
// 409 时后端已消费掉注册会话,和 410 一样是终态
73+
if (res?.status === 410 || res?.status === 409) setStep("expired");
74+
setError(res ? resolveVerifyError(res.status, kind) : "errorGeneric");
75+
return null;
76+
}
77+
78+
async function sendCode(e?: FormEvent) {
79+
e?.preventDefault();
80+
if (!(await post("send", { email }))) return;
81+
setBusy(false);
82+
setCode("");
83+
setStep("code");
84+
}
85+
86+
// 登录交给 use-auth 现成的 #token 流程(含 SSO 续接),所以同样要先 arm nonce
87+
async function verify(e: FormEvent) {
88+
e.preventDefault();
89+
const body = await post("verify", { email, code });
90+
if (!body) return;
91+
const n = armLoginNonce(sessionStorage);
92+
window.location.replace(
93+
`/${locale}#token=${encodeURIComponent(body.data.tokenValue)}&n=${n}`,
94+
);
95+
}
96+
97+
if (step === "loading") {
98+
return (
99+
<div className="mx-auto size-12 rounded-full bg-muted animate-pulse" />
100+
);
101+
}
102+
103+
if (step === "expired") {
104+
return (
105+
<div className="space-y-4 text-center">
106+
<div role="alert" className={ALERT_CLASS}>
107+
{t(error ?? "errorExpired", vars)}
108+
</div>
109+
<Link href={`/${locale}/login`} className="text-sm underline">
110+
{t("backToLogin")}
111+
</Link>
112+
</div>
113+
);
114+
}
115+
116+
return (
117+
<div className="space-y-6">
118+
<p className="text-muted-foreground">{t("intro", vars)}</p>
119+
{error && (
120+
<div role="alert" className={ALERT_CLASS}>
121+
{t(error, vars)}
122+
</div>
123+
)}
124+
{step === "email" ? (
125+
<form onSubmit={sendCode} className="space-y-3">
126+
<label className="block text-sm font-medium">
127+
{t("emailLabel")}
128+
<input
129+
type="email"
130+
required
131+
autoComplete="email"
132+
value={email}
133+
onChange={(e) => setEmail(e.target.value)}
134+
className={INPUT_CLASS}
135+
/>
136+
</label>
137+
<Button type="submit" className="w-full" disabled={busy}>
138+
{t("sendCode")}
139+
</Button>
140+
</form>
141+
) : (
142+
<form onSubmit={verify} className="space-y-3">
143+
<p className="text-sm">{t("codeSent", vars)}</p>
144+
<label className="block text-sm font-medium">
145+
{t("codeLabel")}
146+
<input
147+
required
148+
inputMode="numeric"
149+
autoComplete="one-time-code"
150+
pattern="\d{6}"
151+
maxLength={6}
152+
value={code}
153+
onChange={(e) => setCode(e.target.value.trim())}
154+
className={`${INPUT_CLASS} font-mono tracking-widest`}
155+
/>
156+
</label>
157+
<Button type="submit" className="w-full" disabled={busy}>
158+
{t("verify")}
159+
</Button>
160+
<div className="flex justify-between text-sm">
161+
<button
162+
type="button"
163+
onClick={() => sendCode()}
164+
disabled={busy}
165+
className="underline disabled:opacity-50"
166+
>
167+
{t("resend")}
168+
</button>
169+
<button
170+
type="button"
171+
onClick={() => {
172+
setError(null);
173+
setStep("email");
174+
}}
175+
disabled={busy}
176+
className="underline disabled:opacity-50"
177+
>
178+
{t("changeEmail")}
179+
</button>
180+
</div>
181+
</form>
182+
)}
183+
<p className="text-sm text-muted-foreground">
184+
{t("haveAccount", vars)}{" "}
185+
<Link href={`/${locale}/login`} className="underline">
186+
{t("backToLogin")}
187+
</Link>
188+
</p>
189+
</div>
190+
);
191+
}

‎app/[locale]/login/verify/page.tsx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Metadata } from "next";
2+
import { setRequestLocale, getTranslations } from "next-intl/server";
3+
import { hasLocale } from "next-intl";
4+
import { notFound } from "next/navigation";
5+
import { routing } from "@/i18n/routing";
6+
import { VerifyEmailForm } from "./VerifyEmailForm";
7+
8+
export const metadata: Metadata = {
9+
title: "Verify Email",
10+
robots: { index: false, follow: false },
11+
};
12+
13+
interface Props {
14+
params: Promise<{ locale: string }>;
15+
}
16+
17+
// 注册会话只在 httpOnly cookie 里,内容全靠客户端拉取;页面壳照常 SSG
18+
export default async function VerifyEmailPage({ params }: Props) {
19+
const { locale } = await params;
20+
if (!hasLocale(routing.locales, locale)) notFound();
21+
setRequestLocale(locale);
22+
23+
const t = await getTranslations("verifyEmail");
24+
return (
25+
<div className="min-h-screen flex items-center justify-center bg-background">
26+
<div className="w-full max-w-md space-y-8 p-8">
27+
<h1 className="text-3xl font-bold text-center">{t("title")}</h1>
28+
<VerifyEmailForm />
29+
</div>
30+
</div>
31+
);
32+
}
33+
34+
export function generateStaticParams() {
35+
return routing.locales.map((locale) => ({ locale }));
36+
}

0 commit comments

Comments
 (0)