feat(auth): 重构认证系统,引入 session 会话管理

- 新增 user_sessions 数据库表及 Alembic 迁移
- 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理)
- 认证流程改用 refresh token cookie + access token 双令牌模式
- 前端实现自动静默刷新、跨标签页同步及设备指纹
- 用户设置页新增会话管理和密码修改功能
- 管理员用户管理新增强制登出和会话查看
- 密码策略增强,支持强度校验和泄露检测
- OAuth 登录流程适配新会话机制
- 新增完整的单元测试和 API 测试覆盖

Closes #232

Co-authored-by: LewisPen <LewisPen@nyadoo.com>
This commit is contained in:
fawney19
2026-03-17 16:34:09 +08:00
parent d480aa11f3
commit c4bb6b8161
58 changed files with 4654 additions and 508 deletions

View File

@@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
describe('apiClient auth state change event', () => {
beforeEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
afterEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
it('dispatches a same-tab auth change event when clearing auth', () => {
const handler = vi.fn()
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
apiClient.setToken('access-token')
apiClient.clearAuth()
expect(localStorage.getItem('access_token')).toBeNull()
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
expect(event.detail).toEqual({ token: null })
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
})
})

View File

@@ -9,7 +9,6 @@ export interface LoginRequest {
export interface LoginResponse {
access_token: string
refresh_token?: string
token_type?: string
expires_in?: number
user_id?: string // UUID
@@ -127,10 +126,6 @@ export const authApi = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
apiClient.setToken(response.data.access_token)
// 后端暂时没有返回 refresh_token
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},
@@ -152,14 +147,9 @@ export const authApi = {
return response.data
},
async refreshToken(refreshToken: string): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
refresh_token: refreshToken
})
async refreshToken(): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {})
apiClient.setToken(response.data.access_token)
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},

View File

@@ -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 clientDemo 模式由 adapter 统一处理

View File

@@ -3,6 +3,9 @@ import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
export type { UserSession }
export interface Profile {
id: string // UUID
@@ -176,6 +179,28 @@ export const meApi = {
return response.data
},
async listSessions(): Promise<UserSession[]> {
const response = await apiClient.get<UserSession[]>('/api/users/me/sessions')
return response.data
},
async updateSessionLabel(sessionId: string, deviceLabel: string): Promise<UserSession> {
const response = await apiClient.patch<UserSession>(`/api/users/me/sessions/${sessionId}`, {
device_label: deviceLabel,
})
return response.data
},
async revokeSession(sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`)
return response.data
},
async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete('/api/users/me/sessions/others')
return response.data
},
// API密钥管理
async getApiKeys(): Promise<ApiKey[]> {
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')

View File

@@ -62,6 +62,8 @@ export interface UpsertUserApiKeyRequest {
rate_limit?: number | null
}
export type { UserSession } from '@/types/session'
export const usersApi = {
async getAllUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/admin/users')
@@ -92,6 +94,21 @@ export const usersApi = {
return response.data.api_keys
},
async getUserSessions(userId: string): Promise<UserSession[]> {
const response = await apiClient.get<UserSession[]>(`/api/admin/users/${userId}/sessions`)
return response.data
},
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions/${sessionId}`)
return response.data
},
async revokeAllUserSessions(userId: string): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions`)
return response.data
},
async createApiKey(
userId: string,
data: UpsertUserApiKeyRequest