'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 && (
)}
); }