From aca7928019c95c10e2c325f0a09762e4da117514 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:16:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(login):=20kg/tx=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F=EF=BC=88=E7=A7=BB?= =?UTF-8?q?=E6=A4=8D=20lx-music-api-server=20=E6=96=B9=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kg: login_by_token 续期(AES-256-CBC + RSA 裸加密,参考其 refresh/kg.py),新 token 回写 data/auth.json - tx: 新增 sdk/auth/tx.ts —— cookie 解析、VIP 检测(vip_login_base)、 music.login.LoginServer 续期(走既有 musicu.fcg 未签名通道,最佳努力) - 调度:启动 10s 后首刷 + 每 24h 一次(对齐参考项目 86400s + next_run_time=now) - /login/tx 支持可选 App 抓包凭据(openId/accessToken/refreshToken/refreshKey) - 新增 POST /login/refresh 手动续期;/login/status 增补 tx 字段与更新时间 - 补充 /login/tx 文档行与 smoke 测试 --- docs/API.md | 6 ++- sdk/auth/kg.ts | 92 +++++++++++++++++++++++++++++++++++++++- sdk/auth/store.ts | 8 ++++ sdk/auth/tx.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++ src/refresh.ts | 54 +++++++++++++++++++++++ src/routes-login.ts | 28 +++++++++--- src/server.ts | 4 ++ test/smoke-test.ts | 16 ++++++- 8 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 sdk/auth/tx.ts create mode 100644 src/refresh.ts diff --git a/docs/API.md b/docs/API.md index 097390c..c6925ba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -85,9 +85,13 @@ | DELETE | `/login/wy` | 退出网易云登录 | | POST | `/login/kg` | 绑定酷狗 token;body 传 `{ "token": "...", "userid": ... }`(App 抓包获取),服务端向用户中心验证后存储 | | DELETE | `/login/kg` | 退出酷狗登录 | -| GET | `/login/status` | 登录状态(wy 实时校验 / kg 已存凭据) | +| POST | `/login/tx` | 绑定 QQ 音乐 cookie;body 传 `{ "cookie": "..." }`(需含 `uin` 与 `qm_keyst` 字段)。可选附 App 抓包的 `openId`/`accessToken`/`refreshToken`/`refreshKey` 供自动续期 | +| DELETE | `/login/tx` | 退出 QQ 音乐登录 | +| POST | `/login/refresh` | 手动触发 kg/tx 登录态续期(与自动任务同一逻辑;未登录平台跳过,单平台失败不影响其他) | +| GET | `/login/status` | 登录状态(wy 实时校验 / kg、tx 已存凭据,含 `vipType` 与最近更新时间) | - 登录态持久化于 `data/auth.json`(不入库),登录后取流与平台请求自动携带凭据。 +- **登录态自动续期**:服务器启动 10s 后自动对 kg/tx 执行一次续期,此后每 24h 一次(kg 走 `login_by_token` AES/RSA 协议换新 token;tx 走 `music.login.LoginServer`,仅浏览器 cookie 时可能被网关拒绝,需在 `/login/tx` 附 App 抓包的完整凭据才可靠),结果输出到控制台日志。 - VIP 歌曲完整播放地址暂不在支持范围;登录态目前用于提升请求稳定性与为后续能力预留。 ## 响应示例 diff --git a/sdk/auth/kg.ts b/sdk/auth/kg.ts index 6e55c84..b0763a7 100644 --- a/sdk/auth/kg.ts +++ b/sdk/auth/kg.ts @@ -5,6 +5,7 @@ import crypto from 'node:crypto' import { httpFetch } from '../common/request.ts' import { getCredentials, setCredentials } from './store.ts' +import type { KgCredentials } from './store.ts' const md5 = s => crypto.createHash('md5').update(s).digest('hex') const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d' @@ -39,7 +40,7 @@ export const getDevice = () => { } /** 网关移动端签名请求(gateway.kugou.com + x-router) */ -const gatewayRequest = async(url, { params: extraParams, data, router }) => { +const gatewayRequest = async(url, { params: extraParams, data, router, headers: extraHeaders = {} }) => { const dev = getDevice() const params = { dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER, @@ -55,6 +56,7 @@ const gatewayRequest = async(url, { params: extraParams, data, router }) => { 'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F', 'x-router': router, 'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi', + ...extraHeaders, }, body: data ?? undefined, timeout: 15000, @@ -96,6 +98,94 @@ export const loginByToken = async({ token, userid }) => { return profile } +// ---- token 自动续期 ---- +// login_by_token 协议(与 lx-music-api-server 对齐): +// p3 = AES({clienttime, token}),密钥由固定串派生;params = AES({})(随机密钥); +// pk = RSA 裸加密 {clienttime_ms, key: 随机密钥}(右侧零填充 128 字节); +// 成功响应 data.secu_params 用随机密钥 AES 解密得到新 token。 + +const AES_KEY_SEED = '90b8382a1bb4ccdcf063102053fd75b8' +const AES_IV = 'f063102053fd75b8' + +const aesCbcEncryptHex = (data: string, keyHex: string, ivHex: string) => { + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(keyHex, 'utf8'), Buffer.from(ivHex, 'utf8')) + return Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]).toString('hex') +} + +const aesEncryptRandom = (data: string | object) => { + const tempKey = randomString(16) + const keyHex = md5(tempKey) + return { + str: aesCbcEncryptHex(typeof data === 'string' ? data : JSON.stringify(data), keyHex, keyHex.slice(16, 32)), + key: tempKey, + } +} + +const aesDecryptHex = (hex: string, tempKey: string) => { + const keyHex = md5(tempKey) + const d = crypto.createDecipheriv('aes-256-cbc', Buffer.from(keyHex, 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8')) + const out = Buffer.concat([d.update(Buffer.from(hex, 'hex')), d.final()]) + const text = out.toString('utf8') + try { return JSON.parse(text) } catch { return text } +} + +/** RSA 裸加密(零填充 128 字节,与参考实现一致:负载在前、零在后) */ +const rsaEncryptHex = (data: object) => { + const payload = Buffer.from(JSON.stringify(data)) + if (payload.length > 128) throw new Error('RSA 负载超长') + const padded = Buffer.concat([payload, Buffer.alloc(128 - payload.length)]) + return crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex') +} + +/** + * 刷新 kg token(login_by_token),成功后写回凭据存储。 + * 无登录态时返回 { skipped: true };网关拒绝(token 已失效等)抛错。 + */ +export const refreshLogin = async() => { + const cred = getCredentials('kg') as KgCredentials | null + if (!cred?.token || cred.userid === '' || cred.userid === 0 || cred.userid === '0') { + return { skipped: true } + } + + const clienttime = Math.floor(Date.now() / 1000) + const p3 = aesCbcEncryptHex(JSON.stringify({ clienttime, token: cred.token }), md5(AES_KEY_SEED), AES_IV) + const enc = aesEncryptRandom({}) + const pk = rsaEncryptHex({ clienttime_ms: clienttime * 1000, key: enc.key }) + + const body = await gatewayRequest('/v5/login_by_token', { + router: 'login.user.kugou.com', + params: { clientver: 20349 }, + data: { + p3, + params: enc.str, + userid: Number(cred.userid) || 0, + need_toneinfo: 1, + clienttime_ms: clienttime * 1000, + dfid: '-', + dev: 'SDY-AN00', + plat: 1, + pk, + t1: '0', + gitversion: 'a23c277', + t2: '0', + t3: 'MCwwLDEsMSwwLDYsMSw2LDA=', + }, + headers: { + 'kg-thash': '6a6a1ba', + 'User-Agent': 'Android12-AndroidPhone-20349-201-0-ting#958959317/661004247-LOGIN-wifi', + 'support-calm': '1', + }, + }) + + if (body.error_code !== 0 || !body.data?.secu_params) { + throw new Error(`刷新失败(code ${body.error_code}${body.error ? `: ${body.error}` : ''})`) + } + const decrypted = aesDecryptHex(body.data.secu_params, enc.key) + if (!decrypted?.token) throw new Error('刷新响应解密失败') + setCredentials('kg', { token: decrypted.token }) + return { refreshed: true } +} + /** 当前 kg 登录资料(未登录返回 null) */ export const status = () => { const cred = getCredentials('kg') diff --git a/sdk/auth/store.ts b/sdk/auth/store.ts index 691e4da..bf7773e 100644 --- a/sdk/auth/store.ts +++ b/sdk/auth/store.ts @@ -34,6 +34,14 @@ export interface KgDevice { export interface TxCredentials { cookie: string + // App 抓包的完整凭据(可选):提供后可自动续期(music.login.LoginServer), + // 仅浏览器 cookie 时 qm_keyst 无法主动续期,只能做 VIP 检测 + openId?: string + accessToken?: string + refreshToken?: string + refreshKey?: string + uin?: string + vipType?: 'svip' | 'vip' | 'normal' | null updatedAt?: string } diff --git a/sdk/auth/tx.ts b/sdk/auth/tx.ts new file mode 100644 index 0000000..40cf8c3 --- /dev/null +++ b/sdk/auth/tx.ts @@ -0,0 +1,101 @@ +// QQ音乐凭据 —— cookie 解析、VIP 检测与登录态续期(music.login.LoginServer) +// 与 lx-music-api-server 的 refresh/tx.py 对齐,但走本项目既有的 musicu.fcg 未签名通道 +// (与 apis/tx.ts 一致);Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败, +// 此时保留原凭据并抛错,由调用方记录日志。 +// 续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试, +// 失败率较高;完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。 + +import { httpFetch } from '../common/request.ts' +import { getCredentials, setCredentials } from './store.ts' +import type { TxCredentials } from './store.ts' + +const HEADERS = { + 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', + Referer: 'https://y.qq.com/', +} + +/** 解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType) */ +export const parseCookie = (cookie: string) => ({ + uin: cookie.match(/uin=(\d+)/)?.[1] ?? '0', + musickey: cookie.match(/qm_keyst=([^;]+)/)?.[1] ?? '', + tmeLoginType: cookie.match(/tmeLoginType=(\d+)/)?.[1] ?? '', +}) + +const musicuRequest = async(data: object, cred: TxCredentials | null) => { + const r = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + method: 'post', + headers: { ...HEADERS, ...(cred?.cookie ? { cookie: cred.cookie } : {}) }, + body: data, + timeout: 15000, + }).promise + return r.body ?? {} +} + +/** + * 检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。 + * 成功返回 'svip' / 'vip' / 'normal';请求失败返回 null(不改动已存值)。 + */ +export const checkVip = async() => { + const cred = getCredentials('tx') as TxCredentials | null + if (!cred?.cookie) return null + const { uin } = parseCookie(cred.cookie) + const body = await musicuRequest({ + comm: { ct: '19', cv: '1859', uin }, + req: { + module: 'VipLogin.VipLoginInter', + method: 'vip_login_base', + param: {}, + }, + }, cred) + if (body.req?.code !== 0) return null + const identity = body.req.data?.identity ?? {} + const vipType = identity.HugeVip ? 'svip' : identity.LMFlag ? 'vip' : 'normal' + setCredentials('tx', { vipType }) + return vipType +} + +/** + * 续期 tx 登录态(music.login.LoginServer),成功后用新 musickey 替换 cookie 中的 + * qm_keyst 并写回凭据存储(附带 openId 等抓包字段时一并更新)。随后顺带检测 VIP。 + * 无登录态返回 { skipped: true };网关拒绝抛错。 + */ +export const refreshLogin = async() => { + const cred = getCredentials('tx') as TxCredentials | null + if (!cred?.cookie) return { skipped: true } + const { uin, musickey } = parseCookie(cred.cookie) + if (!musickey) throw new Error('cookie 缺少 qm_keyst 字段') + + const body = await musicuRequest({ + comm: { ct: '19', cv: '1859', uin }, + req: { + module: 'music.login.LoginServer', + method: 'Login', + param: { + ...{ musicid: Number(uin) || 0, musickey, loginMode: 2, expired_in: 0 }, + // App 抓包的完整凭据(存在才携带) + ...(cred.openId ? { openid: cred.openId } : {}), + ...(cred.accessToken ? { access_token: cred.accessToken } : {}), + ...(cred.refreshToken ? { refresh_token: cred.refreshToken } : {}), + ...(cred.refreshKey ? { refresh_key: cred.refreshKey } : {}), + }, + }, + }, cred) + + if (body.req?.code !== 0 || !body.req.data?.musickey) { + throw new Error(`刷新失败(code ${body.req?.code}${body.req?.msg ? `: ${body.req.msg}` : ''})`) + } + + const data = body.req.data + // musickey 可能含 $ 等字符,必须用函数替换(字符串替换的 $&/$' 会被转义展开) + const cookie = cred.cookie.replace(/qm_keyst=[^;]*/, () => `qm_keyst=${data.musickey}`) + setCredentials('tx', { + cookie, + uin: String(data.musicid ?? uin), + ...(data.openid ? { openId: String(data.openid) } : {}), + ...(data.access_token ? { accessToken: String(data.access_token) } : {}), + ...(data.refresh_key ? { refreshKey: String(data.refresh_key) } : {}), + }) + + const vipType = await checkVip().catch(() => null) + return { refreshed: true, ...(vipType ? { vipType } : {}) } +} diff --git a/src/refresh.ts b/src/refresh.ts new file mode 100644 index 0000000..5daf790 --- /dev/null +++ b/src/refresh.ts @@ -0,0 +1,54 @@ +// 登录态自动续期调度 —— 启动即刷一轮 + 每 24h 一次(与 lx-music-api-server 的 +// APScheduler interval 86400s + next_run_time=now 对齐) +// 各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。 + +import { refreshLogin as kgRefreshLogin } from '../sdk/auth/kg.ts' +import { refreshLogin as txRefreshLogin } from '../sdk/auth/tx.ts' + +const INTERVAL_MS = 24 * 60 * 60 * 1000 +// 启动后延迟触发,避开启动期与 smoke 测试的快速起停 +const STARTUP_DELAY_MS = 10_000 + +let running = false +let startupTimer: NodeJS.Timeout | null = null +let intervalTimer: NodeJS.Timeout | null = null + +const log = (msg: string) => console.log(`[${new Date().toISOString()}] [登录续期] ${msg}`) + +/** 手动/定时共用的续期入口,返回各平台结果(skipped / refreshed / error) */ +export const refreshAll = async(): Promise> => { + if (running) return { skipped: true } + running = true + try { + const result: Record = {} + for (const [name, fn] of [['kg', kgRefreshLogin], ['tx', txRefreshLogin]] as const) { + try { + const r = await fn() + result[name] = r + if (r.skipped) log(`${name}: 未登录,跳过`) + else log(`${name}: 登录态已刷新`) + } catch (err: any) { + result[name] = { error: err.message } + log(`${name}: ${err.message}`) + } + } + return result + } finally { + running = false + } +} + +/** 注册定时任务(须在 fastify.listen 之前调用以便挂 onClose 钩子) */ +export const startRefreshScheduler = (fastify) => { + fastify.addHook('onClose', () => { + if (startupTimer) clearTimeout(startupTimer) + if (intervalTimer) clearInterval(intervalTimer) + }) + startupTimer = setTimeout(() => { + void refreshAll() + intervalTimer = setInterval(() => void refreshAll(), INTERVAL_MS) + intervalTimer.unref() + }, STARTUP_DELAY_MS) + startupTimer.unref() + log(`已启动自动续期(启动后 ${STARTUP_DELAY_MS / 1000}s 首刷,此后每 ${INTERVAL_MS / 3600000}h 一次)`) +} diff --git a/src/routes-login.ts b/src/routes-login.ts index 23f15e2..f64ae9c 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -4,6 +4,7 @@ import * as wyAuth from '../sdk/auth/wy.ts' import * as kgAuth from '../sdk/auth/kg.ts' import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts' +import { refreshAll } from './refresh.ts' const fail = (reply, err) => { const status = err.status ?? 502 @@ -107,12 +108,17 @@ export const loginRoutes = async(fastify) => { fastify.post('/login/tx', { schema: { summary: '绑定 QQ 音乐 cookie', - description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。', + description: 'cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),用于解锁 tx 取流的 320k/无损音质。' + + '可选提供 App 抓包的 openId/accessToken/refreshToken/refreshKey,供定时任务自动续期登录态。', tags: ['登录'], body: { type: 'object', properties: { cookie: { type: 'string' }, + openId: { type: 'string' }, + accessToken: { type: 'string' }, + refreshToken: { type: 'string' }, + refreshKey: { type: 'string' }, }, required: ['cookie'], }, @@ -123,7 +129,8 @@ export const loginRoutes = async(fastify) => { if (!uin || !/qm_keyst=\S+/.test(cookie)) { return reply.code(422).send({ code: 422, msg: 'cookie 需包含 uin 与 qm_keyst 字段' }) } - setCredentials('tx', { cookie }) + const { openId, accessToken, refreshToken, refreshKey } = request.body + setCredentials('tx', { cookie, uin, openId, accessToken, refreshToken, refreshKey }) return { logged: true, uin } }) @@ -136,19 +143,30 @@ export const loginRoutes = async(fastify) => { // ---------- 通用 ---------- + fastify.post('/login/refresh', { + schema: { + summary: '手动触发登录态续期', + description: '对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。' + + '未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。', + tags: ['登录'], + }, + }, async() => refreshAll()) + fastify.get('/login/status', { schema: { summary: '查看登录状态', - description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据。', + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间)。', tags: ['登录'], }, }, async() => { const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())]) const tx = getTxAuth() + const txCred = getCredentials('tx') as { vipType?: string; updatedAt?: string } | null + const kgCred = getCredentials('kg') as { updatedAt?: string } | null return { wy: wy ? { logged: true, ...wy } : { logged: false }, - kg: kg ? { logged: true, ...kg } : { logged: false }, - tx: tx ? { logged: true, uin: tx.uin } : { logged: false }, + kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false }, + tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false }, } }) } diff --git a/src/server.ts b/src/server.ts index fa89f2a..afc7bb4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,7 @@ import musicSdk from '../sdk/index.ts' import { ApiError } from './http/api-error.ts' import { musicRoutes } from './routes.ts' import { loginRoutes } from './routes-login.ts' +import { startRefreshScheduler } from './refresh.ts' import { COMPONENT_SCHEMAS } from './openapi/components.ts' import { redocHtml } from './openapi/ui.ts' @@ -184,6 +185,9 @@ fastify.get('/', { fastify.register(musicRoutes) fastify.register(loginRoutes) +// 登录态自动续期(onClose 钩子需在 listen 前注册,故这里而非 startServer 内启动) +startRefreshScheduler(fastify) + export const startServer = async(port = PORT) => { await fastify.listen({ port: Number(port), host: '0.0.0.0' }) console.log(`音乐服务器已启动: http://localhost:${port}`) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 8ba4c56..e158e9b 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -377,8 +377,20 @@ await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() => await test('登录状态 /login/status', async() => { const r = await get('/login/status') if (r.code != 0) throw new Error(r.msg) - if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整') - return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}` + if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean' || typeof r.data.tx?.logged !== 'boolean') { + throw new Error('响应不完整') + } + return `wy:${r.data.wy.logged} kg:${r.data.kg.logged} tx:${r.data.tx.logged}` +}) + +await test('登录态续期 POST /login/refresh', async() => { + const r = await postJson('/login/refresh', {}) + if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) + // 各平台结果为 skipped(未登录)/ refreshed(续期成功)/ error(网关拒绝)之一 + const valid = (v: any) => v && (v.skipped || v.refreshed || v.error) + if (!valid(r.body.data?.kg) || !valid(r.body.data?.tx)) throw new Error(`响应结构异常: ${JSON.stringify(r.body.data)}`) + const brief = (v: any) => v.skipped ? 'skipped' : v.refreshed ? 'refreshed' : `error(${v.error})` + return `kg:${brief(r.body.data.kg)} tx:${brief(r.body.data.tx)}` }) await test('扫码二维码创建 POST /login/wy/qr', async() => {