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

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