import router from "../router"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; import { getToken, setToken, clearToken } from "../utils/auth"; import { extendToken, getLoginKey, unlockSession } from "../api/authClient"; import { encryptLoginPayload } from "../utils/loginCrypto"; import { parseJwtExp } from "./jwt"; export const IDLE_TIMEOUT_MINUTES = 30; export const AUTO_LOGOUT_TIMEOUT_HOURS = 2; export const EXTEND_EARLY_SECONDS = 120; export const EXTEND_MIN_INTERVAL_SECONDS = 60; export const UNLOCK_MAX_ATTEMPTS = 5; export const LOGOUT_REASON_TIMEOUT = "timeout"; const LOGOUT_REASON_STORAGE_KEY = "ctms_logout_reason"; type BroadcastMessage = | { type: "ACTIVE"; at: number } | { type: "NETWORK"; at: number } | { type: "LOCK"; reason: string; email?: string; deadlineAt?: number } | { type: "TOKEN_UPDATED"; token: string } | { type: "LOGOUT"; reason?: string }; let idleTimer: number | null = null; let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null; let initialized = false; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; const getLastActiveAt = (session: ReturnType) => Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt); const reconcileSessionState = (now: number = Date.now()) => { const session = useSessionStore(); if (session.locked) return false; const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; const lastActiveAt = getLastActiveAt(session); if (now - lastActiveAt > autoLogoutMs) { forceLogout(LOGOUT_REASON_TIMEOUT); return false; } if (now - lastActiveAt > idleTimeoutMs) { lockSession("idle"); return false; } return true; }; const broadcast = (message: BroadcastMessage) => { if (channel) { channel.postMessage(message); } try { localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() })); } catch { /* ignore */ } }; const handleBroadcast = (message: BroadcastMessage) => { const session = useSessionStore(); const auth = useAuthStore(); if (message.type === "ACTIVE") { if (session.locked) return; session.recordUserActivity(message.at); scheduleIdleCheck(); } if (message.type === "NETWORK") { if (session.locked) return; session.recordNetworkActivity(message.at); scheduleIdleCheck(); } if (message.type === "LOCK") { const reason = message.reason as "idle" | "extend_failed" | "token_invalid"; session.lock(reason || "idle", message.email, message.deadlineAt); scheduleIdleCheck(); } if (message.type === "TOKEN_UPDATED") { auth.setToken(message.token); setToken(message.token); session.unlock(); } if (message.type === "LOGOUT") { performLogout(message.reason); } }; const scheduleIdleCheck = () => { if (idleTimer) { window.clearTimeout(idleTimer); } const session = useSessionStore(); const now = Date.now(); if (session.locked) { const remain = (session.lockAutoLogoutDeadlineAt || 0) - now; if (remain <= 0) { checkIdle(); return; } idleTimer = window.setTimeout(checkIdle, remain); return; } const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; const lastActiveAt = getLastActiveAt(session); const idleRemain = lastActiveAt + idleTimeoutMs - now; const logoutRemain = lastActiveAt + autoLogoutMs - now; const nextCheck = Math.min(idleRemain, logoutRemain); if (nextCheck <= 0) { checkIdle(); return; } idleTimer = window.setTimeout(checkIdle, nextCheck); }; const checkIdle = () => { const session = useSessionStore(); if (session.locked) { const deadlineAt = session.lockAutoLogoutDeadlineAt || 0; if (deadlineAt > 0 && Date.now() >= deadlineAt) { forceLogout(LOGOUT_REASON_TIMEOUT); return; } scheduleIdleCheck(); return; } if (!reconcileSessionState(Date.now())) return; scheduleIdleCheck(); }; export const initSessionManager = () => { if (initialized) return; initialized = true; const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"]; let lastSignal = 0; const onUserActivity = () => { const now = Date.now(); if (now - lastSignal < 1000) return; lastSignal = now; markUserActive(now); }; events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true })); window.addEventListener("focus", () => { checkIdle(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { checkIdle(); } }); if (channel) { channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage); } window.addEventListener("storage", (event) => { if (event.key !== "ctms_auth_broadcast" || !event.newValue) return; try { const message = JSON.parse(event.newValue) as BroadcastMessage; handleBroadcast(message); } catch { /* ignore */ } }); scheduleIdleCheck(); startTokenKeepAlive(); }; export const markUserActive = (at: number = Date.now()) => { const session = useSessionStore(); if (session.locked) return; if (!reconcileSessionState(at)) return; session.recordUserActivity(at); broadcast({ type: "ACTIVE", at }); scheduleIdleCheck(); }; export const markNetworkActive = (at: number = Date.now()) => { const session = useSessionStore(); if (session.locked) return; if (!reconcileSessionState(at)) return; session.recordNetworkActivity(at); broadcast({ type: "NETWORK", at }); scheduleIdleCheck(); }; export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => { const session = useSessionStore(); const auth = useAuthStore(); if (session.locked) return; const email = auth.user?.email || ""; const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; const deadlineAt = getLastActiveAt(session) + autoLogoutMs; session.lock(reason, email, deadlineAt); broadcast({ type: "LOCK", reason, email, deadlineAt }); scheduleIdleCheck(); }; const setLogoutReason = (reason?: string) => { try { if (!reason) { sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY); return; } sessionStorage.setItem(LOGOUT_REASON_STORAGE_KEY, reason); } catch { /* ignore */ } }; export const consumeLogoutReason = () => { try { const reason = sessionStorage.getItem(LOGOUT_REASON_STORAGE_KEY); sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY); return reason; } catch { return null; } }; const performLogout = (reason?: string) => { setLogoutReason(reason); const auth = useAuthStore(); const session = useSessionStore(); auth.logout(); session.unlock(); clearToken(); router.replace("/login"); }; export const forceLogout = (reason?: string) => { performLogout(reason); broadcast({ type: "LOGOUT", reason }); }; const updateToken = (token: string) => { const auth = useAuthStore(); auth.setToken(token); setToken(token); broadcast({ type: "TOKEN_UPDATED", token }); }; export const extendAccessToken = async (reason: "early" | "response-401") => { const session = useSessionStore(); const token = getToken(); if (!token) return { token: null, authFailed: false }; if (reason === "early") { const now = Date.now(); if (now - session.lastExtendAt < EXTEND_MIN_INTERVAL_SECONDS * 1000) { return { token: null, authFailed: false }; } } if (extendPromise) return extendPromise; extendPromise = (async () => { let authFailed = false; try { const { data } = await extendToken(token); session.setLastExtendAt(Date.now()); updateToken(data.accessToken); return { token: data.accessToken, authFailed: false }; } catch (err: any) { const status = err?.response?.status; if (status === 401) { lockSession("extend_failed"); authFailed = true; } else if (status === 403) { forceLogout(); authFailed = true; } return { token: null, authFailed }; } finally { extendPromise = null; } })(); return extendPromise; }; export const startTokenKeepAlive = () => { window.setInterval(() => { const token = getToken(); if (!token) return; const session = useSessionStore(); if (session.locked) return; const expAt = parseJwtExp(token); if (!expAt) return; const remaining = expAt - Date.now(); const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const active = Date.now() - getLastActiveAt(session) < timeoutMs; if (active && remaining < EXTEND_EARLY_SECONDS * 1000) { void extendAccessToken("early"); } }, 10000); }; export const unlockWithPassword = async (email: string, password: string) => { const session = useSessionStore(); const auth = useAuthStore(); const { data: loginKey } = await getLoginKey(); const ciphertext = await encryptLoginPayload(loginKey.public_key, { email, password, challenge: loginKey.challenge, }); const { data } = await unlockSession({ key_id: loginKey.key_id, challenge: loginKey.challenge, ciphertext, }); updateToken(data.accessToken); session.unlock(); try { await auth.fetchMe(); } catch { // token已更新但用户信息拉取失败时,避免界面进入无角色状态 forceLogout(); throw new Error("无法获取用户信息,请重新登录"); } markUserActive(); };