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:
fawney19
2026-03-17 16:34:09 +08:00
co-authored by LewisPen
parent d480aa11f3
commit c4bb6b8161
58 changed files with 4654 additions and 508 deletions
+66 -2
View File
@@ -5,12 +5,14 @@
</template>
<script setup lang="ts">
import { onMounted, onErrorCaptured } from 'vue'
import { onMounted, onErrorCaptured, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ToastContainer from '@/components/ToastContainer.vue'
import ConfirmContainer from '@/components/ConfirmContainer.vue'
import apiClient from '@/api/client'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import router from '@/router'
import { hasAuthIdentityChanged } from '@/utils/authToken'
import { log } from '@/utils/logger'
const authStore = useAuthStore()
@@ -86,7 +88,59 @@ if (typeof window !== 'undefined') {
})
}
async function syncExternalAuthState(nextToken: string | null): Promise<void> {
const previousToken = authStore.token
const previousUser = authStore.user
? {
id: authStore.user.id,
role: authStore.user.role,
}
: null
authStore.syncToken()
if (!nextToken) {
if (previousToken || previousUser) {
authStore.applyExternalLogout()
await router.replace('/')
}
return
}
const identityChanged = hasAuthIdentityChanged(previousToken, nextToken, previousUser)
if (!identityChanged && previousUser) {
return
}
const user = await authStore.fetchCurrentUser()
if (!user) {
return
}
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
await router.replace('/dashboard')
}
}
function handleAuthStorageChange(event: StorageEvent): void {
if (event.key !== 'access_token') {
return
}
syncExternalAuthState(event.newValue).catch((err) => log.error('syncExternalAuthState failed', err))
}
function handleLocalAuthStateChange(event: Event): void {
const authEvent = event as CustomEvent<{ token: string | null }>
syncExternalAuthState(authEvent.detail?.token ?? apiClient.getToken()).catch((err) => log.error('syncExternalAuthState failed', err))
}
onMounted(async () => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleAuthStorageChange)
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleLocalAuthStateChange as (event: Event) => void)
}
// 延迟检查认证状态,让页面先加载
setTimeout(async () => {
try {
@@ -97,4 +151,14 @@ onMounted(async () => {
}
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', handleAuthStorageChange)
window.removeEventListener(
AUTH_STATE_CHANGE_EVENT,
handleLocalAuthStateChange as (event: Event) => void,
)
}
})
</script>