mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(auth): 重构认证系统,引入 session 会话管理
- 新增 user_sessions 数据库表及 Alembic 迁移 - 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理) - 认证流程改用 refresh token cookie + access token 双令牌模式 - 前端实现自动静默刷新、跨标签页同步及设备指纹 - 用户设置页新增会话管理和密码修改功能 - 管理员用户管理新增强制登出和会话查看 - 密码策略增强,支持强度校验和泄露检测 - OAuth 登录流程适配新会话机制 - 新增完整的单元测试和 API 测试覆盖 Closes #232 Co-authored-by: LewisPen <[email protected]>
This commit is contained in:
+87
-48
@@ -3,10 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosReq
|
||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
||||
import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
// 在开发环境下使用代理,生产环境使用环境变量
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
|
||||
|
||||
/**
|
||||
* 判断请求是否为公共端点
|
||||
@@ -31,17 +34,15 @@ function isAuthRequest(url?: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为可刷新的认证错误
|
||||
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
|
||||
*/
|
||||
function isRefreshableAuthError(errorDetail: string): boolean {
|
||||
const nonRefreshableErrors = [
|
||||
function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
|
||||
if (status !== 403) return false
|
||||
const accountErrors = [
|
||||
'用户不存在或已禁用',
|
||||
'需要管理员权限',
|
||||
'权限不足',
|
||||
'用户已禁用',
|
||||
]
|
||||
|
||||
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
|
||||
return accountErrors.some((msg) => errorDetail.includes(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,12 +84,21 @@ class ApiClient {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
private isRefreshing = false
|
||||
private refreshPromise: Promise<AxiosResponse> | null = null
|
||||
private refreshPromise: Promise<string> | null = null
|
||||
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
|
||||
|
||||
private readonly onStorageSync = (event: StorageEvent): void => {
|
||||
if (event.key !== 'access_token') {
|
||||
return
|
||||
}
|
||||
this.syncTokenState(event.newValue)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -99,6 +109,7 @@ class ApiClient {
|
||||
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
||||
|
||||
this.setupInterceptors()
|
||||
this.setupCrossTabAuthSync()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +119,10 @@ class ApiClient {
|
||||
// 请求拦截器 - 仅处理认证
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (config.url?.includes('/api/')) {
|
||||
config.headers['X-Client-Device-Id'] = getClientDeviceId()
|
||||
}
|
||||
|
||||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||||
config.url?.includes('/api/')
|
||||
|
||||
@@ -129,6 +144,23 @@ class ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
private setupCrossTabAuthSync(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', this.onStorageSync)
|
||||
}
|
||||
}
|
||||
|
||||
private emitAuthStateChange(token: string | null): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
|
||||
detail: { token },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应错误
|
||||
*/
|
||||
@@ -155,8 +187,22 @@ class ApiClient {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const status = error.response?.status ?? 0
|
||||
|
||||
// 处理 403 用户账号级别错误(被禁用/删除)
|
||||
if (status === 403) {
|
||||
const rawDetail = (error.response?.data as Record<string, unknown>)?.detail
|
||||
const errorDetail = typeof rawDetail === 'string' ? rawDetail : ''
|
||||
if (isAccountLevelForbidden(status, errorDetail)) {
|
||||
log.info('User account issue detected, clearing auth', { errorDetail })
|
||||
this.clearAuth()
|
||||
window.location.href = '/'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理401错误
|
||||
if (error.response?.status === 401) {
|
||||
if (status === 401) {
|
||||
return this.handle401Error(error, originalRequest)
|
||||
}
|
||||
|
||||
@@ -177,25 +223,7 @@ class ApiClient {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const errorDetail = (error.response?.data as Record<string, unknown>)?.detail as string || ''
|
||||
log.debug('Got 401 error, attempting token refresh', { errorDetail })
|
||||
|
||||
// 检查是否为业务相关的401错误(用户被禁用/删除等)
|
||||
if (!isRefreshableAuthError(errorDetail)) {
|
||||
log.info('User account issue detected, logging out and redirecting to home', { errorDetail })
|
||||
this.clearAuth()
|
||||
// 跳转到首页
|
||||
window.location.href = '/'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 获取refresh token
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) {
|
||||
log.info('No refresh token available, clearing invalid token')
|
||||
this.clearAuth()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
log.debug('Got 401 error, attempting token refresh')
|
||||
|
||||
// 标记为已重试
|
||||
originalRequest._retry = true
|
||||
@@ -210,8 +238,8 @@ class ApiClient {
|
||||
// 如果正在刷新,等待刷新完成
|
||||
if (this.isRefreshing) {
|
||||
try {
|
||||
await this.refreshPromise
|
||||
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
|
||||
const accessToken = await this.refreshPromise
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
@@ -219,29 +247,27 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// 开始刷新token
|
||||
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
|
||||
return this.refreshTokenAndRetry(originalRequest, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token并重试原始请求
|
||||
*/
|
||||
private async refreshTokenAndRetry(
|
||||
refreshToken: string,
|
||||
originalRequest: InternalAxiosRequestConfig,
|
||||
originalError: import('axios').AxiosError
|
||||
): Promise<AxiosResponse> {
|
||||
this.isRefreshing = true
|
||||
this.refreshPromise = this.refreshToken(refreshToken)
|
||||
this.refreshPromise = this.coordinatedRefresh()
|
||||
|
||||
try {
|
||||
const response = await this.refreshPromise
|
||||
this.setToken(response.data.access_token)
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
const accessToken = await this.refreshPromise
|
||||
this.setToken(accessToken)
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
|
||||
// 重试原始请求
|
||||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch (refreshError: unknown) {
|
||||
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
|
||||
@@ -252,15 +278,29 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
private async coordinatedRefresh(): Promise<string> {
|
||||
return this.refreshCoordinator.run(async () => {
|
||||
const response = await this.refreshToken()
|
||||
const accessToken = response.data.access_token
|
||||
if (!accessToken) {
|
||||
throw new Error('Refresh response missing access token')
|
||||
}
|
||||
return accessToken
|
||||
})
|
||||
}
|
||||
|
||||
private syncTokenState(token: string | null): void {
|
||||
this.token = token
|
||||
localStorage.setItem('access_token', token)
|
||||
// 同步到 mock handler
|
||||
if (isDemoMode()) {
|
||||
setMockUserToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.syncTokenState(token)
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('access_token')
|
||||
@@ -273,18 +313,17 @@ class ApiClient {
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.token = null
|
||||
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
|
||||
this.syncTokenState(null)
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// 同步清除 mock token
|
||||
if (isDemoMode()) {
|
||||
setMockUserToken(null)
|
||||
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
|
||||
if (hadAuth) {
|
||||
this.emitAuthStateChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
|
||||
// refreshToken 会通过 adapter 处理 Demo 模式
|
||||
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
|
||||
async refreshToken(): Promise<AxiosResponse> {
|
||||
return this.client.post('/api/auth/refresh', {})
|
||||
}
|
||||
|
||||
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
|
||||
|
||||
Reference in New Issue
Block a user