From 415a04dea8f678bd92a004b237b7e6486df00ab3 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:31:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=BB=9F=E4=B8=80=20X-Server-Key?= =?UTF-8?q?=20=E5=85=A8=E8=B7=AF=E5=BE=84=E9=89=B4=E6=9D=83=EF=BC=88?= =?UTF-8?q?=E9=98=B2=E6=89=AB=E6=8F=8F=E6=8E=A2=E6=B5=8B=EF=BC=89+=20per-I?= =?UTF-8?q?P=20=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++----- src/http/auth-gate.ts | 31 +++++++--------------- src/http/rate-limit.ts | 36 +++++++++++++++++++++++++ src/server.ts | 13 +++++++-- test/smoke-test.ts | 60 +++++++++++++++++++++++++++--------------- 5 files changed, 106 insertions(+), 51 deletions(-) create mode 100644 src/http/rate-limit.ts diff --git a/README.md b/README.md index f5f8ff3..250f2e7 100644 --- a/README.md +++ b/README.md @@ -74,19 +74,22 @@ pnpm typecheck # 仅类型检查,不运行 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 -## 调用鉴权(AUTH_KEY) +## 调用鉴权与限流 -设置环境变量 `AUTH_KEY` 后,全部业务接口要求携带调用秘钥(未设置则不鉴权): +### 调用秘钥(AUTH_KEY) + +设置环境变量 `AUTH_KEY` 后,**全部路径**(含文档页、健康检查、不存在的路径)都要求调用秘钥——未授权请求统一返回 401,不暴露 404/401 差异,防止接口被扫描探测。未设置则不鉴权。 ``` -Authorization: Bearer # 亦接受裸值 -X-Api-Key: -?key= # 查询参数(播放器直接拉流场景) +X-Server-Key: # 统一验证头 +?key= # 仅 /stream/* 流代理接受(播放器拉流无法带自定义头) ``` -豁免路径:`/health`、`/`、`/docs`、`/redoc`、`/openapi.json`。秘钥错误返回 401。 -启用鉴权时 `/url` 返回的代理流地址(如 qs 解密流)会自动附带 `?key=`,播放器可直接拉流。 +启用鉴权时 `/url` 返回的代理流地址(如 qs 解密流)会自动附带 `?key=`。 +### 限流(RATE_LIMIT) + +per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。 ## 外部音源 diff --git a/src/http/auth-gate.ts b/src/http/auth-gate.ts index cb90bf9..9b09453 100644 --- a/src/http/auth-gate.ts +++ b/src/http/auth-gate.ts @@ -1,15 +1,11 @@ // 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权) -// 客户端携带方式(任一即可): -// Authorization: Bearer (也接受裸值不带 Bearer 前缀) -// X-Api-Key: -// ?key=(查询参数,兼容播放器/下载器直接拉流无法带自定义头的场景) -// 豁免:/health、文档(/、/docs*、/redoc、/openapi.json)与 CORS 预检 +// 验证头统一为 X-Server-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 、X-Api-Key 头或 ?key= 参数中携带', - }) + // 统一 401 文案(不区分路径存在与否,防扫描探测) + return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带(流代理可用 ?key=)' }) } }) } diff --git a/src/http/rate-limit.ts b/src/http/rate-limit.ts new file mode 100644 index 0000000..3af9759 --- /dev/null +++ b/src/http/rate-limit.ts @@ -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() + // 惰性清理:窗口滑动时顺带删除已过期 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)} 秒后重试` }) + } + }) +} diff --git a/src/server.ts b/src/server.ts index 7910f8b..e219dc9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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`) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index daa3e41..a6b78c3 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -94,7 +94,9 @@ const startLocalServer = (port: number, envExtra: Record = {}) = }, 10000) const check = () => { - http.get(`http://localhost:${port}/health`, res => { + // 启用 AUTH_KEY 的实例全路径要求秘钥,探活也须携带 + const headers = envExtra.AUTH_KEY ? { 'X-Server-Key': envExtra.AUTH_KEY } : {} + http.get(`http://localhost:${port}/health`, { headers }, res => { res.resume() if (res.statusCode === 200) { clearTimeout(timer) @@ -608,7 +610,7 @@ await test('登录状态含 xm 字段', async() => { return `xm.logged=${r.data.xm.logged}` }) -// ---- 调用秘钥鉴权(独立实例带 AUTH_KEY 起测;主实例未设 key 时本组自动跳过)---- +// ---- 调用秘钥鉴权 + 限流(独立实例带 AUTH_KEY/RATE_LIMIT 起测;主实例未设时本组自动跳过)---- if (!externalServer && !process.env.AUTH_KEY) { const AUTH_TEST_KEY = 'smoke-test-secret-key' const authPort = await new Promise(resolve => { @@ -621,38 +623,54 @@ if (!externalServer && !process.env.AUTH_KEY) { const authBase = `http://localhost:${authPort}` const authServer: any = await startLocalServer(authPort, { AUTH_KEY: AUTH_TEST_KEY }) const authFetch = (path: string, init: RequestInit = {}) => fetch(authBase + path, init) + const keyHeaders = { 'X-Server-Key': AUTH_TEST_KEY } await test('AUTH_KEY 无秘钥 → 401', async() => { const r = await authFetch('/sources') if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) return 'ok' }) - await test('AUTH_KEY 错误秘钥 → 401', async() => { - const r = await authFetch('/sources', { headers: { Authorization: 'Bearer wrong-key' } }) - if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) + await test('AUTH_KEY 旧方式(Bearer/X-Api-Key)不再生效 → 401', async() => { + const r = await authFetch('/sources', { headers: { Authorization: `Bearer ${AUTH_TEST_KEY}`, 'X-Api-Key': AUTH_TEST_KEY } }) + if (r.status !== 401) throw new Error(`期望 401(只认 X-Server-Key),实际 ${r.status}`) return 'ok' }) - await test('AUTH_KEY Bearer 通过', async() => { - const r = await authFetch('/sources', { headers: { Authorization: `Bearer ${AUTH_TEST_KEY}` } }) + await test('AUTH_KEY X-Server-Key 通过', async() => { + const r = await authFetch('/sources', { headers: keyHeaders }) if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) return 'ok' }) - await test('AUTH_KEY X-Api-Key 通过', async() => { - const r = await authFetch('/sources', { headers: { 'X-Api-Key': AUTH_TEST_KEY } }) - if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) - return 'ok' - }) - await test('AUTH_KEY ?key= 通过', async() => { - const r = await authFetch(`/sources?key=${encodeURIComponent(AUTH_TEST_KEY)}`) - if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) - return 'ok' - }) - await test('AUTH_KEY /health 豁免', async() => { - const r = await authFetch('/health') - if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) - return 'ok' + // 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性 + for (const path of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) { + const r = await authFetch(path) + if (r.status !== 401) throw new Error(`${path} 期望 401,实际 ${r.status}(应全路径要求秘钥)`) + } + await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => 'ok') + // /stream/* 流代理额外接受 ?key=(播放器拉流兼容),其它路径 ?key= 无效 + await test('AUTH_KEY ?key= 仅限 /stream/*', async() => { + const normal = await authFetch(`/sources?key=${AUTH_TEST_KEY}`) + if (normal.status !== 401) throw new Error(`普通路径 ?key= 应 401,实际 ${normal.status}`) + const stream = await authFetch(`/stream/qs/123?key=${AUTH_TEST_KEY}`) + // 带对 key 的 stream 请求应通过鉴权(后续业务可能 502,但不能是 401) + if (stream.status === 401) throw new Error('stream ?key= 应通过鉴权') + return `stream 状态 ${stream.status}(过鉴权)` }) + // 限流(独立低阈值实例验证 429) + const rlPort = authPort + 1 + const rlBase = `http://localhost:${rlPort}` + const rlServer: any = await startLocalServer(rlPort, { AUTH_KEY: AUTH_TEST_KEY, RATE_LIMIT: '3' }) + await test('RATE_LIMIT 超限 → 429', async() => { + const statuses: number[] = [] + for (let i = 0; i < 6; i++) { + const r = await fetch(`${rlBase}/sources`, { headers: { 'X-Server-Key': AUTH_TEST_KEY } }) + statuses.push(r.status) + } + if (!statuses.some(s => s === 429)) throw new Error(`6 连发应触发 429,实际 ${statuses.join(',')}`) + return `状态序列 ${statuses.join(',')}` + }) + + rlServer.kill() authServer.kill() }