Files
boundless/server/auth.ts
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

145 lines
4.3 KiB
TypeScript

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<string, number[]>()
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<string | null | undefined>,
) {
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),
)
}