From ec0942e1e70191c243936208f04b5a24d0d6082d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 00:02:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(usercenter):=20SSO=20/sso=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=A1=B5(=E6=8B=BF=E7=A5=A8=E5=85=91=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E2=86=92=E8=87=AA=E5=8A=A8=E7=99=BB=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App 打开 app.yanmeiai.com/sso?t=&redirect=<路径>;本页 exchangeWebTicket→setSession (与正常登录同路径)→跳白名单相对路径。镜像 jiu sso.njk。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- web/usercenter/app/sso/page.tsx | 102 +++++++++++++++++++++++++++++++ web/usercenter/lib/api/errors.ts | 1 + web/usercenter/lib/api/http.ts | 15 +++++ web/usercenter/lib/api/mock.ts | 15 +++++ web/usercenter/lib/api/types.ts | 2 + web/usercenter/lib/i18n.ts | 6 ++ 6 files changed, 141 insertions(+) create mode 100644 web/usercenter/app/sso/page.tsx diff --git a/web/usercenter/app/sso/page.tsx b/web/usercenter/app/sso/page.tsx new file mode 100644 index 0000000..61fdb05 --- /dev/null +++ b/web/usercenter/app/sso/page.tsx @@ -0,0 +1,102 @@ +'use client'; +// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。 +// +// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、 +// 单次可用),再打开系统浏览器到 https:///sso/?t=&redirect=<本站路径>。 +// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的 +// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket) +// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。 +// +// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams +// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。 +// +// 安全要点(与 jiu 的 sso.njk 一致): +// - URL 只携带一次性票据,从不携带真正的 access/refresh token; +// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对 +// 路径,其余一律回退到 '/'(登录页),防 open redirect; +// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史; +// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。 +import React, { useEffect, useState } from 'react'; +import { Mark } from '../../components/icons'; +import { ErrorLine } from '../../components/Login'; +import { card } from '../../components/shared'; +import { useUI } from '../../lib/theme'; +import { makeT } from '../../lib/i18n'; +import { getClient } from '../../lib/api/client'; +import { bilingual } from '../../lib/api/errors'; + +/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */ +function safeRedirect(raw: string | null): string { + if (!raw) return '/'; + if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/'; + return raw; +} + +export default function SsoPage() { + const { lang } = useUI(); + const t = makeT(lang); + const [failed, setFailed] = useState(false); + const [msg, setMsg] = useState(''); + + useEffect(() => { + let alive = true; + const params = new URLSearchParams(window.location.search); + const ticket = params.get('t'); + const redirect = safeRedirect(params.get('redirect')); + + // 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。 + try { + window.history.replaceState(null, '', '/sso/'); + } catch { + /* ignore */ + } + + if (!ticket) { + window.location.replace('/'); + return; + } + + const api = getClient(); + api + .exchangeWebTicket(ticket) + .then(() => { + if (!alive) return; + window.location.replace(redirect); + }) + .catch((e) => { + if (!alive) return; + setMsg(bilingual(e, lang)); + setFailed(true); + setTimeout(() => { + window.location.replace('/'); + }, 1500); + }); + + return () => { + alive = false; + }; + // 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+
+ +
+
+ {failed ? t('ssoFailTitle') : t('ssoTitle')} +
+
+ {failed ? t('ssoFailHint') : t('ssoMsg')} +
+ {failed && msg && ( +
+ +
+ )} +
+
+ ); +} diff --git a/web/usercenter/lib/api/errors.ts b/web/usercenter/lib/api/errors.ts index 0337c39..69c1e06 100644 --- a/web/usercenter/lib/api/errors.ts +++ b/web/usercenter/lib/api/errors.ts @@ -18,6 +18,7 @@ export const ERROR_TEXT: Record = { device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' }, device_not_found: { zh: '设备不存在', en: 'Device not found' }, unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' }, + 'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' }, network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' }, unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' }, }; diff --git a/web/usercenter/lib/api/http.ts b/web/usercenter/lib/api/http.ts index 587867d..a730b67 100644 --- a/web/usercenter/lib/api/http.ts +++ b/web/usercenter/lib/api/http.ts @@ -142,6 +142,21 @@ export class HttpClient implements ApiClient { return session; } + // App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。 + // 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支—— + // 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与 + // login()/loginTotp() 落地同一份会话状态。 + async exchangeWebTicket(ticket: string): Promise { + const r = await this.request('/v1/auth/web-ticket/exchange', { + method: 'POST', + body: { ticket }, + auth: false, + }); + const session = mapSession(r); + setSession(session); + return session; + } + async refresh(): Promise { const rt = getRefreshToken(); if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' }); diff --git a/web/usercenter/lib/api/mock.ts b/web/usercenter/lib/api/mock.ts index a7b4eba..c033958 100644 --- a/web/usercenter/lib/api/mock.ts +++ b/web/usercenter/lib/api/mock.ts @@ -64,6 +64,21 @@ export class MockClient implements ApiClient { return s; } + // 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。 + async exchangeWebTicket(ticket: string): Promise { + await delay(300); + if (ticket === 'invalid') { + throw new ApiError({ + code: 'auth.ticket_invalid', + message_zh: '登录票据无效或已过期,请重新从 App 打开', + message_en: 'Login ticket is invalid or expired, please reopen from the app', + }); + } + const s = makeSession(); + setSession(s); + return s; + } + async refresh(): Promise { await delay(150); const s = makeSession(); diff --git a/web/usercenter/lib/api/types.ts b/web/usercenter/lib/api/types.ts index 26654c3..58daa26 100644 --- a/web/usercenter/lib/api/types.ts +++ b/web/usercenter/lib/api/types.ts @@ -82,6 +82,8 @@ export interface ApiClient { login(email: string, password: string): Promise; /** 登录二段式:提交 TOTP 动态码换取 session */ loginTotp(pendingToken: string, code: string): Promise; + /** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */ + exchangeWebTicket(ticket: string): Promise; refresh(): Promise; getMe(): Promise; getSubscription(): Promise; diff --git a/web/usercenter/lib/i18n.ts b/web/usercenter/lib/i18n.ts index ab4cb55..c8cca6c 100644 --- a/web/usercenter/lib/i18n.ts +++ b/web/usercenter/lib/i18n.ts @@ -27,6 +27,12 @@ export const STRINGS: Record = { loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' }, lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' }, + /* sso(App→网页免登录落地页) */ + ssoTitle: { zh: '正在登录…', en: 'Signing in…' }, + ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' }, + ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' }, + ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' }, + /* overview */ greeting: { zh: '欢迎回来', en: 'Welcome back' }, curPlan: { zh: '当前套餐', en: 'Current plan' },