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:
@@ -29,10 +29,11 @@ export interface PasswordLoginArgType {
|
||||
uin: string;
|
||||
passwordMd5: string;// passwMD5
|
||||
step: number;// 猜测是需要二次认证 参数 一次为0
|
||||
newDeviceLoginSig: string;
|
||||
proofWaterSig: string;
|
||||
proofWaterRand: string;
|
||||
proofWaterSid: string;
|
||||
newDeviceLoginSig: Uint8Array;
|
||||
proofWaterSig: Uint8Array;
|
||||
proofWaterRand: Uint8Array;
|
||||
proofWaterSid: Uint8Array;
|
||||
unusualDeviceCheckSig: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LoginListItem {
|
||||
|
||||
@@ -234,21 +234,43 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 0,
|
||||
newDeviceLoginSig: '',
|
||||
proofWaterSig: '',
|
||||
proofWaterRand: '',
|
||||
proofWaterSid: '',
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022008') {
|
||||
const errMsg = '需要验证码,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
|
||||
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '需要验证码',
|
||||
needCaptcha: true,
|
||||
proofWaterUrl,
|
||||
});
|
||||
} else if (res.result === '140022010') {
|
||||
const errMsg = '新设备需要扫码登录,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
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);
|
||||
@@ -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 (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||
logger.log('正在快速登录 ', quickLoginUin);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
@@ -9,14 +9,32 @@ import { IoChevronDown } from 'react-icons/io5';
|
||||
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
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 {
|
||||
onSubmit: (uin: string, password: string) => void;
|
||||
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
|
||||
onNewDeviceVerified?: (token: string) => void;
|
||||
isLoading: boolean;
|
||||
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 [password, setPassword] = useState('');
|
||||
|
||||
@@ -34,6 +52,34 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
@@ -115,6 +161,8 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -96,10 +96,67 @@ export default class QQManager {
|
||||
}
|
||||
|
||||
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,
|
||||
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 () {
|
||||
|
||||
@@ -19,6 +19,7 @@ import QrCodeLogin from '@/components/qr_code_login';
|
||||
import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
@@ -58,6 +59,20 @@ export default function QQLoginPage () {
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||
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 () => {
|
||||
if (!uinValue) {
|
||||
toast.error('请选择快捷登录的QQ');
|
||||
@@ -83,8 +98,28 @@ export default function QQLoginPage () {
|
||||
try {
|
||||
// 计算密码的MD5值
|
||||
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('密码登录请求已发送');
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
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 () => {
|
||||
if (firstLoad.current) setIsLoading(true);
|
||||
try {
|
||||
@@ -249,7 +351,13 @@ export default function QQLoginPage () {
|
||||
<PasswordLogin
|
||||
isLoading={isLoading}
|
||||
onSubmit={onPasswordSubmit}
|
||||
onCaptchaSubmit={onCaptchaSubmit}
|
||||
onNewDeviceVerified={onNewDeviceVerified}
|
||||
qqList={qqList}
|
||||
captchaState={captchaState}
|
||||
newDeviceState={newDeviceState}
|
||||
onCaptchaCancel={onCaptchaCancel}
|
||||
onNewDeviceCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
|
||||
Reference in New Issue
Block a user