mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-03 09: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:
@@ -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;
|
||||
Reference in New Issue
Block a user