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,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const { logoutMock, getTokenMock, getCurrentUserMock } = vi.hoisted(() => ({
logoutMock: vi.fn(),
getTokenMock: vi.fn(() => null),
getCurrentUserMock: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
authApi: {
logout: logoutMock,
getCurrentUser: getCurrentUserMock,
},
}))
vi.mock('@/api/client', () => ({
default: {
getToken: getTokenMock,
},
}))
import { useAuthStore } from '@/stores/auth'
describe('auth store logout', () => {
beforeEach(() => {
setActivePinia(createPinia())
logoutMock.mockReset()
getTokenMock.mockReset()
getCurrentUserMock.mockReset()
getTokenMock.mockReturnValue(null)
})
it('waits for backend logout before resolving', async () => {
let resolveLogout: (() => void) | null = null
logoutMock.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveLogout = resolve
})
)
const store = useAuthStore()
store.user = {
id: 'user-1',
username: 'tester',
role: 'user',
is_active: true,
created_at: '2026-03-16T00:00:00Z',
}
store.token = 'access-token'
let settled = false
const logoutPromise = store.logout().then(() => {
settled = true
})
await Promise.resolve()
expect(logoutMock).toHaveBeenCalledTimes(1)
expect(store.user).toBeNull()
expect(store.token).toBeNull()
expect(settled).toBe(false)
resolveLogout?.()
await logoutPromise
expect(settled).toBe(true)
})
it('clears local auth state for external logout without calling backend', () => {
const store = useAuthStore()
store.user = {
id: 'user-1',
username: 'tester',
role: 'user',
is_active: true,
created_at: '2026-03-16T00:00:00Z',
}
store.token = 'access-token'
store.applyExternalLogout()
expect(store.user).toBeNull()
expect(store.token).toBeNull()
expect(logoutMock).not.toHaveBeenCalled()
})
it('clears stale store auth when fetchCurrentUser fails after token was removed', async () => {
const store = useAuthStore()
store.user = {
id: 'user-1',
username: 'tester',
role: 'user',
is_active: true,
created_at: '2026-03-16T00:00:00Z',
}
store.token = 'access-token'
getCurrentUserMock.mockRejectedValue(new Error('unauthorized'))
getTokenMock.mockReturnValue(null)
const result = await store.fetchCurrentUser()
expect(result).toBeNull()
expect(store.user).toBeNull()
expect(store.token).toBeNull()
})
})

View File

@@ -70,7 +70,13 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
user.value = null
token.value = null
authApi.logout()
await authApi.logout()
}
function applyExternalLogout() {
user.value = null
token.value = null
error.value = null
}
async function fetchCurrentUser() {
@@ -80,6 +86,10 @@ export const useAuthStore = defineStore('auth', () => {
return userInfo
} catch (err: unknown) {
log.error('Failed to fetch user info', err)
syncToken()
if (!token.value) {
user.value = null
}
// 根据用户要求,不管什么错误都不清除状态
// 保持登录状态,除非用户手动退出
log.info('Keeping session despite error, as per user requirement')
@@ -106,6 +116,7 @@ export const useAuthStore = defineStore('auth', () => {
isAdmin,
login,
logout,
applyExternalLogout,
fetchCurrentUser,
checkAuth,
syncToken

View File

@@ -6,7 +6,8 @@ import {
type CreateUserRequest,
type UpdateUserRequest,
type ApiKey,
type UpsertUserApiKeyRequest
type UpsertUserApiKeyRequest,
type UserSession,
} from '@/api/users'
import { parseApiError } from '@/utils/errorParser'
@@ -131,6 +132,35 @@ export const useUsersStore = defineStore('users', () => {
}
}
async function getUserSessions(userId: string): Promise<UserSession[]> {
try {
return await usersApi.getUserSessions(userId)
} catch (err: unknown) {
error.value = parseApiError(err, '获取用户设备会话失败')
throw err
}
}
async function revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
try {
return await usersApi.revokeUserSession(userId, sessionId)
} catch (err: unknown) {
error.value = parseApiError(err, '强制下线设备失败')
throw err
}
}
async function revokeAllUserSessions(
userId: string,
): Promise<{ message: string; revoked_count: number }> {
try {
return await usersApi.revokeAllUserSessions(userId)
} catch (err: unknown) {
error.value = parseApiError(err, '强制下线全部设备失败')
throw err
}
}
return {
users,
loading,
@@ -143,6 +173,9 @@ export const useUsersStore = defineStore('users', () => {
createApiKey,
updateApiKey,
deleteApiKey,
getFullApiKey
getFullApiKey,
getUserSessions,
revokeUserSession,
revokeAllUserSessions,
}
})