feat(login): wy cookie 绑定与 qs 短信/sessionid 登录

wy 新增 POST /login/wy(cookie 须含 MUSIC_U,account/get 实测校验、失效
401,与 kg 假 token 同款语义);qs 新增 POST /login/qs/sms、/login/qs/sms/login
(官方 PC 客户端 WebInterfaceSdk 同款 send_code/sms_login,失败映射官方文案,
2046 需去汽水 App 完成安全验证)与 POST /login/qs(sessionid 手动绑定,
user_info 端点恒 16 无权限、不做上游校验)。smoke 补 6 项登录校验用例。
This commit is contained in:
snowy-yang committed 2026-10-04 17:31:38 +08:00
1 parent 6b05fde64b
commit 76fb812a50
4 files changed
+246 -2

No files matched your search

+22
View File
@@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8
return profile return profile
} }
/**
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
*/
export const loginByCookie = async(cookieInput: string) => {
const cookie = String(cookieInput || '').trim()
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
if (!body?.account?.id) {
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
}
const profile = {
userid: body.account.id,
nickname: body.profile?.nickname ?? null,
avatar: body.profile?.avatarUrl ?? null,
vipType: body.profile?.vipType ?? null,
}
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 当前账号资料(未登录返回 null) */ /** 当前账号资料(未登录返回 null) */
export const status = async() => { export const status = async() => {
if (!getWyCookie()) return null if (!getWyCookie()) return null
+92 -1
View File
@@ -272,7 +272,98 @@ export const status = (): { logged: boolean; updatedAt?: string } => {
/** 登录态 sessionid(未登录返回空串) */ /** 登录态 sessionid(未登录返回空串) */
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || '' export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
// ---------- 手机验证码登录(接口与官方 PC 客户端 login.asar 的 WebInterfaceSdk 同款)----------
// POST /passport/web/send_code/ { mobile: "86 13800138000", type: 24 }
// POST /passport/web/sms_login/ { mobile, code } → Set-Cookie sessionid
/** 官方 SDK 的 mobile 参数是 "区号 号码" 空格拼接(如 "86 13800138000") */
const normalizeMobile = (mobile: string, cc = '86'): string => {
const s = String(mobile || '').trim()
const withCc = s.match(/^(\d{1,3})[\s-]+(\d{4,15})$/)
if (withCc) return `${withCc[1]} ${withCc[2]}`
return `${cc} ${s.replace(/\D/g, '')}`
}
const smsUpstream = async (path: string, form: Record<string, string>, cookie = ''): Promise<{ data: Record<string, any>; message: string; headers: Record<string, any> }> => {
const query = new URLSearchParams(commonParams())
const request = httpFetch(`${PASSPORT_HOST}${path}?${query}`, {
method: 'post',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...webHeaders(cookie) },
body: new URLSearchParams(form).toString(),
timeout: 15000,
})
const { statusCode, headers, body } = await request.promise
if (statusCode != 200) throw Object.assign(new Error(`汽水接口 HTTP ${statusCode}`), { status: 502 })
return { data: (body as any)?.data ?? {}, message: String((body as any)?.message ?? ''), headers }
}
// send_code 下发的 cookie(验证码会话),sms_login 时带上
const smsCookies = new Map<string, string>()
export const sendSmsCode = async (mobile: string, cc?: string): Promise<{ sent: true; mobile: string }> => {
const norm = normalizeMobile(mobile, cc)
const { data, message, headers } = await smsUpstream('/passport/web/send_code/', {
mobile: norm,
type: '24', // 官方登录页固定 24(登录注册);16 为仅注册
})
if (message !== 'success') {
throw Object.assign(new Error(data.description || `验证码发送失败 (code=${data.error_code})`), { status: 400 })
}
smsCookies.set(norm, mergeCookies('', cookieMapFrom(headers)))
return { sent: true, mobile: norm }
}
// 官方登录页的失败文案映射(login-7a4b1afd.js catch 分支)
const smsFailMessage = (data: Record<string, any>): string => {
if (data.error_code === 1075) return '账号注销中,请前往抖音放弃注销'
if (data.error_code === 2046) return '需要二次验证,请在汽水 App 完成安全验证后重试'
return data.description || `登录失败 (code=${data.error_code})`
}
export const smsLogin = async (mobile: string, code: string, cc?: string): Promise<Record<string, any>> => {
const norm = normalizeMobile(mobile, cc)
const { data, message, headers } = await smsUpstream('/passport/web/sms_login/', {
mobile: norm,
code: String(code ?? '').trim(),
}, smsCookies.get(norm) || '')
let cookie = mergeCookies(smsCookies.get(norm) || '', cookieMapFrom(headers))
smsCookies.delete(norm)
// 成功但 sessionid 未随本响应下发时补跳 redirect_url(与扫码确认同款)
if (message === 'success' && !sessionIdOf(cookie) && data.redirect_url) {
try {
const hop = await httpFetch(String(data.redirect_url), { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise
cookie = mergeCookies(cookie, cookieMapFrom(hop.headers))
} catch {
// 补跳失败不致命:sessionid 缺失时按失败处理
}
}
const sessionid = sessionIdOf(cookie)
if (message === 'success' && sessionid) {
setCredentials('qs', { sessionid, cookie })
return { code: 803, message: '登录成功', errorCode: 0 }
}
throw Object.assign(new Error(message === 'success' ? '登录响应缺少 sessionid' : smsFailMessage(data)), { status: 400 })
}
/**
* 手动绑定 sessionid(汽水 App / 官方 PC 客户端抓包,或已登录浏览器 Cookie)。
* 接受裸 sessionid 值或完整 cookie 串(须含 sessionid= 字段);完整串优先整体保存,
* sid_guard 等伴随字段有利于延长登录态寿命。passport 无可用的免签校验接口
* (/passport/web/user_info/ 对本 aid 恒报 20016 无权限),不做上游验证,失效会在取流时暴露。
*/
export const loginBySessionId = async(input: string): Promise<{ logged: true; sessionid: string }> => {
const raw = String(input || '').trim()
const sessionid = sessionIdOf(raw) || (/^[\w-]{10,}$/.test(raw) ? raw : '')
if (!sessionid) throw Object.assign(new Error('需提供 sessionid 值或含 sessionid= 字段的 cookie'), { status: 422 })
const cookie = raw.includes('=') ? raw : `sessionid=${sessionid}`
setCredentials('qs', { sessionid, cookie })
return { logged: true, sessionid }
}
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */ /** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || '' export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus } export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus, sendSmsCode, smsLogin, loginBySessionId }
+83 -1
View File
@@ -1,4 +1,5 @@
// 登录路由 —— wy 扫码/手机号,kg 扫码/短信/token,tx 扫码/cookie 绑定,凭据持久化于 data/auth.json // 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,qs 扫码/短信/sessionid,
// xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data } // 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
import * as wyAuth from '../sdk/auth/wy.ts' import * as wyAuth from '../sdk/auth/wy.ts'
@@ -75,6 +76,26 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/wy', {
schema: {
summary: '绑定网易云 cookie',
description: 'cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。' +
'绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
try {
return await wyAuth.loginByCookie(request.body.cookie)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/wy', { fastify.delete('/login/wy', {
schema: { summary: '退出网易云登录', tags: ['登录'] }, schema: { summary: '退出网易云登录', tags: ['登录'] },
}, async() => { }, async() => {
@@ -323,6 +344,67 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/qs/sms', {
schema: {
summary: '发送汽水音乐登录短信验证码',
description: '手机号验证码登录第一步(官方 PC 客户端同款 /passport/web/send_code/,type=24)。' +
'mobile 支持 "13800138000" 或自带区号 "86 13800138000";海外号码用 cc 参数指定区号。' +
'发送成功后用 /login/qs/sms/login 提交验证码完成登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.sendSmsCode(request.body.mobile, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs/sms/login', {
schema: {
summary: '汽水音乐短信验证码登录',
description: '提交 /login/qs/sms 发送的验证码完成登录,成功自动保存凭据并返回 803。' +
'失败经 msg 返回官方文案(1200-1300 为验证码/手机号错误;2046 需去汽水 App 完成安全验证;1075 账号注销中)。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, code: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile', 'code'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.smsLogin(request.body.mobile, request.body.code, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs', {
schema: {
summary: '绑定汽水音乐 sessionid',
description: 'sessionid 从汽水 App / 官方 PC 客户端抓包获取(登录态 Cookie 的 sessionid 字段),' +
'也可传完整 cookie 串(须含 sessionid= 字段,整体保存)。无上游校验接口,不做有效性验证,' +
'失效会在取流时报错。推荐优先用 /login/qs/qr 扫码或 /login/qs/sms 短信登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { sessionid: { type: 'string' }, cookie: { type: 'string' } },
},
},
}, async(request, reply) => {
try {
return await qsPassport.loginBySessionId(request.body.sessionid || request.body.cookie)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/qs', { fastify.delete('/login/qs', {
schema: { summary: '退出汽水音乐登录', tags: ['登录'] }, schema: { summary: '退出汽水音乐登录', tags: ['登录'] },
}, async() => { }, async() => {
+49
View File
@@ -832,6 +832,20 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => {
return r.body.data.key.slice(0, 18) + '…' return r.body.data.key.slice(0, 18) + '…'
}) })
// wy cookie 绑定校验(缺 MUSIC_U 直接 422,不打上游)
await test('wy 坏 cookie 拒绝 POST /login/wy', async() => {
const r = await postJson('/login/wy', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// wy 失效 MUSIC_U 会被 account/get 实测拒绝(401,与 kg 假 token 同款语义)
await test('wy 假 cookie 拒绝 POST /login/wy', async() => {
const r = await postJson('/login/wy', { cookie: 'MUSIC_U=smoke_fake_music_u' })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
return r.body?.msg
})
await test('扫码二维码创建 POST /login/qs/qr', async() => { await test('扫码二维码创建 POST /login/qs/qr', async() => {
const r = await postJson('/login/qs/qr', {}) const r = await postJson('/login/qs/qr', {})
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
@@ -841,6 +855,41 @@ await test('扫码二维码创建 POST /login/qs/qr', async() => {
return `scanApp=${r.body.data.scanApp || '?'}` return `scanApp=${r.body.data.scanApp || '?'}`
}) })
// 短信发码:无效手机号应被上游业务拒绝(400 + 官方文案),而非 502 网关错
await test('sms 坏手机号拒绝 POST /login/qs/sms', async() => {
const r = await postJson('/login/qs/sms', { mobile: '10000000000' })
if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`)
return r.body.msg
})
// 短信登录:未发码直接提交,应被上游业务拒绝(400 + 官方文案)
await test('sms 错误验证码拒绝 POST /login/qs/sms/login', async() => {
const r = await postJson('/login/qs/sms/login', { mobile: '10000000000', code: '000000' })
if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`)
return r.body.msg
})
// qs 手动绑定:裸 sessionid 与完整 cookie 串两种形态均可绑,登录态生效;
// 无上游校验接口,测完解绑恢复匿名(避免假 sessionid 影响后续 qs 用例)
await test('qs sessionid 绑定 POST /login/qs', async() => {
const bare = await postJson('/login/qs', { sessionid: 'smoke_fake_sessionid_0123456789abcdef' })
if (bare.status !== 200 || bare.body?.code !== 0 || bare.body.data?.logged !== true) throw new Error(`HTTP ${bare.status} ${JSON.stringify(bare.body)}`)
const full = await postJson('/login/qs', { cookie: 'sid_guard=smoke; sessionid=smoke_fake_sessionid_0123456789abcdef' })
if (full.status !== 200 || full.body?.code !== 0) throw new Error(`HTTP ${full.status} ${JSON.stringify(full.body)}`)
const st = await get('/login/status')
if (st.code != 0 || st.data?.qs?.logged !== true) throw new Error('qs 登录状态未生效')
const del = await fetch(`${BASE}/login/qs`, { method: 'DELETE' })
if (del.status !== 200) throw new Error(`解绑失败 HTTP ${del.status}`)
return 'bare+cookie 两种形态可绑,已解绑'
})
// qs cookie 绑定校验(缺 sessionid 字段拒绝)
await test('qs 坏 cookie 拒绝 POST /login/qs', async() => {
const r = await postJson('/login/qs', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
await test('kg 假 token 拒绝 POST /login/kg', async() => { await test('kg 假 token 拒绝 POST /login/kg', async() => {
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)