feat: harden sessions and track downstream acknowledgements

This commit is contained in:
hectorzhao
2026-07-14 14:18:43 +08:00
parent 3d37adcc9f
commit 8c03663f24
43 changed files with 1733 additions and 150 deletions
+190 -4
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
ChevronDown,
@@ -12,7 +12,15 @@ import {
} from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { adminApi } from '@/api/adminApi';
import { clearSession } from '@/api/session';
import {
clearSession,
dispatchSessionEvent,
getLastUserActivityAt,
markUserActivity,
readSession,
setReauthenticationHandler,
updateSessionTiming,
} from '@/api/session';
import { Button, Input, Modal } from '@/components/ui';
export type ShellNavItem = {
@@ -64,6 +72,18 @@ export function AppShell({
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
const [locked, setLocked] = useState(false);
const [unlockPassword, setUnlockPassword] = useState('');
const [unlockError, setUnlockError] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [reauthenticationOpen, setReauthenticationOpen] = useState(false);
const [reauthenticationPassword, setReauthenticationPassword] = useState('');
const [reauthenticationError, setReauthenticationError] = useState('');
const [reauthenticating, setReauthenticating] = useState(false);
const reauthenticationResolve = useRef<(() => void) | null>(null);
const reauthenticationReject = useRef<((error: Error) => void) | null>(null);
const lockRequested = useRef(false);
const navigate = useNavigate();
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
const auditTotal = useMemo(
@@ -85,6 +105,7 @@ export function AppShell({
try {
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
clearSession();
dispatchSessionEvent('logout', { message: '密码修改成功,请重新登录' });
navigate(loginPath, { replace: true });
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
@@ -93,6 +114,139 @@ export function AppShell({
}
}
async function logout() {
try {
await adminApi.logout();
} finally {
clearSession();
dispatchSessionEvent('logout');
navigate(loginPath, { replace: true });
}
}
async function continueSession() {
try {
const timing = await adminApi.touchSession();
updateSessionTiming(timing);
markUserActivity();
setIdleWarningSeconds(null);
} catch {
setLocked(true);
}
}
async function unlockSession() {
if (!unlockPassword) {
setUnlockError('请输入当前密码');
return;
}
setUnlocking(true);
setUnlockError('');
try {
const timing = await adminApi.unlockSession(unlockPassword);
updateSessionTiming(timing);
markUserActivity();
setLocked(false);
lockRequested.current = false;
setUnlockPassword('');
setIdleWarningSeconds(null);
dispatchSessionEvent('unlocked');
} catch (error) {
setUnlockError(error instanceof Error ? error.message : '解锁失败');
} finally {
setUnlocking(false);
}
}
async function confirmReauthentication() {
if (!reauthenticationPassword) {
setReauthenticationError('请输入当前密码');
return;
}
setReauthenticating(true);
setReauthenticationError('');
try {
const timing = await adminApi.reauthenticate(reauthenticationPassword);
updateSessionTiming(timing);
reauthenticationResolve.current?.();
reauthenticationResolve.current = null;
reauthenticationReject.current = null;
setReauthenticationOpen(false);
setReauthenticationPassword('');
} catch (error) {
setReauthenticationError(error instanceof Error ? error.message : '身份验证失败');
} finally {
setReauthenticating(false);
}
}
useEffect(() => {
const activityEvents = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
const onActivity = () => markUserActivity();
activityEvents.forEach((eventName) => window.addEventListener(eventName, onActivity, { passive: true }));
const onLocked = () => setLocked(true);
const onUnlocked = () => { setLocked(false); markUserActivity(); };
const onLogout = () => { clearSession(); navigate(loginPath, { replace: true }); };
window.addEventListener('cmpp-session-locked', onLocked);
window.addEventListener('cmpp-session-unlocked', onUnlocked);
window.addEventListener('cmpp-session-logout', onLogout);
let channel: BroadcastChannel | undefined;
try {
channel = new BroadcastChannel('cmpp-session');
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
if (event.data?.type === 'locked') onLocked();
if (event.data?.type === 'unlocked') onUnlocked();
if (event.data?.type === 'logout') onLogout();
};
} catch {
channel = undefined;
}
setReauthenticationHandler(() => new Promise<void>((resolve, reject) => {
reauthenticationResolve.current = resolve;
reauthenticationReject.current = reject;
setReauthenticationPassword('');
setReauthenticationError('');
setReauthenticationOpen(true);
}));
const timer = window.setInterval(() => {
const session = readSession();
if (!session) return;
const now = Date.now();
if (now >= Date.parse(session.absoluteExpiresAt)) {
clearSession();
dispatchSessionEvent('logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
navigate(loginPath, { replace: true });
return;
}
const remaining = session.idleTimeoutSeconds * 1000 - (now - getLastUserActivityAt());
if (remaining <= 0 && !lockRequested.current) {
lockRequested.current = true;
setLocked(true);
setIdleWarningSeconds(null);
void adminApi.lockSession().catch(() => undefined);
} else if (remaining <= 5 * 60 * 1000) {
setIdleWarningSeconds(Math.ceil(remaining / 1000));
} else {
setIdleWarningSeconds(null);
}
}, 1000);
return () => {
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
window.removeEventListener('cmpp-session-locked', onLocked);
window.removeEventListener('cmpp-session-unlocked', onUnlocked);
window.removeEventListener('cmpp-session-logout', onLogout);
channel?.close();
window.clearInterval(timer);
setReauthenticationHandler(undefined);
reauthenticationReject.current?.(new Error('身份验证已取消'));
};
}, [loginPath, navigate]);
useEffect(() => {
if (auditTotal <= 0 || typeof window === 'undefined') {
return;
@@ -228,9 +382,8 @@ export function AppShell({
</button>
<button onClick={() => {
clearSession();
setUserMenuOpen(false);
navigate(loginPath, { replace: true });
void logout();
}} role="menuitem" type="button">
<LogOut size={16} />
退
@@ -258,6 +411,39 @@ export function AppShell({
{passwordError ? <p className="form-error">{passwordError}</p> : null}
</div>
</Modal>
<Modal
footer={<><Button onClick={() => void logout()} variant="ghost">退</Button><Button disabled={unlocking} onClick={() => void unlockSession()}>{unlocking ? '解锁中...' : '解锁'}</Button></>}
onClose={() => undefined}
open={locked}
title="会话已安全锁定"
>
<p>使 4 </p>
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
{unlockError ? <p className="login-error">{unlockError}</p> : null}
</Modal>
<Modal
footer={<><Button onClick={() => setIdleWarningSeconds(null)} variant="ghost"></Button><Button onClick={() => void continueSession()}>使</Button></>}
onClose={() => setIdleWarningSeconds(null)}
open={!locked && idleWarningSeconds !== null}
title="会话即将锁定"
>
<p> {formatCountdown(idleWarningSeconds ?? 0)} 使</p>
</Modal>
<Modal
footer={<><Button onClick={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} variant="ghost"></Button><Button disabled={reauthenticating} onClick={() => void confirmReauthentication()}>{reauthenticating ? '验证中...' : '确认身份'}</Button></>}
onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }}
open={reauthenticationOpen}
title="敏感操作身份验证"
>
<p> 30 </p>
<Input label="当前密码" onChange={(event) => setReauthenticationPassword(event.target.value)} type="password" value={reauthenticationPassword} />
{reauthenticationError ? <p className="login-error">{reauthenticationError}</p> : null}
</Modal>
</div>
);
}
function formatCountdown(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}