diff --git a/packages/napcat-core/services/NodeIKernelLoginService.ts b/packages/napcat-core/services/NodeIKernelLoginService.ts index 4a3118aa..279a30d8 100644 --- a/packages/napcat-core/services/NodeIKernelLoginService.ts +++ b/packages/napcat-core/services/NodeIKernelLoginService.ts @@ -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 { diff --git a/packages/napcat-shell/base.ts b/packages/napcat-shell/base.ts index 1937059c..4e3e5bca 100644 --- a/packages/napcat-shell/base.ts +++ b/packages/napcat-shell/base.ts @@ -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); diff --git a/packages/napcat-webui-backend/src/api/QQLogin.ts b/packages/napcat-webui-backend/src/api/QQLogin.ts index 2d0beeb0..b0d5981a 100644 --- a/packages/napcat-webui-backend/src/api/QQLogin.ts +++ b/packages/napcat-webui-backend/src/api/QQLogin.ts @@ -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): Promise> { + 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}`); + } +}; + diff --git a/packages/napcat-webui-backend/src/helper/Data.ts b/packages/napcat-webui-backend/src/helper/Data.ts index f272b632..917b0261 100644 --- a/packages/napcat-webui-backend/src/helper/Data.ts +++ b/packages/napcat-webui-backend/src/helper/Data.ts @@ -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; }, diff --git a/packages/napcat-webui-backend/src/router/QQLogin.ts b/packages/napcat-webui-backend/src/router/QQLogin.ts index 7165de61..3371d717 100644 --- a/packages/napcat-webui-backend/src/router/QQLogin.ts +++ b/packages/napcat-webui-backend/src/router/QQLogin.ts @@ -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 diff --git a/packages/napcat-webui-backend/src/types/index.ts b/packages/napcat-webui-backend/src/types/index.ts index d0464871..f4ee8ca4 100644 --- a/packages/napcat-webui-backend/src/types/index.ts +++ b/packages/napcat-webui-backend/src/types/index.ts @@ -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; onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>; QQLoginList: string[]; diff --git a/packages/napcat-webui-frontend/src/components/new_device_verify.tsx b/packages/napcat-webui-frontend/src/components/new_device_verify.tsx new file mode 100644 index 00000000..c96a0020 --- /dev/null +++ b/packages/napcat-webui-frontend/src/components/new_device_verify.tsx @@ -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 = ({ + jumpUrl, + uin, + onVerified, + onCancel, +}) => { + const [qrUrl, setQrUrl] = useState(''); + const [status, setStatus] = useState('loading'); + const [errorMsg, setErrorMsg] = useState(''); + const pollTimerRef = useRef | 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 = { + loading: '正在获取二维码...', + waiting: '请使用手机QQ扫描二维码完成验证', + scanned: '已扫描,请在手机上确认', + confirmed: '验证成功,正在登录...', + error: errorMsg || '获取二维码失败', + }; + + const statusColor: Record = { + loading: 'text-default-500', + waiting: 'text-warning', + scanned: 'text-primary', + confirmed: 'text-success', + error: 'text-danger', + }; + + return ( +
+

+ 检测到新设备登录,请使用手机QQ扫描下方二维码完成验证 +

+ +
+ {status === 'loading' ? ( +
+ +
+ ) : status === 'error' ? ( +
+

{errorMsg}

+ +
+ ) : ( +
+ +
+ )} + +

+ {statusText[status]} +

+
+ +
+ {status === 'waiting' && ( + + )} + +
+
+ ); +}; + +export default NewDeviceVerify; diff --git a/packages/napcat-webui-frontend/src/components/password_login.tsx b/packages/napcat-webui-frontend/src/components/password_login.tsx index 0f46f6ea..545ac71f 100644 --- a/packages/napcat-webui-frontend/src/components/password_login.tsx +++ b/packages/napcat-webui-frontend/src/components/password_login.tsx @@ -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 = ({ onSubmit, isLoading, qqList }) => { +const PasswordLogin: React.FC = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => { const [uin, setUin] = useState(''); const [password, setPassword] = useState(''); @@ -34,87 +52,117 @@ const PasswordLogin: React.FC = ({ onSubmit, isLoading, qqLi return (
-
- QQ Avatar onNewDeviceVerified?.(token)} + onCancel={onNewDeviceCancel} /> -
-
- - - - - setUin(key.toString())} - > - {(item) => ( - -
- -
- {isQQQuickNewItem(item) - ? `${item.nickName}(${item.uin})` - : item.uin} -
-
-
- )} -
- - } - /> - -
-
- -
+ ) : captchaState?.needCaptcha && captchaState.proofWaterUrl ? ( +
+

登录需要安全验证,请完成验证码

+ { + onCaptchaSubmit?.(captchaState.uin, captchaState.password, data); + }} + onCancel={onCaptchaCancel} + /> + +
+ ) : ( + <> +
+ QQ Avatar +
+
+ + + + + setUin(key.toString())} + > + {(item) => ( + +
+ +
+ {isQQQuickNewItem(item) + ? `${item.nickName}(${item.uin})` + : item.uin} +
+
+
+ )} +
+ + } + /> + +
+
+ +
+ + )}
); }; diff --git a/packages/napcat-webui-frontend/src/components/tencent_captcha.tsx b/packages/napcat-webui-frontend/src/components/tencent_captcha.tsx new file mode 100644 index 00000000..85fe181a --- /dev/null +++ b/packages/napcat-webui-frontend/src/components/tencent_captcha.tsx @@ -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 + ) => { 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 { + const params: Record = {}; + 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 { + 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 = ({ + 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 ( +
+ + 正在加载验证码... +
+ ); +}; + +export default TencentCaptchaModal; diff --git a/packages/napcat-webui-frontend/src/controllers/qq_manager.ts b/packages/napcat-webui-frontend/src/controllers/qq_manager.ts index b0da8488..bbbcad63 100644 --- a/packages/napcat-webui-frontend/src/controllers/qq_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/qq_manager.ts @@ -96,10 +96,67 @@ export default class QQManager { } public static async passwordLogin (uin: string, passwordMd5: string) { - await serverRequest.post>('/QQLogin/PasswordLogin', { + const data = await serverRequest.post>('/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>('/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>('/QQLogin/NewDeviceLogin', { + uin, + passwordMd5, + newDevicePullQrCodeSig, + }); + return data.data.data; + } + + public static async getNewDeviceQRCode (uin: string, jumpUrl: string) { + const data = await serverRequest.post>('/QQLogin/GetNewDeviceQRCode', { + uin, + jumpUrl, + }); + return data.data.data; + } + + public static async pollNewDeviceQR (uin: string, bytesToken: string) { + const data = await serverRequest.post>('/QQLogin/PollNewDeviceQR', { + uin, + bytesToken, + }); + return data.data.data; } public static async resetDeviceID () { diff --git a/packages/napcat-webui-frontend/src/pages/qq_login.tsx b/packages/napcat-webui-frontend/src/pages/qq_login.tsx index 68744964..75b2ad79 100644 --- a/packages/napcat-webui-frontend/src/pages/qq_login.tsx +++ b/packages/napcat-webui-frontend/src/pages/qq_login.tsx @@ -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(false); const [activeTab, setActiveTab] = useState('shortcut'); const firstLoad = useRef(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); - toast.success('密码登录请求已发送'); + 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 () {