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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
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' },
|
||||
};
|
||||
|
||||
@@ -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<Session> {
|
||||
const r = await this.request<RawTokenPair>('/v1/auth/web-ticket/exchange', {
|
||||
method: 'POST',
|
||||
body: { ticket },
|
||||
auth: false,
|
||||
});
|
||||
const session = mapSession(r);
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
|
||||
@@ -64,6 +64,21 @@ export class MockClient implements ApiClient {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
|
||||
async exchangeWebTicket(ticket: string): Promise<Session> {
|
||||
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<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ApiClient {
|
||||
login(email: string, password: string): Promise<LoginResult>;
|
||||
/** 登录二段式:提交 TOTP 动态码换取 session */
|
||||
loginTotp(pendingToken: string, code: string): Promise<Session>;
|
||||
/** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */
|
||||
exchangeWebTicket(ticket: string): Promise<Session>;
|
||||
refresh(): Promise<Session>;
|
||||
getMe(): Promise<Me>;
|
||||
getSubscription(): Promise<SubscriptionInfo>;
|
||||
|
||||
@@ -27,6 +27,12 @@ export const STRINGS: Record<string, Entry> = {
|
||||
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' },
|
||||
|
||||
Reference in New Issue
Block a user