feat(login): kg/tx 登录态自动续期(移植 lx-music-api-server 方案)

- 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 测试
This commit is contained in:
snowy-yang committed 2026-09-29 20:16:33 +08:00
1 parent 9d7cb6f39a
commit aca7928019
8 files changed
+300 -9

No files matched your search

+91 -1
View File
@@ -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')
+8
View File
@@ -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
}
+101
View File
@@ -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 } : {}) }
}