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:
手瓜一十雪
2026-02-21 13:03:40 +08:00
parent f961830836
commit b71a4913eb
11 changed files with 957 additions and 102 deletions

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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 () {

View File

@@ -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='扫码登录'>