diff --git a/sdk/auth/wy.ts b/sdk/auth/wy.ts index 96bff78..0779489 100644 --- a/sdk/auth/wy.ts +++ b/sdk/auth/wy.ts @@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8 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) */ export const status = async() => { if (!getWyCookie()) return null diff --git a/sdk/qs/passport.ts b/sdk/qs/passport.ts index 2d9d225..5147f74 100644 --- a/sdk/qs/passport.ts +++ b/sdk/qs/passport.ts @@ -272,7 +272,98 @@ export const status = (): { logged: boolean; updatedAt?: string } => { /** 登录态 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, cookie = ''): Promise<{ data: Record; message: string; headers: Record }> => { + 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() + +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 => { + 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> => { + 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 只是最小集) */ 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 } diff --git a/src/routes-login.ts b/src/routes-login.ts index 673aa50..ea1955b 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -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 } 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', { schema: { summary: '退出网易云登录', tags: ['登录'] }, }, 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', { schema: { summary: '退出汽水音乐登录', tags: ['登录'] }, }, async() => { diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 3ca2852..2546b6b 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -832,6 +832,20 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => { 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() => { const r = await postJson('/login/qs/qr', {}) 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 || '?'}` }) +// 短信发码:无效手机号应被上游业务拒绝(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() => { const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)