mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
44
frontend/src/utils/__tests__/authToken.spec.ts
Normal file
44
frontend/src/utils/__tests__/authToken.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
125
frontend/src/utils/__tests__/crossTabRefresh.spec.ts
Normal file
125
frontend/src/utils/__tests__/crossTabRefresh.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
18
frontend/src/utils/__tests__/passwordPolicy.spec.ts
Normal file
18
frontend/src/utils/__tests__/passwordPolicy.spec.ts
Normal 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 个字符、包含大写字母、包含数字、包含特殊字符',
|
||||
)
|
||||
})
|
||||
})
|
||||
61
frontend/src/utils/authToken.ts
Normal file
61
frontend/src/utils/authToken.ts
Normal 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
|
||||
}
|
||||
308
frontend/src/utils/crossTabRefresh.ts
Normal file
308
frontend/src/utils/crossTabRefresh.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
frontend/src/utils/deviceId.ts
Normal file
19
frontend/src/utils/deviceId.ts
Normal 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
|
||||
}
|
||||
@@ -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('、')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user