mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
Add captcha & new-device QQ login flows
Introduce multi-step QQ password login support (captcha and new-device verification) and related OIDB QR handling. - Change login signature fields in NodeIKernelLoginService to binary (Uint8Array) and add unusualDeviceCheckSig. - Update shell base to handle additional result codes (captcha required, new-device, abnormal-device), set login status on success, and register three callbacks: captcha, new-device, and password flows. Use TextEncoder for encoding ticket/randstr/sid and newDevicePullQrCodeSig. - Extend backend WebUiDataRuntime (types and runtime) with set/request methods for captcha and new-device login calls and adjust LoginRuntime types to return richer metadata (needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig). - Add backend API handlers: CaptchaLogin, NewDeviceLogin, GetNewDeviceQRCode and PollNewDeviceQR; add oidbRequest helper using https to query oidb.tim.qq.com for QR generation and polling. - Wire new handlers into QQLogin router and return structured success responses when further steps are required. - Add frontend components and pages for captcha and new-device verification (new files: 1.html, new_device_verify.tsx, tencent_captcha.tsx) and update existing frontend controllers/pages to integrate the new flows. - Improve error logging and user-facing messages for the new flows. This change enables handling of password-login scenarios requiring captcha or device attestation and provides endpoints to obtain and poll OIDB QR codes for new-device verification.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import https from 'https';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
@@ -7,6 +8,37 @@ import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/respons
|
||||
import { Registry20Utils, MachineInfoUtils } from '@/napcat-webui-backend/src/utils/guid';
|
||||
import os from 'node:os';
|
||||
|
||||
// oidb 新设备验证请求辅助函数
|
||||
function oidbRequest (uid: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = JSON.stringify(body);
|
||||
const req = https.request({
|
||||
hostname: 'oidb.tim.qq.com',
|
||||
path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse oidb response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
@@ -171,8 +203,62 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
// 执行密码登录
|
||||
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
if (!result) {
|
||||
if (needCaptcha && proofWaterUrl) {
|
||||
return sendSuccess(res, { needCaptcha: true, proofWaterUrl });
|
||||
}
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 验证码登录(密码登录需要验证码时的第二步)
|
||||
export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, ticket, randstr, sid } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(ticket) || isEmpty(randstr)) {
|
||||
return sendError(res, 'captcha ticket or randstr is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || '');
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(newDevicePullQrCodeSig)) {
|
||||
return sendError(res, 'newDevicePullQrCodeSig is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
@@ -412,4 +498,70 @@ export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OIDB 新设备 QR 验证
|
||||
// ============================================================
|
||||
|
||||
// 获取新设备验证二维码 (通过 OIDB 接口)
|
||||
export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, jumpUrl } = req.body;
|
||||
if (!uin || !jumpUrl) {
|
||||
return sendError(res, 'uin and jumpUrl are required');
|
||||
}
|
||||
|
||||
try {
|
||||
// 从 jumpUrl 中提取 str_url 参数作为 str_dev_auth_token
|
||||
let strDevAuthToken = '';
|
||||
let strUinToken = '';
|
||||
try {
|
||||
const url = new URL(jumpUrl);
|
||||
strDevAuthToken = url.searchParams.get('str_url') || '';
|
||||
strUinToken = url.searchParams.get('str_uin_token') || '';
|
||||
} catch {
|
||||
// 如果 URL 解析失败,尝试正则提取
|
||||
const strUrlMatch = jumpUrl.match(/str_url=([^&]*)/);
|
||||
const uinTokenMatch = jumpUrl.match(/str_uin_token=([^&]*)/);
|
||||
strDevAuthToken = strUrlMatch ? decodeURIComponent(strUrlMatch[1]) : '';
|
||||
strUinToken = uinTokenMatch ? decodeURIComponent(uinTokenMatch[1]) : '';
|
||||
}
|
||||
|
||||
const body = {
|
||||
str_dev_auth_token: strDevAuthToken,
|
||||
uint32_flag: 1,
|
||||
uint32_url_type: 0,
|
||||
str_uin_token: strUinToken,
|
||||
str_dev_type: 'Windows',
|
||||
str_dev_name: os.hostname() || 'DESKTOP-NAPCAT',
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
// result 应包含 str_url (二维码内容) 和 bytes_token 等
|
||||
return sendSuccess(res, result);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get new device QR code: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询新设备验证二维码状态
|
||||
export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, bytesToken } = req.body;
|
||||
if (!uin || !bytesToken) {
|
||||
return sendError(res, 'uin and bytesToken are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
uint32_flag: 0,
|
||||
bytes_token: bytesToken, // base64 编码的 token
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
// result 应包含 uint32_guarantee_status:
|
||||
// 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token)
|
||||
return sendSuccess(res, result);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to poll QR status: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
onPasswordLoginRequested: async () => {
|
||||
return { result: false, message: '密码登录功能未初始化' };
|
||||
},
|
||||
onCaptchaLoginRequested: async () => {
|
||||
return { result: false, message: '验证码登录功能未初始化' };
|
||||
},
|
||||
onNewDeviceLoginRequested: async () => {
|
||||
return { result: false, message: '新设备登录功能未初始化' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
@@ -148,6 +154,22 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||
|
||||
setCaptchaLoginCall (func: LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onCaptchaLoginRequested = func;
|
||||
},
|
||||
|
||||
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
return LoginRuntime.NapCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
|
||||
} as LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested'],
|
||||
|
||||
setNewDeviceLoginCall (func: LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onNewDeviceLoginRequested = func;
|
||||
},
|
||||
|
||||
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
return LoginRuntime.NapCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
} as LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQCaptchaLoginHandler,
|
||||
QQNewDeviceLoginHandler,
|
||||
QQGetNewDeviceQRCodeHandler,
|
||||
QQPollNewDeviceQRHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
@@ -50,6 +54,14 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:验证码登录(密码登录需要验证码时的第二步)
|
||||
router.post('/CaptchaLogin', QQCaptchaLoginHandler);
|
||||
// router:新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
router.post('/NewDeviceLogin', QQNewDeviceLoginHandler);
|
||||
// router:获取新设备验证二维码 (OIDB)
|
||||
router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler);
|
||||
// router:轮询新设备验证二维码状态 (OIDB)
|
||||
router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
|
||||
@@ -57,7 +57,9 @@ export interface LoginRuntimeType {
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
|
||||
Reference in New Issue
Block a user