Files
ctms/frontend/src/session/sessionManager.ts
T
Cheng Zhou 74feca4467 feat: harden auth and study workflows
- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
2026-05-08 22:16:43 +08:00

318 lines
9.4 KiB
TypeScript

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<typeof useSessionStore>) =>
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();
};