From 74b12fec259ff1d49d162f708a97684f35a74209 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 26 Jul 2026 12:04:40 +0000
Subject: [PATCH 1/8] =?UTF-8?q?feat(settings):=20=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F=E5=8A=A0"=E8=BF=9E=E6=8E=A5"=E6=8C=89?=
=?UTF-8?q?=E9=92=AE=EF=BC=88M2b=20=E7=BB=91=E5=AE=9A=E5=85=A5=E5=8F=A3?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
配套后端的 /oauth/bind/{provider}。此前设置页只有"解绑",没有绑定入口——
组件注释自己也写着"现在加会把用户登成新账号(分叉)",因为绑定流程还没做。
现在绑定走 /oauth/bind/{provider}(后端要求已登录,把身份挂到当前账号,
不建号、不换会话),所以按钮可以放出来了。
可绑列表来自新端点 GET /api/user-center/identities/providers,返回后端已注册的
provider——前端不再维护"有哪些登录方式"的写死列表,接入新 provider 时按钮会自动
出现。PROVIDER_LABEL 退化成纯展示名,查不到就显示原始 key,保证新 provider 不会
静默消失(顺带用 Object.hasOwn 查表,避免原型链键)。
绑定回调结果 ?bind=ok / ?bind_error= 给出可区分文案,其中 bind_taken
(该第三方账号已绑到别人)必须说清楚"先去那个账号解绑",否则用户只会看到
一个无法自助解决的失败。
用 Suspense 包裹(新增 useSearchParams)。settings 页改动前后均为 ƒ 动态,
非本次引入。72 测试通过 / typecheck / lint 0 error / build 通过。
---
app/[locale]/settings/LinkedAccounts.tsx | 102 +++++++++++++++++++----
app/[locale]/settings/page.tsx | 7 +-
messages/en.json | 9 +-
messages/zh.json | 9 +-
4 files changed, 106 insertions(+), 21 deletions(-)
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx
index 1558bc60..ab64c779 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
+import { useSearchParams } from "next/navigation";
type LinkedIdentity = {
provider: string;
@@ -15,41 +16,84 @@ function getToken(): string | null {
return localStorage.getItem("satoken");
}
+// 只是展示名。后端 /identities/providers 才是"有哪些登录方式"的真相源,
+// 这里查不到就回退显示原始 key,保证新接入的 provider 不会静默消失。
const PROVIDER_LABEL: Record = {
github: "GitHub",
discord: "Discord",
};
+function labelOf(provider: string): string {
+ return Object.hasOwn(PROVIDER_LABEL, provider)
+ ? PROVIDER_LABEL[provider]!
+ : provider;
+}
+
+// 后端绑定回调会跳回 /settings?bind=ok 或 ?bind_error=
+const BIND_ERROR_KEYS: Record = {
+ bind_taken: "bindTaken",
+ bind_duplicate: "bindDuplicate",
+ bind_already_yours: "bindAlreadyYours",
+ bind_session: "bindSession",
+ oauth_state: "bindFailed",
+ oauth_provider: "bindFailed",
+ oauth_failed: "bindFailed",
+};
+
/**
- * 已绑定登录方式的查看 + 解绑(后端 M2a)。绑定新 provider 走 OAuth 流程,
- * 待绑定流程(M2b)上线后再加"连接"按钮——现在加会把用户登成新账号(分叉)。
+ * 已绑定登录方式的查看 / 绑定(M2b) / 解绑(M2a)。
+ *
+ * 绑定必须走 /oauth/bind/{provider} 而不是普通登录入口:后者会把用户登成新账号
+ * (分叉),而分叉之后那个第三方身份就被新账号占住,本尊再也补绑不回来。
*/
export function LinkedAccounts() {
const t = useTranslations("settings.linked");
const [items, setItems] = useState(null);
+ const [supported, setSupported] = useState([]);
const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null);
+ const params = useSearchParams();
- async function load() {
+ const load = useCallback(async () => {
const token = getToken();
if (!token) return;
try {
- const res = await fetch("/api/user-center/identities", {
- headers: { satoken: token },
- });
- if (!res.ok) throw new Error();
- const body = await res.json();
- setItems(body.data ?? []);
+ const [identities, providers] = await Promise.all([
+ fetch("/api/user-center/identities", { headers: { satoken: token } }),
+ fetch("/api/user-center/identities/providers", {
+ headers: { satoken: token },
+ }),
+ ]);
+ if (!identities.ok) throw new Error();
+ setItems((await identities.json()).data ?? []);
+ if (providers.ok) setSupported((await providers.json()).data ?? []);
setError(null);
} catch {
setError(t("loadFail"));
}
- }
+ }, [t]);
useEffect(() => {
load();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [load]);
+
+ // 绑定回调结果:成功给提示,失败给出可区分的原因("已被别的账号占用"等)
+ useEffect(() => {
+ if (params.get("bind") === "ok") {
+ setNotice(t("bindOk"));
+ return;
+ }
+ const code = params.get("bind_error");
+ if (!code) return;
+ setError(
+ t(
+ Object.hasOwn(BIND_ERROR_KEYS, code)
+ ? BIND_ERROR_KEYS[code]!
+ : "bindFailed",
+ ),
+ );
+ }, [params, t]);
async function unbind(provider: string) {
const token = getToken();
@@ -77,36 +121,58 @@ export function LinkedAccounts() {
if (items === null && !error) return null; // 未登录或加载中,保持安静
+ const bound = items ?? [];
+ const boundKeys = new Set(bound.map((i) => i.provider));
+ const bindable = supported.filter((p) => !boundKeys.has(p));
+
return (
+ {notice && (
+
+ {notice}
+
+ )}
{error && (
{error}
)}
- {(items ?? []).map((it) => (
+ {bound.map((it) => (
-
- {PROVIDER_LABEL[it.provider] ?? it.provider}
+ {labelOf(it.provider)}
{it.displayNameAtLink ? ` · ${it.displayNameAtLink}` : ""}
))}
- {(items ?? []).length === 0 && (
+ {bindable.map((p) => (
+ -
+
+ {labelOf(p)}
+
+
+ {t("connect")}
+
+
+ ))}
+ {bound.length === 0 && bindable.length === 0 && (
-
{t("empty")}
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 7294d24a..5b7965a4 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -2,6 +2,7 @@
// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
import type { Metadata } from "next";
+import { Suspense } from "react";
import { Header } from "@/app/components/Header";
import { Footer } from "@/app/components/Footer";
import { SettingsForm } from "./SettingsForm";
@@ -30,7 +31,11 @@ export default function SettingsPage() {
-
+ {/* LinkedAccounts 用 useSearchParams 读绑定回调结果(?bind=ok / ?bind_error=),
+ 必须有 Suspense 边界,否则整页会被拖成动态渲染 */}
+
+
+
diff --git a/messages/en.json b/messages/en.json
index e23e6963..478c3344 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -106,7 +106,14 @@
"empty": "No linked sign-in methods",
"loadFail": "Failed to load sign-in methods",
"unbindFail": "Unlink failed",
- "lastHint": "This is your only sign-in method and can't be unlinked"
+ "lastHint": "This is your only sign-in method and can't be unlinked",
+ "connect": "Connect",
+ "bindOk": "Linked successfully",
+ "bindTaken": "That account is already linked to a different user. Unlink it there first, then come back.",
+ "bindDuplicate": "You've already linked this sign-in method — one per account.",
+ "bindAlreadyYours": "This sign-in method is already on your account.",
+ "bindSession": "Your session changed during linking. Please click Connect again.",
+ "bindFailed": "Linking failed. Please try again."
}
},
"signIn": {
diff --git a/messages/zh.json b/messages/zh.json
index f3b3997b..9b5512de 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -106,7 +106,14 @@
"empty": "暂无绑定的第三方登录",
"loadFail": "加载登录方式失败",
"unbindFail": "解绑失败",
- "lastHint": "这是你唯一的登录方式,不能解绑"
+ "lastHint": "这是你唯一的登录方式,不能解绑",
+ "connect": "连接",
+ "bindOk": "绑定成功",
+ "bindTaken": "该第三方账号已经绑定到另一个账号了。请先登录那个账号解绑,再回来绑定。",
+ "bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
+ "bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
+ "bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
+ "bindFailed": "绑定失败,请重试。"
}
},
"signIn": {
From c23f41afb11f5da96edff4c79a544924a66802ca Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:55:48 +0000
Subject: [PATCH 2/8] =?UTF-8?q?fix(settings):=20"=E8=BF=9E=E6=8E=A5"?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=20POST=20/oauth/bind=20=E5=8F=96=E6=8E=88?=
=?UTF-8?q?=E6=9D=83=E5=9C=B0=E5=9D=80=EF=BC=8C=E7=BB=91=E5=AE=9A=E7=BB=93?=
=?UTF-8?q?=E6=9E=9C=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=86=B2=E6=8E=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 连接按钮改为带 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 供新用户邮箱验证页复用。
---
app/[locale]/settings/LinkedAccounts.tsx | 73 ++++++++++++++++--------
messages/en.json | 5 +-
messages/zh.json | 5 +-
3 files changed, 56 insertions(+), 27 deletions(-)
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx
index ab64c779..b7dbd24d 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
+import { Link } from "@/i18n/navigation";
type LinkedIdentity = {
provider: string;
@@ -23,7 +24,7 @@ const PROVIDER_LABEL: Record = {
discord: "Discord",
};
-function labelOf(provider: string): string {
+export function labelOf(provider: string): string {
return Object.hasOwn(PROVIDER_LABEL, provider)
? PROVIDER_LABEL[provider]!
: provider;
@@ -50,11 +51,21 @@ export function LinkedAccounts() {
const t = useTranslations("settings.linked");
const [items, setItems] = useState(null);
const [supported, setSupported] = useState([]);
- const [error, setError] = useState(null);
- const [notice, setNotice] = useState(null);
+ const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const params = useSearchParams();
+ // 回调结果直接从 URL 派生,不能放进 error state:load() 成功时会 setError(null) 把它冲掉
+ const bindOk = params.get("bind") === "ok";
+ const bindCode = params.get("bind_error");
+ const bindError = bindCode
+ ? t(
+ Object.hasOwn(BIND_ERROR_KEYS, bindCode)
+ ? BIND_ERROR_KEYS[bindCode]!
+ : "bindFailed",
+ )
+ : null;
+
const load = useCallback(async () => {
const token = getToken();
if (!token) return;
@@ -78,22 +89,33 @@ export function LinkedAccounts() {
load();
}, [load]);
- // 绑定回调结果:成功给提示,失败给出可区分的原因("已被别的账号占用"等)
- useEffect(() => {
- if (params.get("bind") === "ok") {
- setNotice(t("bindOk"));
+ // 必须 POST + satoken header 拿授权地址,不能是 GET:GET 可被别的站点直接触发
+ async function connect(provider: string) {
+ const token = getToken();
+ if (!token) return;
+ setBusy(provider);
+ const res = await fetch(`/oauth/bind/${provider}`, {
+ method: "POST",
+ headers: { satoken: token },
+ }).catch(() => null);
+ const body = await res?.json().catch(() => null);
+ if (res?.ok && typeof body?.data?.url === "string") {
+ window.location.assign(body.data.url); // 不清 busy,跳走前不让再点
return;
}
- const code = params.get("bind_error");
- if (!code) return;
+ setBusy(null);
setError(
- t(
- Object.hasOwn(BIND_ERROR_KEYS, code)
- ? BIND_ERROR_KEYS[code]!
- : "bindFailed",
- ),
+ res?.status === 401
+ ? t.rich("bindNeedLogin", {
+ login: (chunks) => (
+
+ {chunks}
+
+ ),
+ })
+ : (body?.message ?? t("bindFailed")),
);
- }, [params, t]);
+ }
async function unbind(provider: string) {
const token = getToken();
@@ -130,11 +152,14 @@ export function LinkedAccounts() {
- {notice && (
+ {bindOk && (
- {notice}
+ {t("bindOk")}
)}
+ {bindError && (
+ {bindError}
+ )}
{error && (
{error}
)}
@@ -164,12 +189,14 @@ export function LinkedAccounts() {
{labelOf(p)}
- connect(p)}
+ disabled={busy === p}
+ className="font-mono text-xs uppercase tracking-widest text-[var(--foreground)]/70 hover:text-[var(--foreground)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
- {t("connect")}
-
+ {busy === p ? "…" : t("connect")}
+
))}
{bound.length === 0 && bindable.length === 0 && (
diff --git a/messages/en.json b/messages/en.json
index 478c3344..6eb0107c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -109,11 +109,12 @@
"lastHint": "This is your only sign-in method and can't be unlinked",
"connect": "Connect",
"bindOk": "Linked successfully",
- "bindTaken": "That account is already linked to a different user. Unlink it there first, then come back.",
+ "bindTaken": "That sign-in method already belongs to another account. To merge the two accounts, please contact an admin.",
"bindDuplicate": "You've already linked this sign-in method — one per account.",
"bindAlreadyYours": "This sign-in method is already on your account.",
"bindSession": "Your session changed during linking. Please click Connect again.",
- "bindFailed": "Linking failed. Please try again."
+ "bindFailed": "Linking failed. Please try again.",
+ "bindNeedLogin": "Your session has expired. Sign in again, then connect."
}
},
"signIn": {
diff --git a/messages/zh.json b/messages/zh.json
index 9b5512de..768fd019 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -109,11 +109,12 @@
"lastHint": "这是你唯一的登录方式,不能解绑",
"connect": "连接",
"bindOk": "绑定成功",
- "bindTaken": "该第三方账号已经绑定到另一个账号了。请先登录那个账号解绑,再回来绑定。",
+ "bindTaken": "这个登录方式已经属于另一个账号了。如需把两个账号合并,请联系管理员。",
"bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
"bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
"bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
- "bindFailed": "绑定失败,请重试。"
+ "bindFailed": "绑定失败,请重试。",
+ "bindNeedLogin": "登录已过期,请重新登录后再连接。"
}
},
"signIn": {
From b867fa15adb217018efa3f0fc9ef073688ff6416 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:57:17 +0000
Subject: [PATCH 3/8] =?UTF-8?q?fix(auth):=20#token=20=E5=8F=AA=E6=8E=A5?=
=?UTF-8?q?=E5=8F=97=E6=9C=AC=E6=A0=87=E7=AD=BE=E9=A1=B5=E5=8F=91=E8=B5=B7?=
=?UTF-8?q?=E7=9A=84=E7=99=BB=E5=BD=95=EF=BC=8C=E5=A0=B5=E4=BD=8F=20login?=
=?UTF-8?q?=20CSRF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
此前 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 / 过期 / 损坏值。
---
app/components/SignInButton.tsx | 8 +++++-
lib/use-auth.tsx | 27 ++++++++++++++++-
tests/login-nonce.test.ts | 51 +++++++++++++++++++++++++++++++++
3 files changed, 84 insertions(+), 2 deletions(-)
create mode 100644 tests/login-nonce.test.ts
diff --git a/app/components/SignInButton.tsx b/app/components/SignInButton.tsx
index 64bf3bd9..b5b161b7 100644
--- a/app/components/SignInButton.tsx
+++ b/app/components/SignInButton.tsx
@@ -1,6 +1,7 @@
"use client";
import { Button } from "@/app/components/ui/button";
+import { armLoginNonce } from "@/lib/use-auth";
interface SignInButtonProps {
className?: string;
@@ -18,7 +19,12 @@ export function SignInButton({
// provider 已定:同源跳 /oauth/render/{provider},经 next.config rewrite 代理到后端。
// provider 未定:跳 /login 选择页(middleware 会补 locale 前缀)。
const handleSignIn = () => {
- window.location.href = provider ? `/oauth/render/${provider}` : "/login";
+ if (!provider) {
+ window.location.href = "/login";
+ return;
+ }
+ armLoginNonce(sessionStorage);
+ window.location.href = `/oauth/render/${provider}`;
};
return (
diff --git a/lib/use-auth.tsx b/lib/use-auth.tsx
index 81342321..e92d4e0b 100644
--- a/lib/use-auth.tsx
+++ b/lib/use-auth.tsx
@@ -68,6 +68,26 @@ function syncTokenCookie(token: string | null) {
}
}
+const LOGIN_NONCE_KEY = "ih:login:nonce";
+const LOGIN_NONCE_TTL_MS = 10 * 60 * 1000;
+
+// #token 只认本标签页自己发起的登录:否则任何人发一个 /#token=<自己的 token> 链接,
+// 就能把受害者静默登进攻击者账号,再诱导其在设置页把自己的第三方身份绑过去。
+// 所有跳 /oauth/render/* 或带 #token 回站的地方,跳转前都必须先调这个。
+// ponytail: 后端不回传 nonce,只能校验"本标签页 10 分钟内发起过登录";要逐次比对需后端在回跳里带回它
+export function armLoginNonce(storage: Storage) {
+ storage.setItem(LOGIN_NONCE_KEY, String(Date.now()));
+}
+
+// 取出即删;没有、过期、损坏都返回 false
+export function takeLoginNonce(storage: Storage): boolean {
+ const raw = storage.getItem(LOGIN_NONCE_KEY);
+ if (raw === null) return false;
+ storage.removeItem(LOGIN_NONCE_KEY);
+ const age = Date.now() - Number(raw);
+ return age >= 0 && age < LOGIN_NONCE_TTL_MS;
+}
+
// 调用后端 /auth/me 验证 token 并获取用户信息
// 走 Next.js rewrite(/auth/* → 后端),浏览器无跨域问题
async function fetchCurrentUser(token: string): Promise {
@@ -92,12 +112,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// 1. 检查 URL fragment 中是否携带 #token=xxx(后端 OAuth 登录成功后跳回来时携带)
// 使用 fragment 而非 query param:fragment 不会出现在服务器日志和 Referer 头中
const hashParams = new URLSearchParams(window.location.hash.slice(1));
- const urlToken = hashParams.get("token");
+ const hashToken = hashParams.get("token");
+ // 没有本标签页发起登录的 nonce → 丢弃,只清 URL 不存
+ const urlToken =
+ hashToken && takeLoginNonce(sessionStorage) ? hashToken : null;
if (urlToken) {
// 存入 localStorage + 同步写 cookie(供 api 子域直连场景使用,比如 pgAdmin)
localStorage.setItem("satoken", urlToken);
syncTokenCookie(urlToken);
+ }
+ if (hashToken) {
// 用 replaceState 清除 URL 中的 fragment,避免刷新或分享时 token 泄露
hashParams.delete("token");
const newHash = hashParams.toString();
diff --git a/tests/login-nonce.test.ts b/tests/login-nonce.test.ts
new file mode 100644
index 00000000..1d222599
--- /dev/null
+++ b/tests/login-nonce.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { armLoginNonce, takeLoginNonce } from "@/lib/use-auth";
+
+function memoryStorage() {
+ const m = new Map();
+ return {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => void m.set(k, v),
+ removeItem: (k: string) => void m.delete(k),
+ } as unknown as Storage;
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("login nonce", () => {
+ it("本标签页发起过登录 → #token 只接受一次", () => {
+ const s = memoryStorage();
+ armLoginNonce(s);
+ expect(takeLoginNonce(s)).toBe(true);
+ expect(takeLoginNonce(s)).toBe(false);
+ });
+
+ it("没发起过登录(别人发来的 /#token= 链接)→ 拒绝", () => {
+ expect(takeLoginNonce(memoryStorage())).toBe(false);
+ });
+
+ it("超过 10 分钟 → 拒绝,且 nonce 被消费掉", () => {
+ vi.useFakeTimers();
+ const s = memoryStorage();
+ vi.setSystemTime(1_000_000);
+ armLoginNonce(s);
+ vi.setSystemTime(1_000_000 + 10 * 60 * 1000 - 1);
+ expect(takeLoginNonce(s)).toBe(true);
+
+ vi.setSystemTime(1_000_000);
+ armLoginNonce(s);
+ vi.setSystemTime(1_000_000 + 10 * 60 * 1000);
+ expect(takeLoginNonce(s)).toBe(false);
+ expect(s.getItem("ih:login:nonce")).toBeNull();
+ });
+
+ it("损坏或来自未来的值 → 拒绝", () => {
+ const s = memoryStorage();
+ s.setItem("ih:login:nonce", "garbage");
+ expect(takeLoginNonce(s)).toBe(false);
+ s.setItem("ih:login:nonce", String(Date.now() + 60_000));
+ expect(takeLoginNonce(s)).toBe(false);
+ });
+});
From f6da704d98d017836b4ed7ae952f435ff554cd7e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 15:00:12 +0000
Subject: [PATCH 4/8] =?UTF-8?q?feat(login):=20=E6=96=B0=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81=E9=A1=B5=20/login/verify?=
=?UTF-8?q?=EF=BC=88OTP=20=E6=B3=A8=E5=86=8C=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端对全新用户(身份、用户名、已验证邮箱都没命中)不再直接建号,而是 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。
---
app/[locale]/login/verify/VerifyEmailForm.tsx | 190 ++++++++++++++++++
app/[locale]/login/verify/page.tsx | 36 ++++
app/[locale]/settings/LinkedAccounts.tsx | 2 +-
messages/en.json | 20 ++
messages/zh.json | 20 ++
tests/verify-email.test.ts | 37 ++++
6 files changed, 304 insertions(+), 1 deletion(-)
create mode 100644 app/[locale]/login/verify/VerifyEmailForm.tsx
create mode 100644 app/[locale]/login/verify/page.tsx
create mode 100644 tests/verify-email.test.ts
diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx
new file mode 100644
index 00000000..90ac8449
--- /dev/null
+++ b/app/[locale]/login/verify/VerifyEmailForm.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { type FormEvent, useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import Link from "next/link";
+import { Button } from "@/app/components/ui/button";
+import { labelOf } from "@/app/[locale]/settings/LinkedAccounts";
+import { armLoginNonce } from "@/lib/use-auth";
+
+type Step = "loading" | "email" | "code" | "expired";
+
+// /auth/register/* 用状态码区分失败原因,这里映射成 verifyEmail 下的 i18n key
+export function resolveVerifyError(
+ status: number,
+ step: "send" | "verify",
+): string {
+ switch (status) {
+ case 400:
+ return step === "send" ? "errorInvalidEmail" : "errorInvalidCode";
+ case 409:
+ return "errorEmailTaken";
+ case 410:
+ return "errorExpired";
+ case 429:
+ return "errorRateLimited";
+ default:
+ return step === "send" ? "errorSendFailed" : "errorGeneric";
+ }
+}
+
+const ALERT_CLASS =
+ "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";
+const INPUT_CLASS =
+ "mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm";
+
+export function VerifyEmailForm() {
+ const t = useTranslations("verifyEmail");
+ const locale = useLocale();
+ const [step, setStep] = useState("loading");
+ const [provider, setProvider] = useState("");
+ const [email, setEmail] = useState("");
+ const [code, setCode] = useState("");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const vars = { provider: labelOf(provider), email };
+
+ // 会话凭据是 httpOnly 的 ih_reg_pending cookie,同源 fetch 自动带上
+ useEffect(() => {
+ fetch("/auth/register/pending")
+ .then((res) => (res.ok ? res.json() : Promise.reject()))
+ .then(({ data }) => {
+ setProvider(data.provider);
+ setEmail(data.providerEmail ?? "");
+ setStep("email");
+ })
+ .catch(() => setStep("expired"));
+ }, []);
+
+ async function post(kind: "send" | "verify", body: object) {
+ setBusy(true);
+ setError(null);
+ const res = await fetch(
+ kind === "send" ? "/auth/register/send-otp" : "/auth/register/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ ).catch(() => null);
+ if (res?.ok) return res.json();
+ setBusy(false);
+ if (res?.status === 410) setStep("expired");
+ setError(res ? resolveVerifyError(res.status, kind) : "errorGeneric");
+ return null;
+ }
+
+ async function sendCode(e?: FormEvent) {
+ e?.preventDefault();
+ if (!(await post("send", { email }))) return;
+ setBusy(false);
+ setCode("");
+ setStep("code");
+ }
+
+ // 登录交给 use-auth 现成的 #token 流程(含 SSO 续接),所以同样要先 arm nonce
+ async function verify(e: FormEvent) {
+ e.preventDefault();
+ const body = await post("verify", { email, code });
+ if (!body) return;
+ armLoginNonce(sessionStorage);
+ window.location.replace(
+ `/${locale}#token=${encodeURIComponent(body.data.tokenValue)}`,
+ );
+ }
+
+ if (step === "loading") {
+ return (
+
+ );
+ }
+
+ if (step === "expired") {
+ return (
+
+
+ {t(error ?? "errorExpired", vars)}
+
+
+ {t("backToLogin")}
+
+
+ );
+ }
+
+ return (
+
+
{t("intro", vars)}
+ {error && (
+
+ {t(error, vars)}
+
+ )}
+ {step === "email" ? (
+
+ ) : (
+
+ )}
+
+ {t("haveAccount", vars)}{" "}
+
+ {t("backToLogin")}
+
+
+
+ );
+}
diff --git a/app/[locale]/login/verify/page.tsx b/app/[locale]/login/verify/page.tsx
new file mode 100644
index 00000000..03a4b960
--- /dev/null
+++ b/app/[locale]/login/verify/page.tsx
@@ -0,0 +1,36 @@
+import type { Metadata } from "next";
+import { setRequestLocale, getTranslations } from "next-intl/server";
+import { hasLocale } from "next-intl";
+import { notFound } from "next/navigation";
+import { routing } from "@/i18n/routing";
+import { VerifyEmailForm } from "./VerifyEmailForm";
+
+export const metadata: Metadata = {
+ title: "Verify Email",
+ robots: { index: false, follow: false },
+};
+
+interface Props {
+ params: Promise<{ locale: string }>;
+}
+
+// 注册会话只在 httpOnly cookie 里,内容全靠客户端拉取;页面壳照常 SSG
+export default async function VerifyEmailPage({ params }: Props) {
+ const { locale } = await params;
+ if (!hasLocale(routing.locales, locale)) notFound();
+ setRequestLocale(locale);
+
+ const t = await getTranslations("verifyEmail");
+ return (
+
+ );
+}
+
+export function generateStaticParams() {
+ return routing.locales.map((locale) => ({ locale }));
+}
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx
index b7dbd24d..f1bcd5c0 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -3,7 +3,7 @@
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
-import { Link } from "@/i18n/navigation";
+import Link from "next/link";
type LinkedIdentity = {
provider: string;
diff --git a/messages/en.json b/messages/en.json
index 6eb0107c..9efa99cf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -76,6 +76,26 @@
"errorOauthState": "Your sign-in session expired (took too long on the provider's page, or cookies were blocked). Please start the sign-in again.",
"errorOauthProvider": "This sign-in method isn't configured on the server — retrying won't help. Please contact an admin."
},
+ "verifyEmail": {
+ "title": "Verify your email",
+ "intro": "First time signing in with {provider}: verify an email to finish creating your account.",
+ "haveAccount": "Already have an account (e.g. via GitHub)? Sign in that way and connect {provider} under Settings → Sign-in methods — otherwise you'll end up with a second account.",
+ "backToLogin": "Back to sign in",
+ "emailLabel": "Email",
+ "sendCode": "Send code",
+ "codeSent": "We sent a code to {email}. It expires in 10 minutes.",
+ "codeLabel": "6-digit code",
+ "verify": "Verify and create account",
+ "resend": "Resend code",
+ "changeEmail": "Use a different email",
+ "errorInvalidEmail": "That doesn't look like a valid email.",
+ "errorInvalidCode": "Wrong or expired code. After 5 wrong attempts you'll need to sign in again.",
+ "errorRateLimited": "Too many emails: wait 60s between sends; at most 5 per address per day.",
+ "errorSendFailed": "We couldn't send the code. Please try again shortly.",
+ "errorExpired": "This sign-up session expired (over 15 minutes, or too many wrong codes). Please sign in again.",
+ "errorEmailTaken": "This email already belongs to an account. Sign in with that account, then connect {provider} under Settings → Sign-in methods.",
+ "errorGeneric": "Something went wrong. Please try again."
+ },
"settings": {
"theme": {
"label": "Theme",
diff --git a/messages/zh.json b/messages/zh.json
index 768fd019..73b19914 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -76,6 +76,26 @@
"errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。",
"errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。"
},
+ "verifyEmail": {
+ "title": "验证邮箱",
+ "intro": "这是你第一次用 {provider} 登录。验证一个邮箱即可完成注册。",
+ "haveAccount": "已经有账号了(比如用 GitHub 注册过)?请用原来的方式登录,再到「设置 → 登录方式」里连接 {provider},否则会多出一个新账号。",
+ "backToLogin": "返回登录",
+ "emailLabel": "邮箱",
+ "sendCode": "发送验证码",
+ "codeSent": "验证码已发送到 {email},10 分钟内有效。",
+ "codeLabel": "6 位验证码",
+ "verify": "验证并完成注册",
+ "resend": "重新发送",
+ "changeEmail": "换个邮箱",
+ "errorInvalidEmail": "邮箱格式不正确。",
+ "errorInvalidCode": "验证码不对或已过期。连续输错 5 次需要重新登录。",
+ "errorRateLimited": "发送太频繁:两次发送至少间隔 60 秒,同一邮箱每天最多 5 封。",
+ "errorSendFailed": "验证码发送失败,请稍后重试。",
+ "errorExpired": "注册会话已失效(超过 15 分钟,或验证码输错太多次)。请重新登录。",
+ "errorEmailTaken": "这个邮箱已经属于一个账号。请用那个账号登录,再到「设置 → 登录方式」里连接 {provider}。",
+ "errorGeneric": "出了点问题,请重试。"
+ },
"settings": {
"theme": {
"label": "主题",
diff --git a/tests/verify-email.test.ts b/tests/verify-email.test.ts
new file mode 100644
index 00000000..f09a44d7
--- /dev/null
+++ b/tests/verify-email.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import enMessages from "../messages/en.json";
+import zhMessages from "../messages/zh.json";
+import { resolveVerifyError } from "../app/[locale]/login/verify/VerifyEmailForm";
+
+describe("resolveVerifyError", () => {
+ it("后端每个状态码都有专属文案", () => {
+ expect(resolveVerifyError(400, "send")).toBe("errorInvalidEmail");
+ expect(resolveVerifyError(400, "verify")).toBe("errorInvalidCode");
+ expect(resolveVerifyError(409, "verify")).toBe("errorEmailTaken");
+ expect(resolveVerifyError(410, "send")).toBe("errorExpired");
+ expect(resolveVerifyError(410, "verify")).toBe("errorExpired");
+ expect(resolveVerifyError(429, "send")).toBe("errorRateLimited");
+ expect(resolveVerifyError(503, "send")).toBe("errorSendFailed");
+ });
+
+ it("未知状态码落到通用文案", () => {
+ expect(resolveVerifyError(500, "verify")).toBe("errorGeneric");
+ expect(resolveVerifyError(418, "verify")).toBe("errorGeneric");
+ });
+
+ it("解析出的 key 在 zh/en 里都存在", () => {
+ for (const status of [400, 409, 410, 429, 500, 503]) {
+ for (const step of ["send", "verify"] as const) {
+ const key = resolveVerifyError(status, step);
+ expect(zhMessages.verifyEmail, `zh 缺 ${key}`).toHaveProperty(key);
+ expect(enMessages.verifyEmail, `en 缺 ${key}`).toHaveProperty(key);
+ }
+ }
+ });
+
+ it("zh/en 的 verifyEmail key 集合一致", () => {
+ expect(Object.keys(enMessages.verifyEmail).sort()).toEqual(
+ Object.keys(zhMessages.verifyEmail).sort(),
+ );
+ });
+});
From 1c52a1342ba38b53eaf1110b4d789a093d43d2cf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 15:00:36 +0000
Subject: [PATCH 5/8] =?UTF-8?q?feat(login):=20=E7=99=BB=E5=BD=95=E9=A1=B5?=
=?UTF-8?q?=E8=AF=86=E5=88=AB=20account=5Fdisabled=20=E9=94=99=E8=AF=AF?=
=?UTF-8?q?=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端 OAuth 回调遇到被停用的账号会 302 到 /login?error=account_disabled,
此前会落到通用的"请重试",而重试永远不会成功。新增 zh/en 文案提示账号已停用、
联系管理员;映射仍走显式白名单 + Object.hasOwn。
---
app/components/LoginErrorNotice.tsx | 1 +
messages/en.json | 3 ++-
messages/zh.json | 3 ++-
tests/login-error-notice.test.ts | 1 +
4 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/app/components/LoginErrorNotice.tsx b/app/components/LoginErrorNotice.tsx
index 9ac12d09..e855642c 100644
--- a/app/components/LoginErrorNotice.tsx
+++ b/app/components/LoginErrorNotice.tsx
@@ -14,6 +14,7 @@ const ERROR_KEYS: Record = {
oauth_state: "errorOauthState",
oauth_provider: "errorOauthProvider",
oauth_failed: "errorGeneric",
+ account_disabled: "errorAccountDisabled",
};
/** null 表示不显示提示。未知 code 一律落到通用文案,绝不透传原值。 */
diff --git a/messages/en.json b/messages/en.json
index 9efa99cf..535b9d23 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -74,7 +74,8 @@
"errorDiscordCanary": "Discord sign-in is in limited testing and not open yet — stay tuned!",
"errorGeneric": "Sign-in didn't complete. Please try again.",
"errorOauthState": "Your sign-in session expired (took too long on the provider's page, or cookies were blocked). Please start the sign-in again.",
- "errorOauthProvider": "This sign-in method isn't configured on the server — retrying won't help. Please contact an admin."
+ "errorOauthProvider": "This sign-in method isn't configured on the server — retrying won't help. Please contact an admin.",
+ "errorAccountDisabled": "This account has been disabled and can't sign in. Please contact an admin if you think this is a mistake."
},
"verifyEmail": {
"title": "Verify your email",
diff --git a/messages/zh.json b/messages/zh.json
index 73b19914..3d1df92f 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -74,7 +74,8 @@
"errorDiscordCanary": "Discord 登录正在灰度测试中,暂未对外开放,敬请期待~",
"errorGeneric": "登录未完成,请重试。",
"errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。",
- "errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。"
+ "errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。",
+ "errorAccountDisabled": "该账号已被停用,无法登录。如有疑问请联系管理员。"
},
"verifyEmail": {
"title": "验证邮箱",
diff --git a/tests/login-error-notice.test.ts b/tests/login-error-notice.test.ts
index c02ba2ae..a0d88aa7 100644
--- a/tests/login-error-notice.test.ts
+++ b/tests/login-error-notice.test.ts
@@ -10,6 +10,7 @@ const BACKEND_ERROR_CODES = [
"oauth_failed",
"oauth_state",
"oauth_provider",
+ "account_disabled",
];
describe("resolveErrorKey", () => {
From c9ca427c4e789c3cb2f4154f9dc6aeabac3a4dc6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:04:37 +0000
Subject: [PATCH 6/8] =?UTF-8?q?fix(login):=20=E9=AA=8C=E8=AF=81=E9=A1=B5?=
=?UTF-8?q?=20/=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E7=9A=84=20/login=20?=
=?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=B8=A6=E4=B8=8A=E5=BD=93=E5=89=8D=20locale?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
改用 next/link 后 href="/login" 不带 locale 前缀,要靠 middleware 再按 cookie /
Accept-Language 猜一次语言并 307:英文页面上的用户可能被送到 /zh/login。
直接拼 /{locale}/login,和当前页面语言保持一致,也省一次跳转。
---
app/[locale]/login/verify/VerifyEmailForm.tsx | 4 ++--
app/[locale]/settings/LinkedAccounts.tsx | 5 +++--
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx
index 90ac8449..2e5e37a0 100644
--- a/app/[locale]/login/verify/VerifyEmailForm.tsx
+++ b/app/[locale]/login/verify/VerifyEmailForm.tsx
@@ -105,7 +105,7 @@ export function VerifyEmailForm() {
{t(error ?? "errorExpired", vars)}
-
+
{t("backToLogin")}
@@ -181,7 +181,7 @@ export function VerifyEmailForm() {
)}
{t("haveAccount", vars)}{" "}
-
+
{t("backToLogin")}
diff --git a/app/[locale]/settings/LinkedAccounts.tsx b/app/[locale]/settings/LinkedAccounts.tsx
index f1bcd5c0..393dfb3d 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ReactNode, useCallback, useEffect, useState } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
@@ -49,6 +49,7 @@ const BIND_ERROR_KEYS: Record = {
*/
export function LinkedAccounts() {
const t = useTranslations("settings.linked");
+ const locale = useLocale();
const [items, setItems] = useState(null);
const [supported, setSupported] = useState([]);
const [error, setError] = useState(null);
@@ -108,7 +109,7 @@ export function LinkedAccounts() {
res?.status === 401
? t.rich("bindNeedLogin", {
login: (chunks) => (
-
+
{chunks}
),
From 57ae5f24a8491f57f0fb2abdb3f96d2dac6d0ea8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:58:51 +0000
Subject: [PATCH 7/8] =?UTF-8?q?fix(login):=20=E9=82=AE=E7=AE=B1=E9=AA=8C?=
=?UTF-8?q?=E8=AF=81=E8=BF=94=E5=9B=9E=20409=20=E6=97=B6=E8=BF=9B=E5=85=A5?=
=?UTF-8?q?=E7=BB=88=E6=80=81=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=95=99=E4=B8=8B?=
=?UTF-8?q?=E5=BF=85=E7=84=B6=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=8C=89=E9=92=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端在 409(邮箱已被占用)前已消费掉注册会话,之后"验证"/"重发"/"换邮箱"
只会拿到 410,并把"该邮箱已有账号"的提示换成错误的"会话已过期"。409 改为
与 410 一样切到 expired 步骤,保留 errorEmailTaken 文案和返回登录链接。
---
app/[locale]/login/verify/VerifyEmailForm.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx
index 2e5e37a0..0dcfc38b 100644
--- a/app/[locale]/login/verify/VerifyEmailForm.tsx
+++ b/app/[locale]/login/verify/VerifyEmailForm.tsx
@@ -69,7 +69,8 @@ export function VerifyEmailForm() {
).catch(() => null);
if (res?.ok) return res.json();
setBusy(false);
- if (res?.status === 410) setStep("expired");
+ // 409 时后端已消费掉注册会话,和 410 一样是终态
+ if (res?.status === 410 || res?.status === 409) setStep("expired");
setError(res ? resolveVerifyError(res.status, kind) : "errorGeneric");
return null;
}
From e581e03c5bb33a71530116b940749a4c4ee78223 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:58:59 +0000
Subject: [PATCH 8/8] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=20nonce=20?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=9A=8F=E6=9C=BA=E5=80=BC=E5=B9=B6=E7=94=B1?=
=?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=9B=9E=E4=BC=A0=E9=80=90=E6=AC=A1=E6=AF=94?=
=?UTF-8?q?=E5=AF=B9=EF=BC=8C=E8=A2=AB=E6=8B=92=E7=9A=84=20#token=20?=
=?UTF-8?q?=E8=B7=B3=E5=9B=9E=E7=99=BB=E5=BD=95=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
时间戳 nonce 只能证明"本标签页 10 分钟内发起过登录":登录未带回 #token
(报错、新用户验证页、授权页停留)时 nonce 一直有效,持有窗口句柄的打开者
可在此期间把标签页导向 /#token=<攻击者 token> 完成 login CSRF。
- SignInButton 生成 16 字节随机 nonce(base64url)存 sessionStorage,
跳 /oauth/render/{provider}?n=;后端登录回调回跳 #token=…&n=…。
- AuthProvider 只在 n 与存的 nonce 完全一致时接受 token;只要带 #token 就
消费掉已存 nonce,并从 URL 清掉 token 和 n。
- 被拒时 location.replace 到 //login?error=oauth_state,而不是静默
以未登录继续;不取 SSO pending,重新登录成功后照常续接授权请求。
- 新用户验证页验证成功后同样生成 nonce,带 &n= 跳回。
- 测试覆盖:完全一致才接受 / 缺失或不一致拒绝 / 任何一次校验都消费 nonce /
格式与长度 / 被拒后 SSO pending 仍在。
需先部署后端(回传 n),再部署前端。
---
app/[locale]/login/verify/VerifyEmailForm.tsx | 4 +-
app/components/SignInButton.tsx | 4 +-
lib/use-auth.tsx | 44 ++++++++----
tests/login-nonce.test.ts | 71 ++++++++++++-------
4 files changed, 81 insertions(+), 42 deletions(-)
diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx
index 0dcfc38b..3e3db419 100644
--- a/app/[locale]/login/verify/VerifyEmailForm.tsx
+++ b/app/[locale]/login/verify/VerifyEmailForm.tsx
@@ -88,9 +88,9 @@ export function VerifyEmailForm() {
e.preventDefault();
const body = await post("verify", { email, code });
if (!body) return;
- armLoginNonce(sessionStorage);
+ const n = armLoginNonce(sessionStorage);
window.location.replace(
- `/${locale}#token=${encodeURIComponent(body.data.tokenValue)}`,
+ `/${locale}#token=${encodeURIComponent(body.data.tokenValue)}&n=${n}`,
);
}
diff --git a/app/components/SignInButton.tsx b/app/components/SignInButton.tsx
index b5b161b7..3bfe09aa 100644
--- a/app/components/SignInButton.tsx
+++ b/app/components/SignInButton.tsx
@@ -23,8 +23,8 @@ export function SignInButton({
window.location.href = "/login";
return;
}
- armLoginNonce(sessionStorage);
- window.location.href = `/oauth/render/${provider}`;
+ const n = armLoginNonce(sessionStorage);
+ window.location.href = `/oauth/render/${provider}?n=${n}`;
};
return (
diff --git a/lib/use-auth.tsx b/lib/use-auth.tsx
index e92d4e0b..eeed1e6a 100644
--- a/lib/use-auth.tsx
+++ b/lib/use-auth.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
+import { useLocale } from "next-intl";
import { authorizePath, takePending } from "@/lib/sso";
// 对应后端 UserView DTO 的 TypeScript 类型
@@ -69,23 +70,30 @@ function syncTokenCookie(token: string | null) {
}
const LOGIN_NONCE_KEY = "ih:login:nonce";
-const LOGIN_NONCE_TTL_MS = 10 * 60 * 1000;
// #token 只认本标签页自己发起的登录:否则任何人发一个 /#token=<自己的 token> 链接,
// 就能把受害者静默登进攻击者账号,再诱导其在设置页把自己的第三方身份绑过去。
-// 所有跳 /oauth/render/* 或带 #token 回站的地方,跳转前都必须先调这个。
-// ponytail: 后端不回传 nonce,只能校验"本标签页 10 分钟内发起过登录";要逐次比对需后端在回跳里带回它
-export function armLoginNonce(storage: Storage) {
- storage.setItem(LOGIN_NONCE_KEY, String(Date.now()));
+// 所有跳 /oauth/render/* 或带 #token 回站的地方,都要先 arm 并把返回值作为 n 带上;
+// 后端把 /oauth/render 收到的 n 原样回传到 #token=…&n=…。
+// 返回 base64url(16 字节随机数),22 字符,满足后端的 ^[A-Za-z0-9_-]{16,64}$。
+export function armLoginNonce(storage: Storage): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ const nonce = btoa(String.fromCharCode(...bytes))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+ storage.setItem(LOGIN_NONCE_KEY, nonce);
+ return nonce;
}
-// 取出即删;没有、过期、损坏都返回 false
-export function takeLoginNonce(storage: Storage): boolean {
- const raw = storage.getItem(LOGIN_NONCE_KEY);
- if (raw === null) return false;
+// 不论结果都会消费掉已存的 nonce;只有与回传的 n 完全一致才返回 true
+export function takeLoginNonce(
+ storage: Storage,
+ echoed: string | null,
+): boolean {
+ const stored = storage.getItem(LOGIN_NONCE_KEY);
storage.removeItem(LOGIN_NONCE_KEY);
- const age = Date.now() - Number(raw);
- return age >= 0 && age < LOGIN_NONCE_TTL_MS;
+ return !!stored && echoed === stored;
}
// 调用后端 /auth/me 验证 token 并获取用户信息
@@ -107,15 +115,17 @@ async function fetchCurrentUser(token: string): Promise {
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [status, setStatus] = useState("loading");
+ const locale = useLocale();
useEffect(() => {
// 1. 检查 URL fragment 中是否携带 #token=xxx(后端 OAuth 登录成功后跳回来时携带)
// 使用 fragment 而非 query param:fragment 不会出现在服务器日志和 Referer 头中
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const hashToken = hashParams.get("token");
- // 没有本标签页发起登录的 nonce → 丢弃,只清 URL 不存
const urlToken =
- hashToken && takeLoginNonce(sessionStorage) ? hashToken : null;
+ hashToken && takeLoginNonce(sessionStorage, hashParams.get("n"))
+ ? hashToken
+ : null;
if (urlToken) {
// 存入 localStorage + 同步写 cookie(供 api 子域直连场景使用,比如 pgAdmin)
@@ -125,6 +135,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (hashToken) {
// 用 replaceState 清除 URL 中的 fragment,避免刷新或分享时 token 泄露
hashParams.delete("token");
+ hashParams.delete("n");
const newHash = hashParams.toString();
const newUrl =
window.location.pathname +
@@ -132,6 +143,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
(newHash ? "#" + newHash : "");
window.history.replaceState(null, "", newUrl);
}
+ // nonce 对不上:丢弃 token 并让用户重新登录。不 takePending,SSO 请求留到下次登录成功后续接
+ if (hashToken && !urlToken) {
+ window.location.replace(`/${locale}/login?error=oauth_state`);
+ return;
+ }
// 2. 读取存储的 token,调用后端验证并获取用户信息
const token = urlToken ?? getStoredToken();
@@ -157,7 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setStatus("unauthenticated");
}
});
- }, []);
+ }, [locale]);
// 退出登录:清除 token,调用后端注销接口
const logout = async () => {
diff --git a/tests/login-nonce.test.ts b/tests/login-nonce.test.ts
index 1d222599..7d8a9f74 100644
--- a/tests/login-nonce.test.ts
+++ b/tests/login-nonce.test.ts
@@ -1,5 +1,6 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { armLoginNonce, takeLoginNonce } from "@/lib/use-auth";
+import { savePending, takePending } from "@/lib/sso";
function memoryStorage() {
const m = new Map();
@@ -10,42 +11,64 @@ function memoryStorage() {
} as unknown as Storage;
}
-afterEach(() => {
- vi.useRealTimers();
-});
-
describe("login nonce", () => {
- it("本标签页发起过登录 → #token 只接受一次", () => {
+ it("回传的 n 与本标签页存的完全一致 → 接受,且只接受一次", () => {
const s = memoryStorage();
- armLoginNonce(s);
- expect(takeLoginNonce(s)).toBe(true);
- expect(takeLoginNonce(s)).toBe(false);
+ const n = armLoginNonce(s);
+ expect(takeLoginNonce(s, n)).toBe(true);
+ expect(takeLoginNonce(s, n)).toBe(false);
});
it("没发起过登录(别人发来的 /#token= 链接)→ 拒绝", () => {
- expect(takeLoginNonce(memoryStorage())).toBe(false);
+ expect(takeLoginNonce(memoryStorage(), "AAAAAAAAAAAAAAAAAAAAAA")).toBe(
+ false,
+ );
});
- it("超过 10 分钟 → 拒绝,且 nonce 被消费掉", () => {
- vi.useFakeTimers();
+ it("缺 n 或 n 对不上 → 拒绝,且已存的 nonce 被消费掉", () => {
const s = memoryStorage();
- vi.setSystemTime(1_000_000);
armLoginNonce(s);
- vi.setSystemTime(1_000_000 + 10 * 60 * 1000 - 1);
- expect(takeLoginNonce(s)).toBe(true);
+ expect(takeLoginNonce(s, null)).toBe(false);
+ expect(s.getItem("ih:login:nonce")).toBeNull();
- vi.setSystemTime(1_000_000);
- armLoginNonce(s);
- vi.setSystemTime(1_000_000 + 10 * 60 * 1000);
- expect(takeLoginNonce(s)).toBe(false);
+ const n = armLoginNonce(s);
+ expect(takeLoginNonce(s, n + "x")).toBe(false);
expect(s.getItem("ih:login:nonce")).toBeNull();
+ // 攻击者的错 n 已经把 nonce 消费掉,正确的 n 晚到也不再被接受
+ expect(takeLoginNonce(s, n)).toBe(false);
});
- it("损坏或来自未来的值 → 拒绝", () => {
+ it("存的值为空串时,空 n 也不算匹配", () => {
const s = memoryStorage();
- s.setItem("ih:login:nonce", "garbage");
- expect(takeLoginNonce(s)).toBe(false);
- s.setItem("ih:login:nonce", String(Date.now() + 60_000));
- expect(takeLoginNonce(s)).toBe(false);
+ s.setItem("ih:login:nonce", "");
+ expect(takeLoginNonce(s, "")).toBe(false);
+ });
+
+ it("nonce 是 22 位 base64url,满足后端校验,且每次不同", () => {
+ const s = memoryStorage();
+ const a = armLoginNonce(s);
+ const b = armLoginNonce(s);
+ expect(a).toMatch(/^[A-Za-z0-9_-]{22}$/);
+ expect(b).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
+ expect(a).not.toBe(b);
+ expect(s.getItem("ih:login:nonce")).toBe(b);
+ });
+
+ it("token 被拒不影响 SSO pending,重新登录后仍能续接", () => {
+ const s = memoryStorage();
+ const req = {
+ client_id: "holocard",
+ redirect_uri: "https://holocard.example/cb",
+ state: "st",
+ code_challenge: "cc",
+ code_challenge_method: "S256",
+ };
+ savePending(s, req);
+ armLoginNonce(s);
+ expect(takeLoginNonce(s, "forged")).toBe(false);
+
+ const n = armLoginNonce(s);
+ expect(takeLoginNonce(s, n)).toBe(true);
+ expect(takePending(s)).toEqual(req);
});
});