Files
pangolin/web/usercenter/app/sso/page.tsx
T
wangjia ec0942e1e7
Deploy Server / deploy-server (push) Successful in 2m47s
feat(usercenter): SSO /sso 落地页(拿票兑会话→自动登录)
App 打开 app.yanmeiai.com/sso?t=<ticket>&redirect=<路径>;本页 exchangeWebTicket→setSession
(与正常登录同路径)→跳白名单相对路径。镜像 jiu sso.njk。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:02:48 +08:00

103 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
//
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
<Mark size={32} />
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
{failed ? t('ssoFailTitle') : t('ssoTitle')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
{failed ? t('ssoFailHint') : t('ssoMsg')}
</div>
{failed && msg && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<ErrorLine text={msg} />
</div>
)}
</div>
</div>
);
}