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

@@ -76,7 +76,6 @@ export const MOCK_NORMAL_USER: User = {
export const MOCK_LOGIN_RESPONSE_ADMIN: LoginResponse = {
access_token: 'demo-access-token-admin',
refresh_token: 'demo-refresh-token-admin',
token_type: 'bearer',
expires_in: 3600,
user_id: MOCK_ADMIN_USER.id,
@@ -87,7 +86,6 @@ export const MOCK_LOGIN_RESPONSE_ADMIN: LoginResponse = {
export const MOCK_LOGIN_RESPONSE_USER: LoginResponse = {
access_token: 'demo-access-token-user',
refresh_token: 'demo-refresh-token-user',
token_type: 'bearer',
expires_in: 3600,
user_id: MOCK_NORMAL_USER.id,

View File

@@ -531,6 +531,76 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse({ message: '密码修改成功(演示模式)' })
},
'GET /api/users/me/sessions': async () => {
await delay()
return createMockResponse([
{
id: 'session-current',
device_label: 'Chrome / macOS',
device_type: 'desktop',
browser_name: 'Chrome',
browser_version: '134.0',
os_name: 'macOS',
os_version: '15.3',
device_model: null,
ip_address: '192.168.1.100',
last_seen_at: new Date().toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString(),
is_current: true,
revoked_at: null,
revoke_reason: null
},
{
id: 'session-other',
device_label: 'Safari / iPhone',
device_type: 'mobile',
browser_name: 'Safari',
browser_version: '18.0',
os_name: 'iOS',
os_version: '18.3',
device_model: 'iPhone',
ip_address: '10.0.0.12',
last_seen_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(),
created_at: new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString(),
is_current: false,
revoked_at: null,
revoke_reason: null
}
])
},
'DELETE /api/users/me/sessions/others': async () => {
await delay()
return createMockResponse({ message: '其他设备已退出登录(演示模式)', revoked_count: 1 })
},
'PATCH /api/users/me/sessions/:sessionId': async (config) => {
await delay()
const sessionId = config.url?.split('/').pop() || 'session'
const body = JSON.parse(config.data || '{}')
return createMockResponse({
id: sessionId,
device_label: body.device_label || '已重命名设备',
device_type: 'desktop',
browser_name: 'Chrome',
browser_version: '134.0',
os_name: 'macOS',
os_version: '15.3',
device_model: null,
ip_address: '192.168.1.100',
last_seen_at: new Date().toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString(),
is_current: sessionId === 'session-current',
revoked_at: null,
revoke_reason: null
})
},
'DELETE /api/users/me/sessions/:sessionId': async () => {
await delay()
return createMockResponse({ message: '设备已退出登录(演示模式)' })
},
'GET /api/users/me/api-keys': async () => {
await delay()
return createMockResponse(MOCK_USER_API_KEYS)
@@ -2086,6 +2156,44 @@ registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys', async (_config,
return createMockResponse(MOCK_USER_API_KEYS)
})
// 管理员 - 用户会话列表
registerDynamicRoute('GET', '/api/admin/users/:userId/sessions', async (_config, _params) => {
await delay()
requireAdmin()
return createMockResponse([
{
id: 'admin-session-1',
device_label: 'Chrome / macOS',
device_type: 'desktop',
browser_name: 'Chrome',
browser_version: '134.0',
os_name: 'macOS',
os_version: '15.3',
device_model: null,
ip_address: '192.168.1.100',
last_seen_at: new Date().toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString(),
is_current: false,
revoked_at: null,
revoke_reason: null
}
])
})
// 管理员 - 撤销用户单个会话
registerDynamicRoute('DELETE', '/api/admin/users/:userId/sessions/:sessionId', async (_config, _params) => {
await delay()
requireAdmin()
return createMockResponse({ message: '会话已撤销(演示模式)' })
})
// 管理员 - 撤销用户全部会话
registerDynamicRoute('DELETE', '/api/admin/users/:userId/sessions', async (_config, _params) => {
await delay()
requireAdmin()
return createMockResponse({ message: '全部会话已撤销(演示模式)', revoked_count: 1 })
})
// API Key 详情
registerDynamicRoute('GET', '/api/admin/api-keys/:keyId', async (_config, params) => {
await delay()