feat(auth): 统一 X-Server-Key 全路径鉴权(防扫描探测)+ per-IP 滑动窗口限流

This commit is contained in:
snowy-yang committed 2026-10-02 20:31:59 +08:00
1 parent 649ebbe12b
commit 415a04dea8
5 files changed
+106 -51

No files matched your search

+10 -21
View File
@@ -1,15 +1,11 @@
// 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)
// 客户端携带方式(任一即可):
// Authorization: Bearer <AUTH_KEY>(也接受裸值不带 Bearer 前缀)
// X-Api-Key: <AUTH_KEY>
// ?key=<AUTH_KEY>(查询参数,兼容播放器/下载器直接拉流无法带自定义头的场景)
// 豁免:/health、文档(/、/docs*、/redoc、/openapi.json)与 CORS 预检
// 验证头统一为 X-Server-Key: <AUTH_KEY>;全部路径(含文档页/健康检查/404)都要求秘钥,
// 未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
// 唯一例外:/stream/* 代理流额外接受 ?key=(播放器直接拉流无法携带自定义头)。
// CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
import crypto from 'node:crypto'
import type { FastifyRequest, FastifyReply } from 'fastify'
// 无敏感信息且会被监控系统高频探测的路径豁免鉴权
const PUBLIC_PATHS = [/^\/health$/, /^\/$/, /^\/docs(\/|$)/, /^\/redoc$/, /^\/openapi\.json$/]
const safeEqual = (a: string, b: string) => {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
@@ -19,22 +15,15 @@ const safeEqual = (a: string, b: string) => {
export const registerAuth = (fastify: any, authKey: string) => {
fastify.addHook('onRequest', async(request: FastifyRequest, reply: FastifyReply) => {
if (request.method === 'OPTIONS') return
// /stream/* 流媒体代理:X-Server-Key 或 ?key=(播放器拉流兼容)
const pathname = new URL(request.url, 'http://localhost').pathname
if (PUBLIC_PATHS.some(re => re.test(pathname))) return
const authorization = String(request.headers.authorization || '')
const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : authorization.trim()
const provided = [
bearer,
String(request.headers['x-api-key'] || ''),
String((request.query as any)?.key || ''),
].find(Boolean)
const provided = pathname.startsWith('/stream/')
? (String(request.headers['x-server-key'] || '') || String((request.query as any)?.key || ''))
: String(request.headers['x-server-key'] || '')
if (!provided || !safeEqual(provided, authKey)) {
return reply.code(401).send({
code: 401,
msg: '无效或缺少调用秘钥:请在 Authorization: Bearer <key>、X-Api-Key 头或 ?key= 参数中携带',
})
// 统一 401 文案(不区分路径存在与否,防扫描探测)
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带(流代理可用 ?key=)' })
}
})
}
+36
View File
@@ -0,0 +1,36 @@
// 全局限流 —— per-IP 滑动窗口(内存实现,无额外依赖)
// RATE_LIMIT 环境变量:每 IP 每分钟允许的请求数(默认 120;设 0 关闭)。
// 在鉴权之前计数(401 爆破秘钥同样受限);超限返回 429 + Retry-After。
// 未授权请求不区分路径统一计数,与 auth-gate 的防扫描策略一致。
import type { FastifyRequest, FastifyReply } from 'fastify'
const WINDOW_MS = 60_000
export const registerRateLimit = (fastify: any, limitPerMinute: number) => {
const hits = new Map<string, number[]>()
// 惰性清理:窗口滑动时顺带删除已过期 IP 的空记录,防内存无限增长
const cleanup = () => {
const threshold = Date.now() - WINDOW_MS
for (const [ip, times] of hits) {
const alive = times.filter(t => t > threshold)
if (alive.length) hits.set(ip, alive)
else hits.delete(ip)
}
}
const cleanupTimer = setInterval(cleanup, WINDOW_MS)
cleanupTimer.unref?.()
fastify.addHook('onRequest', async(request: FastifyRequest, reply: FastifyReply) => {
if (request.method === 'OPTIONS') return
const ip = request.ip || 'unknown'
const now = Date.now()
const times = (hits.get(ip) || []).filter(t => t > now - WINDOW_MS)
times.push(now)
hits.set(ip, times)
if (times.length > limitPerMinute) {
const retryAfter = Math.ceil((times[0] + WINDOW_MS - now) / 1000)
reply.header('Retry-After', String(Math.max(1, retryAfter)))
return reply.code(429).send({ code: 429, msg: `请求过于频繁(限 ${limitPerMinute} 次/分钟),请 ${Math.max(1, retryAfter)} 秒后重试` })
}
})
}
+11 -2
View File
@@ -80,8 +80,16 @@ fastify.addHook('onRoute', (routeOptions) => {
fastify.register(cors, { origin: '*' })
// ---- 全局限流(per-IP 滑动窗口,RATE_LIMIT=每分钟请求数,0 关闭;默认 120)----
// 先于鉴权计数:401 爆破秘钥同样受限
const rateLimit = Number(process.env.RATE_LIMIT ?? 120)
if (rateLimit > 0) {
const { registerRateLimit } = await import('./http/rate-limit.ts')
registerRateLimit(fastify, rateLimit)
}
// ---- 调用秘钥(AUTH_KEY 环境变量启用;未设置则不鉴权)----
// 详见 src/http/auth-gate.ts;文档/健康检查豁免,业务接口要求 Bearer / X-Api-Key / ?key=
// 统一 X-Server-Key 头,全部路径(含文档/健康检查/404)均要求秘钥,防扫描探测;详见 src/http/auth-gate.ts
const authKey = process.env.AUTH_KEY
if (authKey) {
const { registerAuth } = await import('./http/auth-gate.ts')
@@ -212,7 +220,8 @@ if (!useSource) {
export const startServer = async(port = PORT) => {
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
console.log(`音乐服务器已启动: http://localhost:${port}`)
console.log(`调用鉴权: ${authKey ? `已启用(AUTH_KEY,Bearer / X-Api-Key / ?key=)` : '未启用(设置 AUTH_KEY 环境变量开启)'}`)
console.log(`调用鉴权: ${authKey ? '已启用(AUTH_KEY,X-Server-Key 头)' : '未启用(设置 AUTH_KEY 环境变量开启)'}`)
console.log(`限流: ${rateLimit > 0 ? `${rateLimit} 次/分钟/IP(RATE_LIMIT 可调,0 关闭)` : '未启用'}`)
console.log(`取流通道: ${useSource ? '外部音源脚本(sources/,SOURCE_ENABLED)' : 'SDK 官方直连接口'}`)
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)