feat: harden sessions and track downstream acknowledgements
This commit is contained in:
+190
-4
@@ -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')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user