import { createHash, createHmac, timingSafeEqual } from 'node:crypto' export const SESSION_COOKIE = 'boundless_session' interface SessionPayload { /** 签发时间(毫秒) */ iat: number /** 过期时间(毫秒) */ exp: number } /** * 会话令牌:`base64url(payload).签名`。 * 签名密钥由服务端密钥和登录口令共同派生,所以修改口令后所有设备都会退出登录。 */ export class SessionManager { private readonly key: Buffer private readonly passwordHash: Buffer readonly maxAgeMs: number constructor(secret: string, password: string, sessionDays: number) { this.key = createHmac('sha256', secret).update(`session:${password}`).digest() this.passwordHash = sha256(password) this.maxAgeMs = sessionDays * 24 * 60 * 60 * 1000 } checkPassword(candidate: unknown) { if (typeof candidate !== 'string' || candidate.length > 1024) return false return timingSafeEqual(sha256(candidate), this.passwordHash) } issue(now = Date.now()) { const payload: SessionPayload = { iat: now, exp: now + this.maxAgeMs } const body = Buffer.from(JSON.stringify(payload)).toString('base64url') return `${body}.${this.sign(body)}` } verify(token: string | undefined | null, now = Date.now()): SessionPayload | null { if (!token) return null const [body, signature, extra] = token.split('.') if (!body || !signature || extra !== undefined) return null const expected = Buffer.from(this.sign(body)) const actual = Buffer.from(signature) if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null try { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload if (typeof payload.exp !== 'number' || payload.exp <= now) return null return payload } catch { return null } } /** 已用过一半有效期的令牌需要续期,常用设备就不会突然被登出。 */ shouldRenew(payload: SessionPayload, now = Date.now()) { return payload.exp - now < this.maxAgeMs / 2 } private sign(body: string) { return createHmac('sha256', this.key).update(body).digest('base64url') } } function sha256(value: string) { return createHash('sha256').update(value).digest() } export function readCookie(header: string | null | undefined, name: string) { if (!header) return undefined for (const part of header.split(';')) { const index = part.indexOf('=') if (index === -1) continue if (part.slice(0, index).trim() === name) { try { return decodeURIComponent(part.slice(index + 1).trim()) } catch { return undefined } } } return undefined } /** * 登录失败限流:同一来源 15 分钟内最多失败 10 次。 * 只有一个人使用,内存记录就够了。 */ export class LoginLimiter { private readonly failures = new Map() private readonly maxFailures: number private readonly windowMs: number constructor(maxFailures = 10, windowMs = 15 * 60 * 1000) { this.maxFailures = maxFailures this.windowMs = windowMs } isBlocked(key: string, now = Date.now()) { return this.recent(key, now).length >= this.maxFailures } fail(key: string, now = Date.now()) { const list = this.recent(key, now) list.push(now) this.failures.set(key, list) } reset(key: string) { this.failures.delete(key) } private recent(key: string, now: number) { const list = (this.failures.get(key) ?? []).filter((time) => now - time < this.windowMs) if (list.length === 0) this.failures.delete(key) else this.failures.set(key, list) return list } } /** * 校验请求来源与当前站点一致,阻止其他网站借用登录状态发起请求或建立 WebSocket。 * 没有 Origin 头(例如服务端脚本、curl)时放行,因为它们本来就带不上浏览器 Cookie。 * 反向代理可能改写 Host,所以同时接受 X-Forwarded-Host。 */ export function isSameOrigin( origin: string | null | undefined, hosts: Array, ) { if (!origin) return true let originHost: string try { originHost = new URL(origin).host.toLowerCase() } catch { return false } return hosts.some((host) => host ?.split(',') .map((item) => item.trim().toLowerCase()) .includes(originHost), ) }