Skip to content

Commit 63da533

Browse files
longsizhuoclaude
andauthored
feat(sso): IH 通行证授权页 /sso/authorize,让 HoloCard 用 IH 账号登录 (#402)
* feat(sso): IH 通行证授权页 /sso/authorize,让 HoloCard 用 IH 账号登录 - 新页面 app/[locale]/sso/authorize:读 5 个 query 参数,缺参、method 不是 S256、 client_id 不在常量表都直接报错、不跳转;未登录把请求连同 savedAt 存进 sessionStorage["ih:sso:pending"] 再去 /login;已登录显示「以 <头像> <名字> 登录 HoloCard」,「继续」带 satoken POST /oauth/sso/code,只按后端返回的 data.redirect 跳转,失败显示错误;「换个账号」logout 后同样存 pending 去 /login。页面 noindex, 壳照常 SSG(build 表 ● /[locale]/sso/authorize,其余路由不变) - AuthProvider:/auth/me 成功后若有 10 分钟内的 pending,删掉并跳回 /sso/authorize?<原参数>,GitHub / Discord / 以后的登录方式都能接上,OAuth 回调不用动 - 授权页加载时先清掉残留的 pending,以 URL 上的请求为准,避免旧请求把新请求换掉 - 参数校验、pending 存取、调接口抽到 lib/sso.ts;vitest 覆盖参数校验、pending 存取与过期、跳转只用 data.redirect;proxy matcher 测试加上 /sso/authorize Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF * feat(sso): 授权页禁止被嵌进 iframe;认 HoloCard staging 的 client - /:locale/sso/* 加 Content-Security-Policy: frame-ancestors 'none' 和 X-Frame-Options: DENY, 防点击劫持骗已登录的访客点「继续」(实测只作用于授权页,其它页面响应头不变) - client 表加 holocard-staging(后端 INV-010 里单独的 client,secret 和回跳地址都和线上分开) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3eac782 commit 63da533

9 files changed

Lines changed: 471 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { useSearchParams } from "next/navigation";
5+
import { useTranslations } from "next-intl";
6+
import { useRouter } from "@/i18n/navigation";
7+
import { useAuth } from "@/lib/use-auth";
8+
import { Button } from "@/app/components/ui/button";
9+
import {
10+
Avatar,
11+
AvatarFallback,
12+
AvatarImage,
13+
} from "@/app/components/ui/avatar";
14+
import {
15+
SSO_CLIENTS,
16+
SSO_PENDING_KEY,
17+
parseSsoRequest,
18+
requestSsoRedirect,
19+
savePending,
20+
} from "@/lib/sso";
21+
22+
const ALERT_CLASS =
23+
"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";
24+
25+
export function SsoAuthorize() {
26+
const t = useTranslations("sso");
27+
const searchParams = useSearchParams();
28+
const req = useMemo(
29+
() => parseSsoRequest(Object.fromEntries(searchParams)),
30+
[searchParams],
31+
);
32+
const { user, status, logout } = useAuth();
33+
const router = useRouter();
34+
const [busy, setBusy] = useState(false);
35+
const [failed, setFailed] = useState(false);
36+
37+
// 以 URL 上的请求为准。不清掉残留的 pending,AuthProvider 确认登录后会拿它把本页换成旧请求
38+
useEffect(() => {
39+
sessionStorage.removeItem(SSO_PENDING_KEY);
40+
}, []);
41+
42+
// 未登录、或「换个账号」logout 之后:存 pending 去登录,AuthProvider 登录完成后带回本页
43+
useEffect(() => {
44+
if (req && status === "unauthenticated") {
45+
savePending(sessionStorage, req);
46+
router.replace("/login");
47+
}
48+
}, [req, status, router]);
49+
50+
if (!req) {
51+
return (
52+
<div role="alert" className={ALERT_CLASS}>
53+
{t("invalid")}
54+
</div>
55+
);
56+
}
57+
if (status !== "authenticated" || !user) {
58+
return (
59+
<div className="mx-auto size-12 rounded-full bg-muted animate-pulse" />
60+
);
61+
}
62+
63+
const onContinue = async () => {
64+
setBusy(true);
65+
setFailed(false);
66+
const redirect = await requestSsoRedirect(
67+
req,
68+
localStorage.getItem("satoken") ?? "",
69+
);
70+
if (redirect) {
71+
window.location.replace(redirect);
72+
return;
73+
}
74+
setFailed(true);
75+
setBusy(false);
76+
};
77+
78+
const name = user.displayName || user.username;
79+
return (
80+
<div className="space-y-6">
81+
<p className="text-lg">
82+
{t.rich("signInAs", {
83+
client: SSO_CLIENTS[req.client_id],
84+
name,
85+
user: (chunks) => (
86+
<span className="inline-flex items-center gap-2 align-middle font-semibold">
87+
<Avatar className="size-8">
88+
{user.avatarUrl ? (
89+
<AvatarImage src={user.avatarUrl} alt="" />
90+
) : (
91+
<AvatarFallback>{name[0]}</AvatarFallback>
92+
)}
93+
</Avatar>
94+
{chunks}
95+
</span>
96+
),
97+
})}
98+
</p>
99+
{failed && (
100+
<div role="alert" className={ALERT_CLASS}>
101+
{t("failed")}
102+
</div>
103+
)}
104+
<div className="flex flex-col items-center gap-3">
105+
<Button
106+
className="w-48 bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000]"
107+
onClick={onContinue}
108+
disabled={busy}
109+
>
110+
{t("continue")}
111+
</Button>
112+
<Button
113+
className="w-48"
114+
variant="outline"
115+
disabled={busy}
116+
onClick={() => {
117+
setBusy(true);
118+
logout();
119+
}}
120+
>
121+
{t("switchAccount")}
122+
</Button>
123+
</div>
124+
</div>
125+
);
126+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Metadata } from "next";
2+
import { Suspense } from "react";
3+
import { setRequestLocale, getTranslations } from "next-intl/server";
4+
import { hasLocale } from "next-intl";
5+
import { notFound } from "next/navigation";
6+
import { routing } from "@/i18n/routing";
7+
import { SsoAuthorize } from "./SsoAuthorize";
8+
9+
export const metadata: Metadata = {
10+
title: "Authorize",
11+
robots: { index: false, follow: false },
12+
};
13+
14+
interface Props {
15+
params: Promise<{ locale: string }>;
16+
}
17+
18+
// 内容取决于 query 和登录态,全在客户端;页面壳照常 SSG
19+
export default async function SsoAuthorizePage({ params }: Props) {
20+
const { locale } = await params;
21+
if (!hasLocale(routing.locales, locale)) notFound();
22+
setRequestLocale(locale);
23+
24+
const t = await getTranslations("sso");
25+
return (
26+
<div className="min-h-screen flex items-center justify-center bg-background">
27+
<div className="w-full max-w-md space-y-8 p-8 text-center">
28+
<h1 className="text-3xl font-bold">{t("heading")}</h1>
29+
<Suspense fallback={null}>
30+
<SsoAuthorize />
31+
</Suspense>
32+
</div>
33+
</div>
34+
);
35+
}
36+
37+
export function generateStaticParams() {
38+
return routing.locales.map((locale) => ({ locale }));
39+
}

‎lib/sso.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// 授权页上显示的站点名。和后端 sso.clients 表对应(后端还会再校验 client 和回跳地址)
2+
export const SSO_CLIENTS: Record<string, string> = {
3+
holocard: "HoloCard",
4+
"holocard-staging": "HoloCard(staging)",
5+
};
6+
7+
export const SSO_PENDING_KEY = "ih:sso:pending";
8+
const PENDING_TTL_MS = 10 * 60 * 1000;
9+
10+
const SSO_PARAMS = [
11+
"client_id",
12+
"redirect_uri",
13+
"state",
14+
"code_challenge",
15+
"code_challenge_method",
16+
] as const;
17+
18+
export type SsoRequest = Record<(typeof SSO_PARAMS)[number], string>;
19+
20+
// client 表必须用 Object.hasOwn 查:?client_id=__proto__ / constructor 会命中原型链
21+
export function parseSsoRequest(
22+
source: Record<string, unknown>,
23+
): SsoRequest | null {
24+
const req = {} as SsoRequest;
25+
for (const key of SSO_PARAMS) {
26+
const value = source[key];
27+
if (typeof value !== "string" || !value) return null;
28+
req[key] = value;
29+
}
30+
if (req.code_challenge_method !== "S256") return null;
31+
if (!Object.hasOwn(SSO_CLIENTS, req.client_id)) return null;
32+
return req;
33+
}
34+
35+
export function savePending(storage: Storage, req: SsoRequest) {
36+
storage.setItem(
37+
SSO_PENDING_KEY,
38+
JSON.stringify({ ...req, savedAt: Date.now() }),
39+
);
40+
}
41+
42+
// 取出即删;过期、损坏、校验不过都按没有处理
43+
export function takePending(storage: Storage): SsoRequest | null {
44+
const raw = storage.getItem(SSO_PENDING_KEY);
45+
if (raw === null) return null;
46+
storage.removeItem(SSO_PENDING_KEY);
47+
try {
48+
const pending = JSON.parse(raw);
49+
const age = Date.now() - pending.savedAt;
50+
return age >= 0 && age < PENDING_TTL_MS ? parseSsoRequest(pending) : null;
51+
} catch {
52+
return null;
53+
}
54+
}
55+
56+
export function authorizePath(req: SsoRequest): string {
57+
return `/sso/authorize?${new URLSearchParams(req)}`;
58+
}
59+
60+
// 跳转目标只认后端返回的 data.redirect(redirect_uri 由后端按登记值逐字校验)。
61+
// 失败一律返回 null,调用方不得退回去用 query 里的 redirect_uri。
62+
export async function requestSsoRedirect(
63+
req: SsoRequest,
64+
token: string,
65+
): Promise<string | null> {
66+
try {
67+
const res = await fetch("/oauth/sso/code", {
68+
method: "POST",
69+
headers: { "Content-Type": "application/json", satoken: token },
70+
body: JSON.stringify({
71+
clientId: req.client_id,
72+
redirectUri: req.redirect_uri,
73+
state: req.state,
74+
codeChallenge: req.code_challenge,
75+
codeChallengeMethod: req.code_challenge_method,
76+
}),
77+
});
78+
const body = await res.json();
79+
const redirect = body?.data?.redirect;
80+
return res.ok &&
81+
body.success === true &&
82+
typeof redirect === "string" &&
83+
redirect
84+
? redirect
85+
: null;
86+
} catch {
87+
return null;
88+
}
89+
}

‎lib/use-auth.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import React, { createContext, useContext, useEffect, useState } from "react";
4+
import { authorizePath, takePending } from "@/lib/sso";
45

56
// 对应后端 UserView DTO 的 TypeScript 类型
67
// 字段与 involutionhell-backend/.../dto/UserView.java 保持一致
@@ -121,6 +122,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
121122
setStatus("authenticated");
122123
// 已登录用户每次刷新也重写 cookie,覆盖掉可能过期 / 丢失的副本
123124
syncTokenCookie(token);
125+
// 登录前在 /sso/authorize 存下的请求:不管走哪种登录方式回来,都在这里接上
126+
const pending = takePending(sessionStorage);
127+
if (pending) window.location.replace(authorizePath(pending));
124128
} else {
125129
// token 无效或已过期,localStorage + cookie 都清
126130
localStorage.removeItem("satoken");

‎messages/en.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,5 +510,13 @@
510510
},
511511
"shareLink": {
512512
"button": "Share a Link"
513+
},
514+
"sso": {
515+
"heading": "IH Pass",
516+
"signInAs": "Sign in to {client} as <user>{name}</user>",
517+
"continue": "Continue",
518+
"switchAccount": "Use a different account",
519+
"invalid": "This sign-in request is invalid. Please go back to the site and start signing in again.",
520+
"failed": "Authorization failed. Try again, or go back to the site and start signing in again."
513521
}
514522
}

‎messages/zh.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,5 +510,13 @@
510510
},
511511
"shareLink": {
512512
"button": "丢个链接"
513+
},
514+
"sso": {
515+
"heading": "IH 通行证",
516+
"signInAs": "以 <user>{name}</user> 登录 {client}",
517+
"continue": "继续",
518+
"switchAccount": "换个账号",
519+
"invalid": "登录请求无效。请回到原网站重新发起登录。",
520+
"failed": "授权失败。请重试,仍然不行就回到原网站重新发起登录。"
513521
}
514522
}

‎next.config.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,19 @@ const config = {
353353
// / NEXT_LOCALE 协商(307,单跳到 /zh 或 /en)才是对的。
354354
];
355355
},
356+
async headers() {
357+
return [
358+
{
359+
// IH 通行证授权页不许被别的网站嵌进 iframe:防点击劫持骗已登录的访客点「继续」,
360+
// 替攻击者发起的登录签出授权码(见 lib/sso.ts、后端 SECURITY.md 的 INV-010)
361+
source: "/:locale/sso/:path*",
362+
headers: [
363+
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
364+
{ key: "X-Frame-Options", value: "DENY" },
365+
],
366+
},
367+
];
368+
},
356369
async rewrites() {
357370
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080";
358371
return [

‎tests/proxy-guard.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ describe("matcher:docs 带点路径进 middleware,其余排除规则不回
5454
'/zh/docs/learn/cs/dev-tips/你的图片.jpg "自定义鼠标悬停显示名"',
5555
// leetcode 带点中文旧 URL:一直要能进 middleware 吃 slug-map 301
5656
"/docs/CommunityShare/Leetcode/46.全排列",
57+
// 外站(HoloCard)跳来的地址不带 locale,要靠 next-intl 补前缀,否则 404
58+
"/sso/authorize",
5759
])("进 middleware: %s", (p) => {
5860
expect(re.test(p)).toBe(true);
5961
});

0 commit comments

Comments
 (0)