【问题①:退出动作在审计里完全不可见】 根因:后端没有 logout 接口。前端「退出」只清本地 localStorage、不产生任何 请求,中间件自然无从采集(中间件里早已预留 "logout" 动作映射,但端点没做)。 - 新增 POST /auth/logout,【仅用于留痕】。JWT 无状态,服务端本就没有可吊销 的会话,该接口不做任何令牌失效动作 —— 它存在的唯一目的,是让审计记下 「谁在何时退出了系统」。这点在 docstring 里写明,免得日后被误当安全边界。 - 挂 Depends(get_current_user),让 JWT 依赖把操作人写进 request.state, 从而记录到真实姓名而非「未认证」 - 前端 logout 改为【先上报、后清 token】。顺序不能反:api.ts 的请求拦截器 是从 localStorage 取 token 的,清掉后就发不出这个请求了。 刻意不 await、失败也不阻断 —— 用户点「退出」必须退得掉。 【问题②:登录记录的「操作人」显示英文账号而非中文名】 根因:登录接口只写了 request.state.audit_user(账号),没写 audit_display_name —— 那一刻还不知道显示名。前端按 display_name || user_id 渲染,于是退化成账号。 - 改为登录成功后补写 display_name / role。可行的原因是中间件在 call_next 返回【之后】才落库,此刻写 request.state 依然能被采集到。 - 失败登录走不到这一步,保持「只有账号可追责」(密码绝不落库),语义不变。
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
import {
|
||
createContext,
|
||
useContext,
|
||
useState,
|
||
useCallback,
|
||
useMemo,
|
||
useEffect,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi";
|
||
|
||
// ============================================================
|
||
// 类型
|
||
// ============================================================
|
||
|
||
export interface UserInfo {
|
||
id: string;
|
||
username: string;
|
||
display_name: string;
|
||
role: string;
|
||
}
|
||
|
||
interface AuthState {
|
||
user: UserInfo | null;
|
||
token: string | null;
|
||
loading: boolean;
|
||
}
|
||
|
||
interface AuthContextValue extends AuthState {
|
||
login: (username: string, password: string) => Promise<void>;
|
||
logout: () => void;
|
||
isAuthenticated: boolean;
|
||
}
|
||
|
||
// ============================================================
|
||
// Token 存储 key
|
||
// ============================================================
|
||
|
||
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||
const USER_KEY = "track_admin_user";
|
||
|
||
// ============================================================
|
||
// Context
|
||
// ============================================================
|
||
|
||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||
|
||
export function useAuth() {
|
||
const ctx = useContext(AuthContext);
|
||
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||
return ctx;
|
||
}
|
||
|
||
// ============================================================
|
||
// Provider
|
||
// ============================================================
|
||
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const [state, setState] = useState<AuthState>({
|
||
user: null,
|
||
token: null,
|
||
loading: true,
|
||
});
|
||
|
||
// 初始化:从 localStorage 恢复双 Token
|
||
useEffect(() => {
|
||
const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||
const savedUser = localStorage.getItem(USER_KEY);
|
||
if (savedToken && savedUser) {
|
||
try {
|
||
const user = JSON.parse(savedUser) as UserInfo;
|
||
setState({ user, token: savedToken, loading: false });
|
||
// 后台静默验证 token 是否仍有效
|
||
getMe(savedToken)
|
||
.then((fresh) => {
|
||
setState((prev) => ({ ...prev, user: fresh }));
|
||
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
|
||
})
|
||
.catch((err) => {
|
||
// 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器
|
||
// 通过 Refresh Token 无感刷新,彻底失败才跳转登录
|
||
console.error(
|
||
"[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):",
|
||
err?.message ?? err,
|
||
);
|
||
});
|
||
} catch {
|
||
logoutInternal();
|
||
}
|
||
} else {
|
||
setState((prev) => ({ ...prev, loading: false }));
|
||
}
|
||
}, []);
|
||
|
||
function logoutInternal() {
|
||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||
localStorage.removeItem(USER_KEY);
|
||
setState({ user: null, token: null, loading: false });
|
||
}
|
||
|
||
const login = useCallback(async (username: string, password: string) => {
|
||
const result = await loginApi(username, password);
|
||
const accessToken = result.access_token;
|
||
const refreshToken = result.refresh_token;
|
||
const user: UserInfo = result.user;
|
||
|
||
// 存储双 Token
|
||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||
setState({ user, token: accessToken, loading: false });
|
||
}, []);
|
||
|
||
const logout = useCallback(() => {
|
||
// 先上报登出(审计留痕),再做本地清理 —— 顺序不能反:
|
||
// api 的请求拦截器是从 localStorage 取 token 的,清掉之后就发不出这个请求了。
|
||
// 刻意不 await、失败也不阻断:JWT 无状态,服务端本就不需要它成功,
|
||
// 而用户点了「退出」必须退得掉,不能因为一次上报失败就卡住。
|
||
logoutApi().catch(() => {});
|
||
logoutInternal();
|
||
}, []);
|
||
|
||
const ctxValue = useMemo<AuthContextValue>(
|
||
() => ({
|
||
...state,
|
||
login,
|
||
logout,
|
||
isAuthenticated: !!state.token && !!state.user,
|
||
}),
|
||
[state, login, logout],
|
||
);
|
||
|
||
return (
|
||
<AuthContext.Provider value={ctxValue}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
}
|