feat: 前端认证网关 + API 自动附加 JWT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- AuthProvider 管理 jumpToken 交换和 JWT 生命周期 - 未授权页面(ShieldX 图标 + 提示文字) - 加载中旋转动画 - fetchJson 全局客户端自动附加 Authorization header - 401 响应触发重新认证 - JWT 存 sessionStorage,刷新不丢失 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
src/auth/AuthProvider.tsx
Normal file
118
src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { AuthContext, type AuthState } from './useAuth';
|
||||
import { setTokenGetter } from './api-client';
|
||||
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
error: null,
|
||||
});
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 设置全局 token getter
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
tokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
// 1. 检查 sessionStorage 中是否有 JWT
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
}
|
||||
|
||||
// 2. 从 URL 提取 jumpToken
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const jumpToken = params.get('jumpToken');
|
||||
|
||||
if (!jumpToken) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 通过后端代理交换 jumpToken
|
||||
const exchangeRes = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`);
|
||||
const exchangeData = await exchangeRes.json();
|
||||
|
||||
if (!exchangeRes.ok || !exchangeData.token) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '跳转令牌无效或已过期' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 用 sessionToken 登录获取 JWT
|
||||
const loginRes = await fetch(`${AUTH_API}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: exchangeData.token }),
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
|
||||
if (!loginRes.ok || !loginData.token) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '获取用户信息失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 存储 JWT
|
||||
tokenRef.current = loginData.token;
|
||||
sessionStorage.setItem('bi_jwt', loginData.token);
|
||||
sessionStorage.setItem('bi_user', JSON.stringify(loginData.user));
|
||||
|
||||
// 6. 清除 URL 中的 jumpToken
|
||||
params.delete('jumpToken');
|
||||
const cleanUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: loginData.user,
|
||||
error: null,
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user