Files
BYW-VoiceHub/server/utils/captchaStore.ts
T

202 lines
5.7 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto'
import type { RedisClientType } from 'redis'
import { buildRedisKey, getRedisClient, isRedisConfigured } from './redis'
import { getServerTimestamp } from './serverTime'
interface MemoryRecord {
value: string
expiresAt: number
}
export class RedisStateUnavailableError extends Error {
statusCode = 503
constructor() {
super('分布式短期状态服务暂不可用,请稍后重试')
this.name = 'RedisStateUnavailableError'
}
}
const memoryStore = new Map<string, MemoryRecord>()
const MAX_MEMORY_STORE_SIZE = 10000
const keyOf = (key: string) => buildRedisKey('state', key)
const cleanupMemoryStore = () => {
const now = getServerTimestamp()
for (const [key, record] of memoryStore.entries()) {
if (now >= record.expiresAt) memoryStore.delete(key)
}
}
const ensureMemoryStoreCapacity = (key: string) => {
cleanupMemoryStore()
if (memoryStore.has(key) || memoryStore.size < MAX_MEMORY_STORE_SIZE) return
const oldestKey = memoryStore.keys().next().value
if (oldestKey !== undefined) memoryStore.delete(oldestKey)
}
const requireRedis = async () => {
const redis = await getRedisClient()
if (!redis) throw new RedisStateUnavailableError()
return redis
}
const executeRedisState = async <T>(
operation: (redis: RedisClientType) => Promise<T>
): Promise<T> => {
try {
const redis = await requireRedis()
return await operation(redis)
} catch (error) {
if (error instanceof RedisStateUnavailableError) throw error
throw new RedisStateUnavailableError()
}
}
const INCREMENT_WITH_EXPIRY_SCRIPT = `
local value = redis.call('INCR', KEYS[1])
if value == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return value
`
const GET_AND_DELETE_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value then redis.call('DEL', KEYS[1]) end
return value
`
const COMPARE_AND_DELETE_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0
`
/** 设置短期状态。配置 Redis 后,Redis 故障不会悄悄切换到另一份内存状态。 */
export async function setStore(key: string, value: string, ttlSeconds: number) {
if (isRedisConfigured()) {
await executeRedisState((redis) =>
redis.set(keyOf(key), value, { EX: Math.max(1, Math.floor(ttlSeconds)) })
)
return
}
ensureMemoryStoreCapacity(key)
memoryStore.set(key, {
value,
expiresAt: getServerTimestamp() + Math.max(1, ttlSeconds) * 1000
})
}
export async function getStore(key: string): Promise<string | null> {
if (isRedisConfigured()) {
return await executeRedisState((redis) => redis.get(keyOf(key)))
}
cleanupMemoryStore()
const record = memoryStore.get(key)
return record ? record.value : null
}
export async function delStore(key: string) {
if (isRedisConfigured()) {
await executeRedisState((redis) => redis.del(keyOf(key)))
return
}
memoryStore.delete(key)
}
/** 递增键值并仅在第一次递增时设置过期时间。 */
export async function incrStore(key: string, ttlSeconds: number): Promise<number> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, {
keys: [keyOf(key)],
arguments: [String(Math.max(1, Math.floor(ttlSeconds)))]
})
)
return Number(result)
}
ensureMemoryStoreCapacity(key)
const existing = memoryStore.get(key)
const current = Number(existing?.value || 0) + 1
memoryStore.set(key, {
value: String(current),
expiresAt: existing?.expiresAt ?? getServerTimestamp() + Math.max(1, ttlSeconds) * 1000
})
return current
}
/** 原子读取并删除键,用于一次性验证码。 */
export async function getAndDelStore(key: string): Promise<string | null> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(GET_AND_DELETE_SCRIPT, {
keys: [keyOf(key)],
arguments: []
})
)
return result === null ? null : String(result)
}
cleanupMemoryStore()
const record = memoryStore.get(key)
memoryStore.delete(key)
return record ? record.value : null
}
/** 仅当值未被其他请求改变时删除,用于防止一次性验证码并发重复消费。 */
export async function delStoreIfValue(key: string, expectedValue: string): Promise<boolean> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(COMPARE_AND_DELETE_SCRIPT, {
keys: [keyOf(key)],
arguments: [expectedValue]
})
)
return Number(result) === 1
}
cleanupMemoryStore()
const record = memoryStore.get(key)
if (!record || record.value !== expectedValue) return false
memoryStore.delete(key)
return true
}
/** 安全解析短期状态 JSON,拒绝旧明文、数组和损坏数据。 */
export function parseStoreJson<T extends object>(value: string | null): T | null {
if (!value) return null
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
return parsed as T
} catch {
return null
}
}
const getCodeSecret = () => {
const secret = process.env.JWT_SECRET?.trim()
if (!secret) throw new Error('JWT_SECRET environment variable is not set')
return secret
}
export function hashStateCode(scope: string, code: string) {
return createHmac('sha256', getCodeSecret()).update(`${scope}:${code}`).digest('hex')
}
export function verifyStateCode(scope: string, code: string, expectedHash: string) {
const actual = Buffer.from(hashStateCode(scope, code), 'utf8')
const expected = Buffer.from(expectedHash, 'utf8')
return actual.length === expected.length && timingSafeEqual(actual, expected)
}