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

@@ -29,10 +29,11 @@ export interface PasswordLoginArgType {
uin: string; uin: string;
passwordMd5: string;// passwMD5 passwordMd5: string;// passwMD5
step: number;// 猜测是需要二次认证 参数 一次为0 step: number;// 猜测是需要二次认证 参数 一次为0
newDeviceLoginSig: string; newDeviceLoginSig: Uint8Array;
proofWaterSig: string; proofWaterSig: Uint8Array;
proofWaterRand: string; proofWaterRand: Uint8Array;
proofWaterSid: string; proofWaterSid: Uint8Array;
unusualDeviceCheckSig: Uint8Array;
} }
export interface LoginListItem { export interface LoginListItem {

View File

@@ -234,21 +234,43 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
uin, uin,
passwordMd5, passwordMd5,
step: 0, step: 0,
newDeviceLoginSig: '', newDeviceLoginSig: new Uint8Array(),
proofWaterSig: '', proofWaterSig: new Uint8Array(),
proofWaterRand: '', proofWaterRand: new Uint8Array(),
proofWaterSid: '', proofWaterSid: new Uint8Array(),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => { }).then(res => {
if (res.result === '140022008') { if (res.result === '140022008') {
const errMsg = '需要验证码,暂不支持'; const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
WebUiDataRuntime.setQQLoginError(errMsg); logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
loginService.getQRCodePicture(); resolve({
resolve({ result: false, message: errMsg }); result: false,
message: '需要验证码',
needCaptcha: true,
proofWaterUrl,
});
} else if (res.result === '140022010') { } else if (res.result === '140022010') {
const errMsg = '新设备需要扫码登录,暂不支持'; const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
WebUiDataRuntime.setQQLoginError(errMsg); const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
loginService.getQRCodePicture(); logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
resolve({ result: false, message: errMsg }); resolve({
result: false,
message: '新设备需要扫码验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') { } else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败'; const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg); WebUiDataRuntime.setQQLoginError(errMsg);
@@ -270,6 +292,114 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
} }
}); });
}); });
// 注册验证码登录回调(密码登录需要验证码时的第二步)
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5 && ticket) {
logger.log('正在验证码登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 1,
newDeviceLoginSig: new Uint8Array(),
proofWaterSig: new TextEncoder().encode(ticket),
proofWaterRand: new TextEncoder().encode(randstr),
proofWaterSid: new TextEncoder().encode(sid),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => {
console.log('验证码登录结果: ', res);
if (res.result === '140022010') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '新设备需要扫码验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('验证码登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '验证码登录发生错误' });
});
} else {
resolve({ result: false, message: '验证码登录失败:参数不完整' });
}
});
});
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
logger.log('正在新设备验证登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 2,
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
proofWaterSig: new Uint8Array(),
proofWaterRand: new Uint8Array(),
proofWaterSid: new Uint8Array(),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => {
if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '新设备验证登录发生错误' });
});
} else {
resolve({ result: false, message: '新设备验证登录失败:参数不完整' });
}
});
});
if (quickLoginUin) { if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) { if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin); logger.log('正在快速登录 ', quickLoginUin);

View File

@@ -1,4 +1,5 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import https from 'https';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { WebUiConfig } from '@/napcat-webui-backend/index'; 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 { Registry20Utils, MachineInfoUtils } from '@/napcat-webui-backend/src/utils/guid';
import os from 'node:os'; 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 路径的辅助函数 // 获取 Registry20 路径的辅助函数
const getRegistryPath = () => { const getRegistryPath = () => {
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath // 优先从 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 (!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 sendError(res, message);
} }
return sendSuccess(res, null); 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 () => { onPasswordLoginRequested: async () => {
return { result: false, message: '密码登录功能未初始化' }; return { result: false, message: '密码登录功能未初始化' };
}, },
onCaptchaLoginRequested: async () => {
return { result: false, message: '验证码登录功能未初始化' };
},
onNewDeviceLoginRequested: async () => {
return { result: false, message: '新设备登录功能未初始化' };
},
onRestartProcessRequested: async () => { onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' }; return { result: false, message: '重启功能未初始化' };
}, },
@@ -148,6 +154,22 @@ export const WebUiDataRuntime = {
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5); return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'], } 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 { setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func; LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
}, },

View File

@@ -11,6 +11,10 @@ import {
setAutoLoginAccountHandler, setAutoLoginAccountHandler,
QQRefreshQRcodeHandler, QQRefreshQRcodeHandler,
QQPasswordLoginHandler, QQPasswordLoginHandler,
QQCaptchaLoginHandler,
QQNewDeviceLoginHandler,
QQGetNewDeviceQRCodeHandler,
QQPollNewDeviceQRHandler,
QQResetDeviceIDHandler, QQResetDeviceIDHandler,
QQRestartNapCatHandler, QQRestartNapCatHandler,
QQGetDeviceGUIDHandler, QQGetDeviceGUIDHandler,
@@ -50,6 +54,14 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
router.post('/RefreshQRcode', QQRefreshQRcodeHandler); router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
// router:密码登录 // router:密码登录
router.post('/PasswordLogin', QQPasswordLoginHandler); 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:重置设备信息
router.post('/ResetDeviceID', QQResetDeviceIDHandler); router.post('/ResetDeviceID', QQResetDeviceIDHandler);
// router:重启NapCat // router:重启NapCat

View File

@@ -57,7 +57,9 @@ export interface LoginRuntimeType {
OneBotContext: any | null; // OneBot 上下文,用于调试功能 OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; 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>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>; onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[]; QQLoginList: string[];

View File

@@ -0,0 +1,157 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { QRCodeSVG } from 'qrcode.react';
import QQManager from '@/controllers/qq_manager';
interface NewDeviceVerifyProps {
/** jumpUrl from loginErrorInfo */
jumpUrl: string;
/** QQ uin for OIDB requests */
uin: string;
/** Called when QR verification is confirmed, passes str_nt_succ_token */
onVerified: (token: string) => void;
/** Called when user cancels */
onCancel?: () => void;
}
type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error';
const NewDeviceVerify: React.FC<NewDeviceVerifyProps> = ({
jumpUrl,
uin,
onVerified,
onCancel,
}) => {
const [qrUrl, setQrUrl] = useState<string>('');
const [status, setStatus] = useState<QRStatus>('loading');
const [errorMsg, setErrorMsg] = useState<string>('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const startPolling = useCallback((token: string) => {
stopPolling();
pollTimerRef.current = setInterval(async () => {
if (!mountedRef.current) return;
try {
const result = await QQManager.pollNewDeviceQR(uin, token);
if (!mountedRef.current) return;
const s = result?.uint32_guarantee_status;
if (s === 3) {
setStatus('scanned');
} else if (s === 1) {
stopPolling();
setStatus('confirmed');
const ntToken = result?.str_nt_succ_token || '';
onVerified(ntToken);
}
// s === 0 means still waiting, do nothing
} catch {
// Ignore poll errors, keep polling
}
}, 2500);
}, [uin, onVerified, stopPolling]);
const fetchQRCode = useCallback(async () => {
setStatus('loading');
setErrorMsg('');
try {
const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl);
if (!mountedRef.current) return;
if (result?.str_url && result?.bytes_token) {
setQrUrl(result.str_url);
setStatus('waiting');
startPolling(result.bytes_token);
} else {
setStatus('error');
setErrorMsg('获取二维码失败,请重试');
}
} catch (e) {
if (!mountedRef.current) return;
setStatus('error');
setErrorMsg((e as Error).message || '获取二维码失败');
}
}, [uin, jumpUrl, startPolling]);
useEffect(() => {
mountedRef.current = true;
fetchQRCode();
return () => {
mountedRef.current = false;
stopPolling();
};
}, [fetchQRCode, stopPolling]);
const statusText: Record<QRStatus, string> = {
loading: '正在获取二维码...',
waiting: '请使用手机QQ扫描二维码完成验证',
scanned: '已扫描,请在手机上确认',
confirmed: '验证成功,正在登录...',
error: errorMsg || '获取二维码失败',
};
const statusColor: Record<QRStatus, string> = {
loading: 'text-default-500',
waiting: 'text-warning',
scanned: 'text-primary',
confirmed: 'text-success',
error: 'text-danger',
};
return (
<div className='flex flex-col gap-4 items-center'>
<p className='text-warning text-sm'>
使QQ扫描下方二维码完成验证
</p>
<div className='flex flex-col items-center gap-3' style={{ minHeight: 280 }}>
{status === 'loading' ? (
<div className='flex items-center justify-center' style={{ height: 240 }}>
<Spinner size='lg' />
</div>
) : status === 'error' ? (
<div className='flex flex-col items-center justify-center gap-3' style={{ height: 240 }}>
<p className='text-danger text-sm'>{errorMsg}</p>
<Button color='primary' variant='flat' onPress={fetchQRCode}>
</Button>
</div>
) : (
<div className='p-3 bg-white rounded-lg'>
<QRCodeSVG value={qrUrl} size={220} />
</div>
)}
<p className={`text-sm ${statusColor[status]}`}>
{statusText[status]}
</p>
</div>
<div className='flex gap-3'>
{status === 'waiting' && (
<Button color='default' variant='flat' size='sm' onPress={fetchQRCode}>
</Button>
)}
<Button
variant='light'
color='danger'
size='sm'
onPress={onCancel}
>
</Button>
</div>
</div>
);
};
export default NewDeviceVerify;

View File

@@ -9,14 +9,32 @@ import { IoChevronDown } from 'react-icons/io5';
import type { QQItem } from '@/components/quick_login'; import type { QQItem } from '@/components/quick_login';
import { isQQQuickNewItem } from '@/utils/qq'; import { isQQQuickNewItem } from '@/utils/qq';
import TencentCaptchaModal from '@/components/tencent_captcha';
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
import NewDeviceVerify from '@/components/new_device_verify';
interface PasswordLoginProps { interface PasswordLoginProps {
onSubmit: (uin: string, password: string) => void; onSubmit: (uin: string, password: string) => void;
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
onNewDeviceVerified?: (token: string) => void;
isLoading: boolean; isLoading: boolean;
qqList: (QQItem | LoginListItem)[]; qqList: (QQItem | LoginListItem)[];
captchaState?: {
needCaptcha: boolean;
proofWaterUrl: string;
uin: string;
password: string;
} | null;
newDeviceState?: {
needNewDevice: boolean;
jumpUrl: string;
uin: string;
} | null;
onCaptchaCancel?: () => void;
onNewDeviceCancel?: () => void;
} }
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => { const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => {
const [uin, setUin] = useState(''); const [uin, setUin] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -34,6 +52,34 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
return ( return (
<div className='flex flex-col gap-8'> <div className='flex flex-col gap-8'>
{newDeviceState?.needNewDevice && newDeviceState.jumpUrl ? (
<NewDeviceVerify
jumpUrl={newDeviceState.jumpUrl}
uin={newDeviceState.uin}
onVerified={(token) => onNewDeviceVerified?.(token)}
onCancel={onNewDeviceCancel}
/>
) : captchaState?.needCaptcha && captchaState.proofWaterUrl ? (
<div className='flex flex-col gap-4 items-center'>
<p className='text-warning text-sm'></p>
<TencentCaptchaModal
proofWaterUrl={captchaState.proofWaterUrl}
onSuccess={(data) => {
onCaptchaSubmit?.(captchaState.uin, captchaState.password, data);
}}
onCancel={onCaptchaCancel}
/>
<Button
variant='light'
color='danger'
size='sm'
onPress={onCaptchaCancel}
>
</Button>
</div>
) : (
<>
<div className='flex justify-center'> <div className='flex justify-center'>
<Image <Image
className='shadow-lg' className='shadow-lg'
@@ -115,6 +161,8 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
</Button> </Button>
</div> </div>
</>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useCallback } from 'react';
import { Spinner } from '@heroui/spinner';
declare global {
interface Window {
TencentCaptcha: new (
appid: string,
callback: (res: TencentCaptchaResult) => void,
options?: Record<string, unknown>
) => { show: () => void; destroy: () => void; };
}
}
export interface TencentCaptchaResult {
ret: number;
appid?: string;
ticket?: string;
randstr?: string;
errorCode?: number;
errorMessage?: string;
}
export interface CaptchaCallbackData {
ticket: string;
randstr: string;
appid: string;
sid: string;
}
interface TencentCaptchaProps {
/** proofWaterUrl returned from login error, contains uin/sid/aid params */
proofWaterUrl: string;
/** Called when captcha verification succeeds */
onSuccess: (data: CaptchaCallbackData) => void;
/** Called when captcha is cancelled or fails */
onCancel?: () => void;
}
function parseUrlParams (url: string): Record<string, string> {
const params: Record<string, string> = {};
try {
const u = new URL(url);
u.searchParams.forEach((v, k) => { params[k] = v; });
} catch {
const match = url.match(/[?&]([^#]+)/);
if (match) {
match[1].split('&').forEach(pair => {
const [k, v] = pair.split('=');
if (k) params[k] = decodeURIComponent(v || '');
});
}
}
return params;
}
function loadScript (src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.TencentCaptcha) {
resolve();
return;
}
const tag = document.createElement('script');
tag.src = src;
tag.onload = () => resolve();
tag.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(tag);
});
}
const TencentCaptchaModal: React.FC<TencentCaptchaProps> = ({
proofWaterUrl,
onSuccess,
onCancel,
}) => {
const captchaRef = useRef<{ destroy: () => void; } | null>(null);
const mountedRef = useRef(true);
const handleResult = useCallback((res: TencentCaptchaResult, sid: string) => {
if (!mountedRef.current) return;
if (res.ret === 0 && res.ticket && res.randstr) {
onSuccess({
ticket: res.ticket,
randstr: res.randstr,
appid: res.appid || '',
sid,
});
} else {
onCancel?.();
}
}, [onSuccess, onCancel]);
useEffect(() => {
mountedRef.current = true;
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
const init = async () => {
try {
await loadScript('https://captcha.gtimg.com/TCaptcha.js');
} catch {
try {
await loadScript('https://ssl.captcha.qq.com/TCaptcha.js');
} catch {
// Both CDN failed, generate fallback ticket
if (mountedRef.current) {
handleResult({
ret: 0,
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
randstr: '@' + Math.random().toString(36).substring(2),
errorCode: 1001,
errorMessage: 'jsload_error',
}, sid);
}
return;
}
}
if (!mountedRef.current) return;
try {
const captcha = new window.TencentCaptcha(
appid,
(res) => handleResult(res, sid),
{
type: 'popup',
showHeader: false,
login_appid: params.login_appid,
uin: params.uin,
sid: params.sid,
enableAged: true,
}
);
captchaRef.current = captcha;
captcha.show();
} catch {
if (mountedRef.current) {
handleResult({
ret: 0,
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
randstr: '@' + Math.random().toString(36).substring(2),
errorCode: 1001,
errorMessage: 'init_error',
}, sid);
}
}
};
init();
return () => {
mountedRef.current = false;
captchaRef.current?.destroy();
captchaRef.current = null;
};
}, [proofWaterUrl, handleResult]);
return (
<div className="flex items-center justify-center py-8 gap-3">
<Spinner size="lg" />
<span className="text-default-500">...</span>
</div>
);
};
export default TencentCaptchaModal;

View File

@@ -96,10 +96,67 @@ export default class QQManager {
} }
public static async passwordLogin (uin: string, passwordMd5: string) { public static async passwordLogin (uin: string, passwordMd5: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', { const data = await serverRequest.post<ServerResponse<{
needCaptcha?: boolean;
proofWaterUrl?: string;
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/PasswordLogin', {
uin, uin,
passwordMd5, passwordMd5,
}); });
return data.data.data;
}
public static async captchaLogin (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
const data = await serverRequest.post<ServerResponse<{
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/CaptchaLogin', {
uin,
passwordMd5,
ticket,
randstr,
sid,
});
return data.data.data;
}
public static async newDeviceLogin (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
const data = await serverRequest.post<ServerResponse<{
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/NewDeviceLogin', {
uin,
passwordMd5,
newDevicePullQrCodeSig,
});
return data.data.data;
}
public static async getNewDeviceQRCode (uin: string, jumpUrl: string) {
const data = await serverRequest.post<ServerResponse<{
str_url?: string;
bytes_token?: string;
}>>('/QQLogin/GetNewDeviceQRCode', {
uin,
jumpUrl,
});
return data.data.data;
}
public static async pollNewDeviceQR (uin: string, bytesToken: string) {
const data = await serverRequest.post<ServerResponse<{
uint32_guarantee_status?: number;
str_nt_succ_token?: string;
}>>('/QQLogin/PollNewDeviceQR', {
uin,
bytesToken,
});
return data.data.data;
} }
public static async resetDeviceID () { public static async resetDeviceID () {

View File

@@ -19,6 +19,7 @@ import QrCodeLogin from '@/components/qr_code_login';
import QuickLogin from '@/components/quick_login'; import QuickLogin from '@/components/quick_login';
import type { QQItem } from '@/components/quick_login'; import type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch'; import { ThemeSwitch } from '@/components/theme-switch';
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
import QQManager from '@/controllers/qq_manager'; import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
@@ -58,6 +59,20 @@ export default function QQLoginPage () {
const [refresh, setRefresh] = useState<boolean>(false); const [refresh, setRefresh] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>('shortcut'); const [activeTab, setActiveTab] = useState<string>('shortcut');
const firstLoad = useRef<boolean>(true); const firstLoad = useRef<boolean>(true);
const [captchaState, setCaptchaState] = useState<{
needCaptcha: boolean;
proofWaterUrl: string;
uin: string;
password: string;
} | null>(null);
const [newDeviceState, setNewDeviceState] = useState<{
needNewDevice: boolean;
jumpUrl: string;
newDevicePullQrCodeSig: string;
uin: string;
password: string;
} | null>(null);
// newDevicePullQrCodeSig is kept for step:2 login after QR verification
const onSubmit = async () => { const onSubmit = async () => {
if (!uinValue) { if (!uinValue) {
toast.error('请选择快捷登录的QQ'); toast.error('请选择快捷登录的QQ');
@@ -83,8 +98,28 @@ export default function QQLoginPage () {
try { try {
// 计算密码的MD5值 // 计算密码的MD5值
const passwordMd5 = CryptoJS.MD5(password).toString(); const passwordMd5 = CryptoJS.MD5(password).toString();
await QQManager.passwordLogin(uin, passwordMd5); const result = await QQManager.passwordLogin(uin, passwordMd5);
if (result?.needCaptcha && result.proofWaterUrl) {
// 需要验证码,显示验证码组件
setCaptchaState({
needCaptcha: true,
proofWaterUrl: result.proofWaterUrl,
uin,
password,
});
toast('需要安全验证,请完成验证码', { icon: '🔒' });
} else if (result?.needNewDevice && result.jumpUrl) {
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到新设备,请扫码验证', { icon: '📱' });
} else {
toast.success('密码登录请求已发送'); toast.success('密码登录请求已发送');
}
} catch (error) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`); toast.error(`密码登录失败: ${msg}`);
@@ -93,6 +128,73 @@ export default function QQLoginPage () {
} }
}; };
const onCaptchaSubmit = async (uin: string, password: string, captchaData: CaptchaCallbackData) => {
setIsLoading(true);
try {
const passwordMd5 = CryptoJS.MD5(password).toString();
const result = await QQManager.captchaLogin(uin, passwordMd5, captchaData.ticket, captchaData.randstr, captchaData.sid);
if (result?.needNewDevice && result.jumpUrl) {
setCaptchaState(null);
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到异常设备,请扫码验证', { icon: '📱' });
} else {
toast.success('验证码登录请求已发送');
setCaptchaState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`验证码登录失败: ${msg}`);
setCaptchaState(null);
} finally {
setIsLoading(false);
}
};
const onCaptchaCancel = () => {
setCaptchaState(null);
};
const onNewDeviceVerified = async (token: string) => {
if (!newDeviceState) return;
setIsLoading(true);
try {
const passwordMd5 = CryptoJS.MD5(newDeviceState.password).toString();
// Use the str_nt_succ_token from QR verification as newDevicePullQrCodeSig for step:2
const sig = token || newDeviceState.newDevicePullQrCodeSig;
const result = await QQManager.newDeviceLogin(newDeviceState.uin, passwordMd5, sig);
if (result?.needNewDevice && result.jumpUrl) {
// 新设备验证后又触发了异常设备验证,更新 jumpUrl
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin: newDeviceState.uin,
password: newDeviceState.password,
});
toast('检测到异常设备,请继续扫码验证', { icon: '📱' });
} else {
toast.success('新设备验证登录请求已发送');
setNewDeviceState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`新设备验证登录失败: ${msg}`);
setNewDeviceState(null);
} finally {
setIsLoading(false);
}
};
const onNewDeviceCancel = () => {
setNewDeviceState(null);
};
const onUpdateQrCode = async () => { const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true); if (firstLoad.current) setIsLoading(true);
try { try {
@@ -249,7 +351,13 @@ export default function QQLoginPage () {
<PasswordLogin <PasswordLogin
isLoading={isLoading} isLoading={isLoading}
onSubmit={onPasswordSubmit} onSubmit={onPasswordSubmit}
onCaptchaSubmit={onCaptchaSubmit}
onNewDeviceVerified={onNewDeviceVerified}
qqList={qqList} qqList={qqList}
captchaState={captchaState}
newDeviceState={newDeviceState}
onCaptchaCancel={onCaptchaCancel}
onNewDeviceCancel={onNewDeviceCancel}
/> />
</Tab> </Tab>
<Tab key='qrcode' title='扫码登录'> <Tab key='qrcode' title='扫码登录'>