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:
@@ -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,87 +52,117 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||
width={100}
|
||||
alt="QQ Avatar"
|
||||
{newDeviceState?.needNewDevice && newDeviceState.jumpUrl ? (
|
||||
<NewDeviceVerify
|
||||
jumpUrl={newDeviceState.jumpUrl}
|
||||
uin={newDeviceState.uin}
|
||||
onVerified={(token) => onNewDeviceVerified?.(token)}
|
||||
onCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
type="text"
|
||||
label="QQ账号"
|
||||
placeholder="请输入QQ号"
|
||||
value={uin}
|
||||
onValueChange={setUin}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="off"
|
||||
endContent={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||
<IoChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="QQ Login History"
|
||||
items={qqList}
|
||||
onAction={(key) => setUin(key.toString())}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item)
|
||||
? item.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
) : 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'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||
width={100}
|
||||
alt="QQ Avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
type="text"
|
||||
label="QQ账号"
|
||||
placeholder="请输入QQ号"
|
||||
value={uin}
|
||||
onValueChange={setUin}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="off"
|
||||
endContent={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||
<IoChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="QQ Login History"
|
||||
items={qqList}
|
||||
onAction={(key) => setUin(key.toString())}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item)
|
||||
? item.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
登录
|
||||
</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);
|
||||
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 () {
|
||||
<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