diff --git a/app/[locale]/login/verify/VerifyEmailForm.tsx b/app/[locale]/login/verify/VerifyEmailForm.tsx
new file mode 100644
index 00000000..3e3db419
--- /dev/null
+++ b/app/[locale]/login/verify/VerifyEmailForm.tsx
@@ -0,0 +1,191 @@
+"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);
+ // 409 时后端已消费掉注册会话,和 410 一样是终态
+ if (res?.status === 410 || res?.status === 409) 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;
+ const n = armLoginNonce(sessionStorage);
+ window.location.replace(
+ `/${locale}#token=${encodeURIComponent(body.data.tokenValue)}&n=${n}`,
+ );
+ }
+
+ 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 1558bc60..393dfb3d 100644
--- a/app/[locale]/settings/LinkedAccounts.tsx
+++ b/app/[locale]/settings/LinkedAccounts.tsx
@@ -1,7 +1,9 @@
"use client";
-import { useEffect, useState } from "react";
-import { useTranslations } from "next-intl";
+import { type ReactNode, useCallback, useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { useSearchParams } from "next/navigation";
+import Link from "next/link";
type LinkedIdentity = {
provider: string;
@@ -15,41 +17,106 @@ function getToken(): string | null {
return localStorage.getItem("satoken");
}
+// 只是展示名。后端 /identities/providers 才是"有哪些登录方式"的真相源,
+// 这里查不到就回退显示原始 key,保证新接入的 provider 不会静默消失。
const PROVIDER_LABEL: Record = {
github: "GitHub",
discord: "Discord",
};
+export 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 locale = useLocale();
const [items, setItems] = useState(null);
- const [error, setError] = useState(null);
+ const [supported, setSupported] = useState([]);
+ const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
+ const params = useSearchParams();
- async function load() {
+ // 回调结果直接从 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;
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]);
+
+ // 必须 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;
+ }
+ setBusy(null);
+ setError(
+ res?.status === 401
+ ? t.rich("bindNeedLogin", {
+ login: (chunks) => (
+
+ {chunks}
+
+ ),
+ })
+ : (body?.message ?? t("bindFailed")),
+ );
+ }
async function unbind(provider: string) {
const token = getToken();
@@ -77,36 +144,63 @@ 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 (
+ {bindOk && (
+
+ {t("bindOk")}
+
+ )}
+ {bindError && (
+ {bindError}
+ )}
{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)}
+
+
+
+ ))}
+ {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/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/app/components/SignInButton.tsx b/app/components/SignInButton.tsx
index 64bf3bd9..3bfe09aa 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;
+ }
+ 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 81342321..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 类型
@@ -68,6 +69,33 @@ function syncTokenCookie(token: string | null) {
}
}
+const LOGIN_NONCE_KEY = "ih:login:nonce";
+
+// #token 只认本标签页自己发起的登录:否则任何人发一个 /#token=<自己的 token> 链接,
+// 就能把受害者静默登进攻击者账号,再诱导其在设置页把自己的第三方身份绑过去。
+// 所有跳 /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;
+}
+
+// 不论结果都会消费掉已存的 nonce;只有与回传的 n 完全一致才返回 true
+export function takeLoginNonce(
+ storage: Storage,
+ echoed: string | null,
+): boolean {
+ const stored = storage.getItem(LOGIN_NONCE_KEY);
+ storage.removeItem(LOGIN_NONCE_KEY);
+ return !!stored && echoed === stored;
+}
+
// 调用后端 /auth/me 验证 token 并获取用户信息
// 走 Next.js rewrite(/auth/* → 后端),浏览器无跨域问题
async function fetchCurrentUser(token: string): Promise {
@@ -87,19 +115,27 @@ 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 urlToken = hashParams.get("token");
+ const hashToken = hashParams.get("token");
+ const urlToken =
+ hashToken && takeLoginNonce(sessionStorage, hashParams.get("n"))
+ ? hashToken
+ : null;
if (urlToken) {
// 存入 localStorage + 同步写 cookie(供 api 子域直连场景使用,比如 pgAdmin)
localStorage.setItem("satoken", urlToken);
syncTokenCookie(urlToken);
+ }
+ if (hashToken) {
// 用 replaceState 清除 URL 中的 fragment,避免刷新或分享时 token 泄露
hashParams.delete("token");
+ hashParams.delete("n");
const newHash = hashParams.toString();
const newUrl =
window.location.pathname +
@@ -107,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();
@@ -132,7 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setStatus("unauthenticated");
}
});
- }, []);
+ }, [locale]);
// 退出登录:清除 token,调用后端注销接口
const logout = async () => {
diff --git a/messages/en.json b/messages/en.json
index e23e6963..535b9d23 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -74,7 +74,28 @@
"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",
+ "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": {
@@ -106,7 +127,15 @@
"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 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.",
+ "bindNeedLogin": "Your session has expired. Sign in again, then connect."
}
},
"signIn": {
diff --git a/messages/zh.json b/messages/zh.json
index f3b3997b..3d1df92f 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -74,7 +74,28 @@
"errorDiscordCanary": "Discord 登录正在灰度测试中,暂未对外开放,敬请期待~",
"errorGeneric": "登录未完成,请重试。",
"errorOauthState": "登录会话已失效(可能是等待授权太久,或浏览器拦截了 Cookie)。请重新点击登录。",
- "errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。"
+ "errorOauthProvider": "该登录方式服务端未配置好,重试也无法解决。请联系管理员。",
+ "errorAccountDisabled": "该账号已被停用,无法登录。如有疑问请联系管理员。"
+ },
+ "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": {
@@ -106,7 +127,15 @@
"empty": "暂无绑定的第三方登录",
"loadFail": "加载登录方式失败",
"unbindFail": "解绑失败",
- "lastHint": "这是你唯一的登录方式,不能解绑"
+ "lastHint": "这是你唯一的登录方式,不能解绑",
+ "connect": "连接",
+ "bindOk": "绑定成功",
+ "bindTaken": "这个登录方式已经属于另一个账号了。如需把两个账号合并,请联系管理员。",
+ "bindDuplicate": "你已经绑定过这种登录方式了,一个账号同一登录方式只能绑一个。",
+ "bindAlreadyYours": "这个登录方式已经在你的账号上了,无需重复绑定。",
+ "bindSession": "绑定过程中登录状态发生了变化,请重新点一次连接。",
+ "bindFailed": "绑定失败,请重试。",
+ "bindNeedLogin": "登录已过期,请重新登录后再连接。"
}
},
"signIn": {
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", () => {
diff --git a/tests/login-nonce.test.ts b/tests/login-nonce.test.ts
new file mode 100644
index 00000000..7d8a9f74
--- /dev/null
+++ b/tests/login-nonce.test.ts
@@ -0,0 +1,74 @@
+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();
+ 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;
+}
+
+describe("login nonce", () => {
+ it("回传的 n 与本标签页存的完全一致 → 接受,且只接受一次", () => {
+ const s = memoryStorage();
+ const n = armLoginNonce(s);
+ expect(takeLoginNonce(s, n)).toBe(true);
+ expect(takeLoginNonce(s, n)).toBe(false);
+ });
+
+ it("没发起过登录(别人发来的 /#token= 链接)→ 拒绝", () => {
+ expect(takeLoginNonce(memoryStorage(), "AAAAAAAAAAAAAAAAAAAAAA")).toBe(
+ false,
+ );
+ });
+
+ it("缺 n 或 n 对不上 → 拒绝,且已存的 nonce 被消费掉", () => {
+ const s = memoryStorage();
+ armLoginNonce(s);
+ expect(takeLoginNonce(s, null)).toBe(false);
+ expect(s.getItem("ih:login:nonce")).toBeNull();
+
+ 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("存的值为空串时,空 n 也不算匹配", () => {
+ const s = memoryStorage();
+ 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);
+ });
+});
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(),
+ );
+ });
+});