mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20: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;
|
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
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 () {
|
||||||
|
|||||||
@@ -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='扫码登录'>
|
||||||
|
|||||||
Reference in New Issue
Block a user