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:
手瓜一十雪
2026-02-21 13:03:40 +08:00
parent f961830836
commit b71a4913eb
11 changed files with 957 additions and 102 deletions

View File

@@ -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}`);
}
};

View File

@@ -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;
},

View File

@@ -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

View File

@@ -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[];