Enforce trading authorization and suitability checks

This commit is contained in:
张胜宇
2026-09-13 19:24:59 +08:00
parent b9aafce120
commit e3c316aafb
7 changed files with 149 additions and 28 deletions
+8 -1
View File
@@ -117,7 +117,10 @@ async function request(endpointId, options = {}) {
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) clearAuthSession();
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId;
document.documentElement.dataset.traceId = responseTraceId;
if (!response.ok || payload.error) {
@@ -178,6 +181,10 @@ async function stream(endpointId, body, options = {}) {
});
if (!response.ok || !response.body) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const detail = payload.error || {};
throw new ApiError(detail.message || '流式请求未完成', {
code: detail.code,
+64 -4
View File
@@ -4,7 +4,9 @@ const SESSION_KEY = 'portalAuthContext';
const IDENTITY_COOKIE = 'portal_auth_user';
const CONTEXT_COOKIE = 'portal_auth_context';
const AUTH_CHANNEL_NAME = 'portal-auth-session';
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched']);
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']);
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const SESSION_CHECK_INTERVAL_MS = 15 * 1000;
let authChannel = null;
let authSyncStarted = false;
@@ -38,13 +40,59 @@ function writeSharedContext(context, maxAge) {
}
function hasMatchingIdentity(token, context) {
const expiresAt = tokenExpiry(token);
return Boolean(
token
&& context
&& readCookie(IDENTITY_COOKIE) === String(context.userId),
&& readCookie(IDENTITY_COOKIE) === String(context.userId)
&& (!expiresAt || Date.now() < expiresAt),
);
}
function tokenExpiry(token) {
try {
const payload = token.split('.')[1];
if (!payload) return 0;
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join('')));
return Number(value.exp || 0) * 1000;
} catch {
return 0;
}
}
function sessionExpired() {
clearAuthSession({ eventType: 'session-expired' });
if (isProtectedPortalPage()) {
window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`);
}
}
function checkSessionLifetime() {
const token = readCookie('auth_token');
const context = readSharedContext() || readSessionContext();
if (!token || !context || !hasMatchingIdentity(token, context)) return;
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
const lastActivityAt = Number(context.lastActivityAt || Date.now());
if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) {
sessionExpired();
}
}
function touchActivity() {
const token = readCookie('auth_token');
const context = readSharedContext() || readSessionContext();
if (!token || !context || !hasMatchingIdentity(token, context)) return;
const now = Date.now();
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
if (expiresAt && now >= expiresAt) { sessionExpired(); return; }
if (now - Number(context.lastActivityAt || 0) < 60_000) return;
const next = { ...context, lastActivityAt: now, expiresAt };
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800;
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
writeSharedContext(next, remaining);
}
function openAuthChannel() {
if (!('BroadcastChannel' in window)) return null;
if (authChannel) return authChannel;
@@ -80,7 +128,8 @@ function redirectForExternalAuthChange(type) {
window.location.replace('/portal/guest/home/?reason=signed-out');
return;
}
window.location.replace(`${loginPathForCurrentPortal()}?reason=account-switched`);
const reason = type === 'session-expired' ? 'session-expired' : 'account-switched';
window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`);
}
function handleExternalAuthChange(type) {
@@ -108,6 +157,11 @@ export function startAuthSync() {
});
window.addEventListener('focus', reconcileSharedSession);
window.addEventListener('pageshow', reconcileSharedSession);
['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => {
window.addEventListener(eventName, touchActivity, { passive: true });
});
window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS);
checkSessionLifetime();
}
export function getAccessToken() {
@@ -130,6 +184,8 @@ export function setAuthSession(loginData, username) {
roles: Array.isArray(loginData.roles) ? loginData.roles : [],
dataScope: loginData.data_scope || 'self',
permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [],
expiresAt: Date.now() + maxAge * 1000,
lastActivityAt: Date.now(),
};
document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`;
writeSharedContext(context, maxAge);
@@ -190,8 +246,12 @@ export function updateAuthPermissions(permissions, dataScope) {
permissions: Array.isArray(permissions) ? [...permissions] : [],
dataScope: dataScope || context.dataScope,
};
const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token')));
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800;
next.expiresAt = expiresAt;
next.lastActivityAt = Date.now();
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
writeSharedContext(next, 1800);
writeSharedContext(next, remaining);
return next;
}
@@ -135,6 +135,10 @@ export function mountShell({ active, mode = 'public' }) {
networkStatus.textContent = detail.message || '请求未完成';
networkStatus.classList.add('network-status--visible');
});
window.addEventListener('portal:auth-expired', () => {
networkStatus.textContent = '登录已过期,请重新登录';
networkStatus.classList.add('network-status--visible');
});
if (new URLSearchParams(window.location.search).get('reason') === 'signed-out') {
networkStatus.textContent = '您已安全退出登录';
networkStatus.classList.add('network-status--visible');
@@ -9,6 +9,7 @@ import {
const REASON_MESSAGES = Object.freeze({
'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。',
'signed-out': '您已安全退出登录。',
'session-expired': '登录已过期或长时间未操作,请重新登录。',
});
export function setupLogin({ mode }) {