ec0942e1e7
Deploy Server / deploy-server (push) Successful in 2m47s
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
103 lines
4.0 KiB
TypeScript
103 lines
4.0 KiB
TypeScript
'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>
|
||
);
|
||
}
|