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,44 @@
import { describe, expect, it } from 'vitest'
import { hasAuthIdentityChanged, parseAccessTokenIdentity } from '@/utils/authToken'
function buildToken(payload: Record<string, unknown>): string {
const encoded = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
return `header.${encoded}.signature`
}
describe('authToken helpers', () => {
it('parses user identity from JWT payload', () => {
const token = buildToken({ user_id: 'user-1', role: 'admin' })
expect(parseAccessTokenIdentity(token)).toEqual({
userId: 'user-1',
role: 'admin',
})
})
it('returns null for non-jwt tokens', () => {
expect(parseAccessTokenIdentity('demo-access-token')).toBeNull()
})
it('detects unchanged identity when only token value rotates', () => {
const previous = buildToken({ user_id: 'user-1', role: 'user', exp: 1 })
const next = buildToken({ user_id: 'user-1', role: 'user', exp: 2 })
expect(
hasAuthIdentityChanged(previous, next, { id: 'user-1', role: 'user' }),
).toBe(false)
})
it('detects account switch when user identity changes', () => {
const previous = buildToken({ user_id: 'user-1', role: 'user' })
const next = buildToken({ user_id: 'user-2', role: 'admin' })
expect(
hasAuthIdentityChanged(previous, next, { id: 'user-1', role: 'user' }),
).toBe(true)
})
})

View File

@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BroadcastChannelLike } from '@/utils/crossTabRefresh'
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
type Listener = (event: { data: unknown }) => void
const channelRegistry = new Map<string, Set<FakeBroadcastChannel>>()
class FakeBroadcastChannel implements BroadcastChannelLike {
private readonly listeners = new Set<Listener>()
constructor(private readonly name: string) {
const channels = channelRegistry.get(name) ?? new Set<FakeBroadcastChannel>()
channels.add(this)
channelRegistry.set(name, channels)
}
postMessage(data: unknown): void {
const channels = channelRegistry.get(this.name) ?? new Set<FakeBroadcastChannel>()
for (const channel of channels) {
if (channel === this) continue
for (const listener of channel.listeners) {
listener({ data })
}
}
}
addEventListener(_type: 'message', listener: Listener): void {
this.listeners.add(listener)
}
removeEventListener(_type: 'message', listener: Listener): void {
this.listeners.delete(listener)
}
close(): void {
channelRegistry.get(this.name)?.delete(this)
}
}
function createChannel(name: string): BroadcastChannelLike {
return new FakeBroadcastChannel(name)
}
describe('CrossTabRefreshCoordinator', () => {
beforeEach(() => {
localStorage.clear()
channelRegistry.clear()
})
afterEach(() => {
localStorage.clear()
channelRegistry.clear()
})
it('deduplicates refresh requests across tabs', async () => {
let resolveRefresh: ((token: string) => void) | null = null
const firstExecutor = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveRefresh = resolve
}),
)
const secondExecutor = vi.fn(() => Promise.resolve('should-not-run'))
const first = new CrossTabRefreshCoordinator({
storage: localStorage,
channelFactory: createChannel,
})
const second = new CrossTabRefreshCoordinator({
storage: localStorage,
channelFactory: createChannel,
})
const firstRun = first.run(firstExecutor)
await Promise.resolve()
const secondRun = second.run(secondExecutor)
expect(firstExecutor).toHaveBeenCalledTimes(1)
expect(secondExecutor).not.toHaveBeenCalled()
resolveRefresh?.('access-from-first-tab')
await expect(firstRun).resolves.toBe('access-from-first-tab')
await expect(secondRun).resolves.toBe('access-from-first-tab')
first.destroy()
second.destroy()
})
it('propagates refresh failure to waiting tabs without second refresh call', async () => {
const refreshError = new Error('refresh failed')
let rejectRefresh: ((error: Error) => void) | null = null
const firstExecutor = vi.fn(
() =>
new Promise<string>((_resolve, reject) => {
rejectRefresh = reject
}),
)
const secondExecutor = vi.fn(() => Promise.resolve('should-not-run'))
const first = new CrossTabRefreshCoordinator({
storage: localStorage,
channelFactory: createChannel,
})
const second = new CrossTabRefreshCoordinator({
storage: localStorage,
channelFactory: createChannel,
})
const firstRun = first.run(firstExecutor)
await Promise.resolve()
const secondRun = second.run(secondExecutor)
rejectRefresh?.(refreshError)
await expect(firstRun).rejects.toThrow('refresh failed')
await expect(secondRun).rejects.toThrow('failed in another tab')
expect(secondExecutor).not.toHaveBeenCalled()
first.destroy()
second.destroy()
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { getPasswordPolicyErrors, validatePasswordByPolicy } from '../passwordPolicy'
describe('passwordPolicy utils', () => {
it('rejects passwords longer than 72 bytes', () => {
expect(getPasswordPolicyErrors('a'.repeat(80), 'weak')).toContain('长度不能超过72字节')
})
it('rejects multibyte passwords longer than 72 bytes', () => {
expect(getPasswordPolicyErrors('中'.repeat(25), 'weak')).toContain('长度不能超过72字节')
})
it('formats validation errors into a single message', () => {
expect(validatePasswordByPolicy('abc', 'strong')).toBe(
'密码需要:至少 8 个字符、包含大写字母、包含数字、包含特殊字符',
)
})
})

View File

@@ -0,0 +1,61 @@
export type AccessTokenIdentity = {
userId: string | null
role: string | null
}
function decodeBase64Url(value: string): string | null {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
return atob(padded)
} catch {
return null
}
}
export function parseAccessTokenIdentity(token: string | null): AccessTokenIdentity | null {
if (!token) {
return null
}
const parts = token.split('.')
if (parts.length < 2) {
return null
}
const decodedPayload = decodeBase64Url(parts[1])
if (!decodedPayload) {
return null
}
try {
const payload = JSON.parse(decodedPayload) as Record<string, unknown>
return {
userId: typeof payload.user_id === 'string' ? payload.user_id : null,
role: typeof payload.role === 'string' ? payload.role : null,
}
} catch {
return null
}
}
export function hasAuthIdentityChanged(
previousToken: string | null,
nextToken: string | null,
currentUser: { id?: string | null; role?: string | null } | null,
): boolean {
const nextIdentity = parseAccessTokenIdentity(nextToken)
if (!nextIdentity) {
return true
}
const previousIdentity = parseAccessTokenIdentity(previousToken)
const previousUserId = previousIdentity?.userId ?? currentUser?.id ?? null
const previousRole = previousIdentity?.role ?? currentUser?.role ?? null
if (!previousUserId && !previousRole) {
return true
}
return nextIdentity.userId !== previousUserId || nextIdentity.role !== previousRole
}

View File

@@ -0,0 +1,308 @@
import { NETWORK_CONFIG } from '@/config/constants'
const REFRESH_LOCK_KEY = 'aether_auth_refresh_lock'
const REFRESH_RESULT_KEY = 'aether_auth_refresh_result'
const REFRESH_CHANNEL_NAME = 'aether-auth-refresh'
const DEFAULT_WAIT_TIMEOUT_MS = NETWORK_CONFIG.API_TIMEOUT + 5000
const MAX_RETRIES = 2
type RefreshStatus = 'success' | 'failure'
type RefreshLock = {
owner: string
requestId: string
expiresAt: number
}
type RefreshResult = {
requestId: string
status: RefreshStatus
accessToken?: string
emittedAt: number
}
type RefreshEventMessage = {
type: 'refresh-result'
payload: RefreshResult
}
type Waiter = {
resolve: (result: RefreshResult) => void
reject: (error: Error) => void
timeoutId: ReturnType<typeof setTimeout>
}
type BroadcastMessageEvent = {
data: unknown
}
export type BroadcastChannelLike = {
postMessage(data: unknown): void
addEventListener(type: 'message', listener: (event: BroadcastMessageEvent) => void): void
removeEventListener(
type: 'message',
listener: (event: BroadcastMessageEvent) => void,
): void
close?(): void
}
type CoordinatorOptions = {
storage?: Storage | null
waitTimeoutMs?: number
channelFactory?: (name: string) => BroadcastChannelLike | null
}
class CrossTabRefreshTimeoutError extends Error {
constructor(requestId: string) {
super(`Timed out while waiting for refresh request ${requestId}`)
this.name = 'CrossTabRefreshTimeoutError'
}
}
function createId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `refresh-${Math.random().toString(36).slice(2, 10)}-${Date.now()}`
}
function parseJson<T>(raw: string | null): T | null {
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
function defaultChannelFactory(name: string): BroadcastChannelLike | null {
if (typeof BroadcastChannel === 'undefined') {
return null
}
return new BroadcastChannel(name)
}
export class CrossTabRefreshCoordinator {
private readonly storage: Storage | null
private readonly waitTimeoutMs: number
private readonly tabId = createId()
private readonly channel: BroadcastChannelLike | null
private readonly waiters = new Map<string, Waiter>()
private readonly onStorage = (event: StorageEvent): void => {
if (event.key !== REFRESH_RESULT_KEY || !event.newValue) {
return
}
const result = parseJson<RefreshResult>(event.newValue)
if (result) {
this.resolveWaiter(result)
}
}
private readonly onBroadcastMessage = (event: BroadcastMessageEvent): void => {
const message = event.data as RefreshEventMessage | null
if (!message || message.type !== 'refresh-result') {
return
}
this.resolveWaiter(message.payload)
}
constructor(options: CoordinatorOptions = {}) {
this.storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null)
this.waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS
this.channel = (options.channelFactory ?? defaultChannelFactory)(REFRESH_CHANNEL_NAME)
if (typeof window !== 'undefined') {
window.addEventListener('storage', this.onStorage)
}
this.channel?.addEventListener('message', this.onBroadcastMessage)
}
destroy(): void {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', this.onStorage)
}
this.channel?.removeEventListener('message', this.onBroadcastMessage)
this.channel?.close?.()
for (const waiter of this.waiters.values()) {
clearTimeout(waiter.timeoutId)
}
this.waiters.clear()
}
async run(executor: () => Promise<string>, retryCount = 0): Promise<string> {
const activeLock = this.readActiveLock()
if (activeLock && activeLock.owner !== this.tabId) {
return this.waitForRefreshResult(activeLock.requestId, executor, retryCount)
}
const lock = this.tryAcquireLock()
if (!lock) {
const currentLock = this.readActiveLock()
if (currentLock && currentLock.owner !== this.tabId) {
return this.waitForRefreshResult(currentLock.requestId, executor, retryCount)
}
return executor()
}
try {
const accessToken = await executor()
this.publishRefreshResult({
requestId: lock.requestId,
status: 'success',
accessToken,
emittedAt: Date.now(),
})
return accessToken
} catch (error) {
this.publishRefreshResult({
requestId: lock.requestId,
status: 'failure',
emittedAt: Date.now(),
})
throw error
} finally {
this.releaseLock(lock)
}
}
private waitForRefreshResult(requestId: string, executor: () => Promise<string>, retryCount: number): Promise<string> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.waiters.delete(requestId)
reject(new CrossTabRefreshTimeoutError(requestId))
}, this.waitTimeoutMs)
this.waiters.set(requestId, {
resolve: (result) => {
if (result.status === 'success' && result.accessToken) {
resolve(result.accessToken)
return
}
reject(new Error(`Refresh request ${requestId} failed in another tab`))
},
reject,
timeoutId,
})
}).catch((error: unknown) => {
if (error instanceof CrossTabRefreshTimeoutError) {
if (retryCount >= MAX_RETRIES) {
return executor()
}
return this.run(executor, retryCount + 1)
}
throw error
})
}
private tryAcquireLock(): RefreshLock | null {
if (!this.storage) {
return {
owner: this.tabId,
requestId: createId(),
expiresAt: Date.now() + this.waitTimeoutMs,
}
}
const existing = this.readActiveLock()
if (existing && existing.owner !== this.tabId) {
return null
}
const lock: RefreshLock = {
owner: this.tabId,
requestId: createId(),
expiresAt: Date.now() + this.waitTimeoutMs,
}
try {
// 这是一个 best-effort 跨标签页锁;写入后立刻回读,只认最终赢得竞态的 owner。
this.storage.setItem(REFRESH_LOCK_KEY, JSON.stringify(lock))
const current = this.readLock()
if (current && current.owner === lock.owner && current.requestId === lock.requestId) {
return current
}
} catch {
return lock
}
return null
}
private releaseLock(lock: RefreshLock): void {
if (!this.storage) {
return
}
try {
const current = this.readLock()
if (current && current.owner === lock.owner && current.requestId === lock.requestId) {
this.storage.removeItem(REFRESH_LOCK_KEY)
}
} catch {
// ignore storage release failures and allow lock TTL to expire naturally
}
}
private publishRefreshResult(result: RefreshResult): void {
const message: RefreshEventMessage = {
type: 'refresh-result',
payload: result,
}
this.channel?.postMessage(message)
if (!this.storage) {
return
}
try {
this.storage.setItem(REFRESH_RESULT_KEY, JSON.stringify(result))
// 清理残留的 token 数据,仅依赖 BroadcastChannel 和 storage 事件的瞬时传播
setTimeout(() => {
try {
this.storage?.removeItem(REFRESH_RESULT_KEY)
} catch {
// ignore
}
}, 2000)
} catch {
// ignore storage publish failures; BroadcastChannel already covers most browsers
}
}
private resolveWaiter(result: RefreshResult): void {
const waiter = this.waiters.get(result.requestId)
if (!waiter) {
return
}
clearTimeout(waiter.timeoutId)
this.waiters.delete(result.requestId)
waiter.resolve(result)
}
private readActiveLock(): RefreshLock | null {
const lock = this.readLock()
if (!lock) {
return null
}
if (lock.expiresAt > Date.now()) {
return lock
}
if (this.storage) {
try {
this.storage.removeItem(REFRESH_LOCK_KEY)
} catch {
// ignore storage cleanup failures; stale lock will age out on next write
}
}
return null
}
private readLock(): RefreshLock | null {
if (!this.storage) {
return null
}
try {
return parseJson<RefreshLock>(this.storage.getItem(REFRESH_LOCK_KEY))
} catch {
return null
}
}
}

View File

@@ -0,0 +1,19 @@
const DEVICE_ID_KEY = 'aether_client_device_id'
function generateDeviceId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `device-${Math.random().toString(36).slice(2, 10)}-${Date.now()}`
}
export function getClientDeviceId(): string {
const existing = localStorage.getItem(DEVICE_ID_KEY)
if (existing) {
return existing
}
const created = generateDeviceId()
localStorage.setItem(DEVICE_ID_KEY, created)
return created
}

View File

@@ -1,4 +1,11 @@
export type PasswordPolicyLevel = 'weak' | 'medium' | 'strong'
export const PASSWORD_MAX_BYTES = 72
const textEncoder = new TextEncoder()
function getPasswordByteLength(password: string): number {
return textEncoder.encode(password).length
}
export const PASSWORD_POLICY_OPTIONS: Array<{
value: PasswordPolicyLevel
@@ -53,46 +60,51 @@ export function getPasswordPolicyPlaceholder(level: unknown): string {
}
}
export function validatePasswordByPolicy(password: string, level: unknown): string {
if (!password) {
return ''
}
/**
* 返回所有未满足的密码策略条件。
* 空数组 = 密码合规。
*/
export function getPasswordPolicyErrors(password: string, level: unknown): string[] {
if (!password) return []
const normalized = normalizePasswordPolicyLevel(level)
const errors: string[] = []
if (password.length < 6) {
return '密码长度至少为6个字符'
const byteLength = getPasswordByteLength(password)
if (byteLength > PASSWORD_MAX_BYTES) {
errors.push(`长度不能超过${PASSWORD_MAX_BYTES}字节`)
}
// 根据策略确定最小长度,不做两段式报错
const minLen = normalized === 'weak' ? 6 : 8
if (password.length < minLen) {
errors.push(`至少 ${minLen} 个字符`)
}
if (normalized === 'medium') {
if (password.length < 8) {
return '密码长度至少为8个字符'
}
if (!/[A-Za-z]/.test(password)) {
return '密码必须包含至少一个字母'
}
if (!/[0-9]/.test(password)) {
return '密码必须包含至少一个数字'
}
if (!/[A-Za-z]/.test(password)) errors.push('包含字母')
if (!/[0-9]/.test(password)) errors.push('包含数字')
}
if (normalized === 'strong') {
if (password.length < 8) {
return '密码长度至少为8个字符'
}
if (!/[A-Z]/.test(password)) {
return '密码必须包含至少一个大写字母'
}
if (!/[a-z]/.test(password)) {
return '密码必须包含至少一个小写字母'
}
if (!/[0-9]/.test(password)) {
return '密码必须包含至少一个数字'
}
if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?/\\|`~]/.test(password)) {
return '密码必须包含至少一个特殊字符'
}
if (!/[A-Z]/.test(password)) errors.push('包含大写字母')
if (!/[a-z]/.test(password)) errors.push('包含小写字母')
if (!/[0-9]/.test(password)) errors.push('包含数字')
if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?/\\|`~]/.test(password)) errors.push('包含特殊字符')
}
return ''
return errors
}
/**
* 兼容旧接口:返回单条错误字符串,空字符串表示通过。
* 多条未满足条件时用顿号连接。
*/
export function validatePasswordByPolicy(password: string, level: unknown): string {
const errors = getPasswordPolicyErrors(password, level)
if (errors.length === 0) return ''
if (errors.length === 1 && errors[0].startsWith('长度不能超过')) {
return `密码${ errors[0]}`
}
return `密码需要:${ errors.join('、')}`
}