feat(login): 新增网易云扫码/手机号登录与酷狗 token 绑定

This commit is contained in:
snowy-yang committed 2026-09-18 19:15:11 +08:00
1 parent fd5e2122d3
commit 45f3a4b989
15 files changed
+716 -3

No files matched your search

+3 -1
View File
@@ -4,6 +4,7 @@
// promise resolve → { type, url }
import { httpFetch } from '../common/request.js'
import { getKgAuth } from '../auth/store.js'
let mid = ''
@@ -16,7 +17,8 @@ const getMid = () => {
}
const getPlayUrl = (hash, albumId) => getMid().then(m => {
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}`
const auth = getKgAuth()
const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}${auth ? `&token=${encodeURIComponent(auth.token)}&userid=${auth.userid}` : ''}`
return httpFetch(url, {
method: 'get',
headers: {
+3
View File
@@ -6,6 +6,7 @@
import { httpFetch } from '../common/request.js'
import { eapi } from '../../musicSdk/wy/utils/crypto.ts'
import { getWyCookie } from '../auth/store.js'
const levelMap = {
'128k': 'standard',
@@ -21,11 +22,13 @@ export const getMusicUrl = (songInfo, type) => {
level: levelMap[type] || 'standard',
encodeType: 'flac',
}
const cookie = getWyCookie()
const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
},
form: eapi('/api/song/enhance/player/url/v1', data),
timeout: 15000,
+105
View File
@@ -0,0 +1,105 @@
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
// token 可从酷狗 App 抓包获取(login.token)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js'
import { getCredentials, setCredentials } from './store.js'
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
const APPID = 1005
const CLIENTVER = 11430
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----`
const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString()
const randomString = (n) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
/** 设备标识持久化在凭据里,保证同一登录态的请求指纹稳定 */
const device = () => {
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
if (!dev.mid) dev.mid = calculateMid(dev.guid)
setCredentials('_kg_device', dev)
return dev
}
const androidSign = (params, data = '') => {
const s = Object.keys(params).sort()
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
.join('')
return md5(SALT_SIGN + s + data + SALT_SIGN)
}
/** 网关移动端签名请求(gateway.kugou.com + x-router) */
const gatewayRequest = async(url, { params: extraParams, data, router }) => {
const dev = device()
const mid = dev.mid || (dev.mid = calculateMid(dev.guid))
setCredentials('_kg_device', dev)
const params = {
dfid: dev.dfid, mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
...extraParams,
}
const signature = androidSign(params, data ? JSON.stringify(data) : '')
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'x-router': router,
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
body: data ?? undefined,
timeout: 15000,
}).promise
return r.body ?? {}
}
/**
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
* p = RSA 原始加密(零填充){ token, clienttime }
*/
export const verifyToken = async({ token, userid }) => {
const unixSec = Math.floor(Date.now() / 1000)
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
// 原始 RSA(无填充):左侧零填充到密钥长度(128 字节)
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload])
const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
const body = await gatewayRequest('/v3/get_my_info', {
router: 'usercenter.kugou.com',
params: { plat: 1 },
data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 },
})
if (body.status !== 1 || !body.data) {
const err = new Error(body.error ?? 'token 无效或已过期')
err.status = 401
throw err
}
return {
userid: body.data.userid ?? Number(userid),
nickname: body.data.nickname ?? body.data.username ?? null,
vip: body.data.vip ? Boolean(body.data.vip) : null,
}
}
/** 绑定并验证 kg token */
export const loginByToken = async({ token, userid }) => {
const profile = await verifyToken({ token, userid })
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
return profile
}
/** 当前 kg 登录资料(未登录返回 null) */
export const status = () => {
const cred = getCredentials('kg')
return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null
}
+29
View File
@@ -0,0 +1,29 @@
// src/auth/store.js 的类型声明(实现为同目录 store.js)
export interface WyCredentials {
cookie: string
musicU: string
userid?: number
nickname?: string | null
avatar?: string | null
vipType?: number | null
updatedAt?: string
}
export interface KgCredentials {
token: string
userid: number | string
nickname?: string | null
updatedAt?: string
}
interface KgDevice {
guid: string
mid: string
dfid: string
}
export declare function getCredentials(platform: 'wy' | 'kg' | '_kg_device'): WyCredentials | KgCredentials | KgDevice | null
export declare function setCredentials(platform: 'wy' | 'kg' | '_kg_device', credentials: object): void
export declare function clearCredentials(platform: 'wy' | 'kg' | '_kg_device'): void
export declare function getWyCookie(): string
export declare function getKgAuth(): { token: string; userid: number | string } | null
+50
View File
@@ -0,0 +1,50 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: { cookie, musicU, userid?, nickname?, updatedAt }, kg: { token, userid, nickname?, updatedAt } }
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
let cache = null
const load = () => {
if (!cache) {
try {
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
} catch {
cache = {}
}
}
return cache
}
const save = (data) => {
cache = data
mkdirSync(dirname(DATA_FILE), { recursive: true })
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
}
export const getCredentials = (platform) => load()[platform] ?? null
export const setCredentials = (platform, credentials) => {
const data = load()
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
save(data)
}
export const clearCredentials = (platform) => {
const data = load()
delete data[platform]
save(data)
}
/** wy 登录 cookie 字符串(未登录返回空串) */
export const getWyCookie = () => getCredentials('wy')?.cookie || ''
/** kg 登录态(未登录返回 null) */
export const getKgAuth = () => {
const cred = getCredentials('kg')
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
}
+120
View File
@@ -0,0 +1,120 @@
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.js'
import { weapi } from '../../musicSdk/wy/utils/crypto.ts'
import { getWyCookie, setCredentials } from './store.js'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
const requestWeapi = async(path, data, cookie = getWyCookie()) => {
const r = await httpFetch(`https://music.163.com/weapi${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
...(cookie ? { cookie } : {}),
},
form: weapi(data),
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */
const requestApi = async(path, data) => {
const r = await httpFetch(`https://music.163.com${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
},
form: data,
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */
const extractLoginCookie = (setCookie) => {
const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ')
const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1]
return { cookie: joined, musicU }
}
/** 登录成功后拉取账号资料 */
const fetchProfile = async() => {
const { body } = await requestWeapi('/api/w/nuser/account/get', {})
return {
userid: body?.account?.id ?? null,
nickname: body?.profile?.nickname ?? null,
avatar: body?.profile?.avatarUrl ?? null,
vipType: body?.profile?.vipType ?? null,
}
}
/** 应用登录 cookie(成功后调用) */
const applyLogin = async(setCookie) => {
const { cookie, musicU } = extractLoginCookie(setCookie)
if (!musicU) throw new Error('登录响应中缺少 MUSIC_U')
const profile = await fetchProfile()
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 创建扫码登录 key 与二维码内容 */
export const qrKey = async() => {
const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 })
if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败')
const key = body.unikey
const qrurl = `https://music.163.com/login?codekey=${key}`
const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
return { key, qrurl, qrimg }
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
*/
export const qrCheck = async(key) => {
const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 })
if (body.code === 803) {
const profile = await applyLogin(setCookie)
return { code: 803, message: '登录成功', profile }
}
const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' }
return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' }
}
/**
* 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha)
*/
export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => {
const data = {
type: '1',
https: 'true',
phone,
countrycode,
remember: 'true',
[captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'),
}
const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data)
if (body.code !== 200) {
const err = new Error(body.message ?? body.msg ?? '登录失败')
err.status = 401
throw err
}
const profile = await applyLogin(setCookie)
return profile
}
/** 当前账号资料(未登录返回 null) */
export const status = async() => {
if (!getWyCookie()) return null
return fetchProfile()
}
+120
View File
@@ -0,0 +1,120 @@
// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
import * as wyAuth from './auth/wy.js'
import * as kgAuth from './auth/kg.js'
import { clearCredentials, getCredentials } from './auth/store.js'
const fail = (reply, err) => {
const status = err.status ?? 502
return reply.code(status).send({ code: status, msg: err.message })
}
/**
* 注册登录相关路由
* @param {import('fastify').FastifyInstance} fastify
*/
export const loginRoutes = async(fastify) => {
// ---------- 网易云 ----------
fastify.post('/login/wy/qr', {
schema: {
summary: '获取网易云扫码登录二维码',
description: '用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。',
tags: ['登录'],
},
}, async() => wyAuth.qrKey())
fastify.post('/login/wy/qr/check', {
schema: {
summary: '轮询网易云扫码状态',
description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。',
tags: ['登录'],
body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
},
}, async(request, reply) => {
try {
return await wyAuth.qrCheck(request.body.key)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/wy/cellphone', {
schema: {
summary: '网易云手机号登录',
description: 'password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。',
tags: ['登录'],
body: {
type: 'object',
properties: {
phone: { type: 'string' },
password: { type: 'string' },
captcha: { type: 'string' },
countrycode: { type: 'string', default: '86' },
},
required: ['phone'],
},
},
}, async(request, reply) => {
try {
return await wyAuth.cellphoneLogin(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/wy', {
schema: { summary: '退出网易云登录', tags: ['登录'] },
}, async() => {
clearCredentials('wy')
return { logged: false }
})
// ---------- 酷狗 ----------
fastify.post('/login/kg', {
schema: {
summary: '绑定酷狗 token',
description: 'token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。手机号/扫码登录暂未实现。',
tags: ['登录'],
body: {
type: 'object',
properties: {
token: { type: 'string' },
userid: { type: ['integer', 'string'] },
},
required: ['token', 'userid'],
},
},
}, async(request, reply) => {
try {
return await kgAuth.loginByToken(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/kg', {
schema: { summary: '退出酷狗登录', tags: ['登录'] },
}, async() => {
clearCredentials('kg')
return { logged: false }
})
// ---------- 通用 ----------
fastify.get('/login/status', {
schema: {
summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg 为已存凭据。',
tags: ['登录'],
},
}, async() => {
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
return {
wy: wy ? { logged: true, ...wy } : { logged: false },
kg: kg ? { logged: true, ...kg } : { logged: false },
}
})
}
+3
View File
@@ -29,6 +29,7 @@ import { dirname, join } from 'node:path'
import musicSdk from '../musicSdk/index.js'
import { ApiError } from './http/api-error.js'
import { musicRoutes } from './routes.js'
import { loginRoutes } from './routes-login.js'
import { COMPONENT_SCHEMAS } from './openapi/components.js'
import { redocHtml } from './openapi/ui.js'
@@ -144,6 +145,7 @@ fastify.register(swagger, {
{ name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' },
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg token)与状态' },
{ name: '系统', description: '健康检查、平台信息、文档' },
],
},
@@ -180,6 +182,7 @@ fastify.get('/', {
// ---- 业务路由 ----
fastify.register(musicRoutes)
fastify.register(loginRoutes)
export const startServer = async(port = PORT) => {
await fastify.listen({ port, host: '0.0.0.0' })
+41
View File
@@ -42,6 +42,25 @@ const postRaw = (path) => new Promise((resolve, reject) => {
req.end()
})
// JSON POST(登录等写入接口)
const postJson = (path, body) => new Promise((resolve, reject) => {
const payload = JSON.stringify(body)
const req = http.request(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
}, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => resolve({
status: res.statusCode,
body: (() => { try { return JSON.parse(data) } catch { return null } })(),
}))
})
req.on('error', reject)
req.write(payload)
req.end()
})
const test = async(name, fn) => {
try {
const result = await fn()
@@ -360,6 +379,28 @@ await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() =>
return spec.openapi
})
// ---- 登录 ----
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}`
})
await test('扫码二维码创建 POST /login/wy/qr', async() => {
const r = await postJson('/login/wy/qr', {})
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整')
return r.body.data.key.slice(0, 18) + '…'
})
await test('kg 假 token 拒绝 POST /login/kg', async() => {
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
return r.body?.msg
})
// 汇总
const passed = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok)