feat(auth): 流代理升级短时签名 URL——?exp=&sig=(HMAC 派生 10 分钟有效)替代裸 ?key=,秘钥不再落入 URL/日志
This commit is contained in:
1 parent
415a04dea8
commit
cf0874fc15
5 files changed
+78
-21
No files matched your search
+17
-6
@@ -1,10 +1,14 @@
|
||||
// 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)
|
||||
// 验证头统一为 X-Server-Key: <AUTH_KEY>;全部路径(含文档页/健康检查/404)都要求秘钥,
|
||||
// 未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
|
||||
// 唯一例外:/stream/* 代理流额外接受 ?key=(播放器直接拉流无法携带自定义头)。
|
||||
// CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
|
||||
//
|
||||
// /stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
|
||||
// 改用短时签名(?exp=&sig=,见 stream-sign.ts)——不落裸秘钥到 URL/日志,签名泄露仅能
|
||||
// 在时效窗口内拉当条流;能带头的客户端(curl 等)仍可用 X-Server-Key。
|
||||
import crypto from 'node:crypto'
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { verifyStreamSign } from './stream-sign.ts'
|
||||
|
||||
const safeEqual = (a: string, b: string) => {
|
||||
const bufA = Buffer.from(a)
|
||||
@@ -15,15 +19,22 @@ 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
|
||||
const provided = pathname.startsWith('/stream/')
|
||||
? (String(request.headers['x-server-key'] || '') || String((request.query as any)?.key || ''))
|
||||
: String(request.headers['x-server-key'] || '')
|
||||
|
||||
// /stream/* 流媒体代理:X-Server-Key 优先,或短时签名 ?exp=&sig=(播放器拉流兼容)
|
||||
if (pathname.startsWith('/stream/')) {
|
||||
const header = String(request.headers['x-server-key'] || '')
|
||||
const q = request.query as any
|
||||
const ok = (header && safeEqual(header, authKey))
|
||||
|| (!header && verifyStreamSign(authKey, pathname, q?.exp, q?.sig))
|
||||
if (ok) return
|
||||
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名' })
|
||||
}
|
||||
|
||||
const provided = String(request.headers['x-server-key'] || '')
|
||||
if (!provided || !safeEqual(provided, authKey)) {
|
||||
// 统一 401 文案(不区分路径存在与否,防扫描探测)
|
||||
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带(流代理可用 ?key=)' })
|
||||
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 流代理签名 —— /stream/* 的无头客户端兼容方案(播放器/下载器直接 GET,无法携带自定义头)
|
||||
// /url 返回流地址时附加 ?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>
|
||||
// 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// 流地址有效期(毫秒),与 qs 流缓存 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
|
||||
export const STREAM_SIGN_TTL = 10 * 60_000
|
||||
|
||||
const hmac = (authKey: string, data: string) =>
|
||||
crypto.createHmac('sha256', authKey).update(data).digest('hex').slice(0, 32)
|
||||
|
||||
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/qs/123) */
|
||||
export const signStream = (authKey: string, path: string) => {
|
||||
const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000)
|
||||
return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}`
|
||||
}
|
||||
|
||||
/** 校验流地址签名(timingSafeEqual + 时效窗口) */
|
||||
export const verifyStreamSign = (authKey: string, path: string, exp: unknown, sig: unknown): boolean => {
|
||||
const expNum = Number(exp)
|
||||
const sigStr = String(sig || '')
|
||||
if (!Number.isFinite(expNum) || !sigStr) return false
|
||||
if (Date.now() / 1000 > expNum) return false // 过期即拒(客户端重新 /url 取新地址)
|
||||
const expected = Buffer.from(hmac(authKey, `${path}:${expNum}`))
|
||||
const provided = Buffer.from(sigStr)
|
||||
return expected.length === provided.length && crypto.timingSafeEqual(expected, provided)
|
||||
}
|
||||
+9
-4
@@ -4,6 +4,7 @@
|
||||
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
||||
|
||||
import { notFound, upstream } from './http/api-error.ts'
|
||||
import { signStream } from './http/stream-sign.ts'
|
||||
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
|
||||
|
||||
const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||
@@ -137,10 +138,14 @@ export const musicRoutes = async(fastify: any) => {
|
||||
const finish = (res: any, via: string) => {
|
||||
if (!res?.url) throw new Error('获取播放地址失败')
|
||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
|
||||
// 启用 AUTH_KEY 时附带 ?key=(播放器直接拉流无法携带自定义头)
|
||||
const url = String(res.url).startsWith('/')
|
||||
? `${request.protocol}://${request.headers.host}${res.url}${process.env.AUTH_KEY ? `${String(res.url).includes('?') ? '&' : '?'}key=${encodeURIComponent(process.env.AUTH_KEY)}` : ''}`
|
||||
: res.url
|
||||
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
|
||||
let url = String(res.url)
|
||||
if (url.startsWith('/')) {
|
||||
const suffix = process.env.AUTH_KEY
|
||||
? `${url.includes('?') ? '&' : '?'}${signStream(process.env.AUTH_KEY, url.split('?')[0])}`
|
||||
: ''
|
||||
url = `${request.protocol}://${request.headers.host}${url}${suffix}`
|
||||
}
|
||||
// type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道
|
||||
return { type: res.type || quality, url, ...(via !== source ? { via } : {}) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user