- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Alert, Button, Card, Divider, Form, Input, Space, Typography } from 'antd';
|
||
import type { InputRef } from 'antd';
|
||
import { message } from '../utils/antdMessage';
|
||
import {
|
||
LockOutlined,
|
||
MobileOutlined,
|
||
SafetyCertificateOutlined,
|
||
UserOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { authApi } from '../api/modules';
|
||
import type { DevicePrepareResult, LoginResult } from '../api/modules';
|
||
import { setAuth, type AuthUser } from '../store/auth';
|
||
import { getErrorMessage } from '../utils/error';
|
||
import { useTheme } from '../store/useTheme';
|
||
import { useAppInfo } from '../hooks/useAppInfo';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
type LoginMode = 'secure' | 'legacy';
|
||
type LoginValues = { username: string; password: string; smsCode?: string };
|
||
|
||
function errorCodeOf(error: unknown): string {
|
||
return (error as { errorCode?: string } | null)?.errorCode || '';
|
||
}
|
||
|
||
function launchProtocol(url: string) {
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.style.display = 'none';
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
window.setTimeout(() => anchor.remove(), 1000);
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
|
||
const [mode, setMode] = useState<LoginMode>('secure');
|
||
const [loading, setLoading] = useState(false);
|
||
const [sendingCode, setSendingCode] = useState(false);
|
||
const [countdown, setCountdown] = useState(0);
|
||
const [phoneMasked, setPhoneMasked] = useState('');
|
||
const [waiting, setWaiting] = useState(false);
|
||
const [launchUrl, setLaunchUrl] = useState('');
|
||
const [form] = Form.useForm<LoginValues>();
|
||
const passwordRef = useRef<InputRef>(null);
|
||
const timerRef = useRef<number | null>(null);
|
||
const submittingRef = useRef(false);
|
||
const navigate = useNavigate();
|
||
const { isDark } = useTheme();
|
||
const appInfo = useAppInfo();
|
||
const versionText = appInfo?.version ? `v${appInfo.version}` : 'v--';
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
function startCountdown(seconds: number) {
|
||
setCountdown(seconds);
|
||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||
timerRef.current = window.setInterval(() => {
|
||
setCountdown((current) => {
|
||
if (current <= 1) {
|
||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||
return 0;
|
||
}
|
||
return current - 1;
|
||
});
|
||
}, 1000);
|
||
}
|
||
|
||
function switchMode(next: LoginMode) {
|
||
if (loading || waiting) return;
|
||
setMode(next);
|
||
form.setFieldValue('smsCode', '');
|
||
setWaiting(false);
|
||
setLaunchUrl('');
|
||
setPhoneMasked('');
|
||
}
|
||
|
||
function applyLoginResult(result: LoginResult) {
|
||
const user: AuthUser = {
|
||
id: 0,
|
||
username: result.username,
|
||
role: result.role,
|
||
permissions: result.permissions,
|
||
};
|
||
setAuth(user);
|
||
message.success('登录成功');
|
||
onLogin?.();
|
||
navigate('/', { replace: true });
|
||
}
|
||
|
||
async function sendCode() {
|
||
let values: Pick<LoginValues, 'username' | 'password'>;
|
||
try {
|
||
values = await form.validateFields(['username', 'password']);
|
||
} catch {
|
||
return;
|
||
}
|
||
setSendingCode(true);
|
||
try {
|
||
const result = await authApi.sendSmsCode(values.username.trim(), values.password);
|
||
setPhoneMasked(result.phoneMasked);
|
||
startCountdown(60);
|
||
message.success(`验证码已发送至 ${result.phoneMasked}`);
|
||
} catch (error) {
|
||
const code = errorCodeOf(error);
|
||
if (code === 'phone_unbound') {
|
||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||
} else {
|
||
message.error(getErrorMessage(error) || '验证码发送失败');
|
||
}
|
||
} finally {
|
||
setSendingCode(false);
|
||
}
|
||
}
|
||
|
||
async function handleSecureLogin(values: LoginValues) {
|
||
if (submittingRef.current) return;
|
||
submittingRef.current = true;
|
||
setLoading(true);
|
||
setWaiting(false);
|
||
try {
|
||
const prepared = await authApi.prepareDeviceLogin({
|
||
username: values.username.trim(),
|
||
password: values.password,
|
||
smsCode: (values.smsCode || '').trim(),
|
||
apiBase: window.location.origin,
|
||
});
|
||
setPhoneMasked(prepared.phoneMasked);
|
||
setLaunchUrl(prepared.launchUrl);
|
||
setWaiting(true);
|
||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认');
|
||
launchProtocol(prepared.launchUrl);
|
||
|
||
const completed = await waitForDevice(prepared);
|
||
applyLoginResult(completed);
|
||
} catch (error) {
|
||
const code = errorCodeOf(error);
|
||
if (code === 'phone_unbound') {
|
||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||
} else if (code === 'device_mismatch') {
|
||
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备');
|
||
} else if (code === 'challenge_expired' || code === 'challenge_rejected') {
|
||
message.error('本机验证已过期,请重新发起登录');
|
||
} else if (code === 'device_timeout') {
|
||
message.error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起');
|
||
} else {
|
||
const text = getErrorMessage(error) || '登录失败';
|
||
message.error(text);
|
||
if (text.includes('密码') || text.includes('用户名')) {
|
||
form.setFieldValue('password', '');
|
||
passwordRef.current?.focus();
|
||
}
|
||
}
|
||
} finally {
|
||
submittingRef.current = false;
|
||
setLoading(false);
|
||
setWaiting(false);
|
||
setLaunchUrl('');
|
||
}
|
||
}
|
||
|
||
async function waitForDevice(prepared: DevicePrepareResult): Promise<LoginResult> {
|
||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||
await sleep(1000);
|
||
const status = await authApi.deviceChallengeStatus(
|
||
prepared.challengeId,
|
||
prepared.pollToken,
|
||
);
|
||
if (status.status === 'authorized' && status.deviceTicket) {
|
||
return authApi.completeDeviceLogin({
|
||
challengeId: prepared.challengeId,
|
||
pollToken: prepared.pollToken,
|
||
deviceTicket: status.deviceTicket,
|
||
});
|
||
}
|
||
if (status.status === 'expired' || status.status === 'rejected') {
|
||
const error = new Error(status.message || '本机验证已过期,请重新登录') as Error & {
|
||
errorCode?: string;
|
||
};
|
||
error.errorCode = status.status === 'expired' ? 'challenge_expired' : 'challenge_rejected';
|
||
throw error;
|
||
}
|
||
if (status.status === 'ticket_issued' || status.status === 'consumed') {
|
||
const error = new Error('本机验证会话已被使用,请重新发起登录') as Error & {
|
||
errorCode?: string;
|
||
};
|
||
error.errorCode = 'challenge_used';
|
||
throw error;
|
||
}
|
||
if (attempt === 4) {
|
||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth');
|
||
}
|
||
}
|
||
const timeout = new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') as Error & {
|
||
errorCode?: string;
|
||
};
|
||
timeout.errorCode = 'device_timeout';
|
||
throw timeout;
|
||
}
|
||
|
||
async function handleLegacyLogin(values: LoginValues) {
|
||
if (submittingRef.current) return;
|
||
submittingRef.current = true;
|
||
setLoading(true);
|
||
try {
|
||
const result = await authApi.login(values.username.trim(), values.password);
|
||
applyLoginResult(result);
|
||
} catch (error) {
|
||
const code = errorCodeOf(error);
|
||
if (code === 'secure_login_required') {
|
||
message.warning('该账号已启用安全登录,请使用短信验证码 + 本机验证登录');
|
||
} else {
|
||
message.error(getErrorMessage(error) || '登录失败');
|
||
}
|
||
} finally {
|
||
submittingRef.current = false;
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: '100vh',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
background: isDark
|
||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||
}}
|
||
>
|
||
<Card
|
||
style={{
|
||
width: 420,
|
||
boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)',
|
||
}}
|
||
>
|
||
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
|
||
直播运营后台
|
||
</Title>
|
||
<Text
|
||
type="secondary"
|
||
style={{
|
||
display: 'block',
|
||
textAlign: 'center',
|
||
marginTop: -20,
|
||
marginBottom: 24,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
当前版本 {versionText}
|
||
</Text>
|
||
|
||
{mode === 'secure' ? (
|
||
<>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="安全登录"
|
||
description="用户名密码验证后,还需短信验证码和本机 DCAuth 设备签名。"
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
|
||
{waiting ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message="等待本机确认"
|
||
description={
|
||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||
<span>请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth。</span>
|
||
{launchUrl ? (
|
||
<Text code copyable={{ text: launchUrl }} style={{ wordBreak: 'break-all' }}>
|
||
{launchUrl}
|
||
</Text>
|
||
) : null}
|
||
</Space>
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
) : null}
|
||
|
||
<Form form={form} onFinish={handleSecureLogin} size="large" requiredMark={false}>
|
||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||
<Input
|
||
prefix={<UserOutlined />}
|
||
placeholder="用户名(中文 / 英文 / 数字)"
|
||
autoComplete="username"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||
<Input.Password
|
||
prefix={<LockOutlined />}
|
||
placeholder="密码"
|
||
autoComplete="current-password"
|
||
ref={passwordRef}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="smsCode"
|
||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||
>
|
||
<Input
|
||
prefix={<SafetyCertificateOutlined />}
|
||
placeholder={phoneMasked ? `6 位验证码(${phoneMasked})` : '6 位短信验证码'}
|
||
maxLength={6}
|
||
autoComplete="one-time-code"
|
||
addonAfter={
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
htmlType="button"
|
||
loading={sendingCode}
|
||
disabled={countdown > 0 || loading}
|
||
onClick={() => void sendCode()}
|
||
style={{ padding: 0, height: 'auto' }}
|
||
>
|
||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||
</Button>
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||
安全登录 · 拉起本机验证
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Divider plain style={{ margin: '4px 0 12px' }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
未绑定手机号的旧账号
|
||
</Text>
|
||
</Divider>
|
||
<Button type="link" block onClick={() => switchMode('legacy')} disabled={loading}>
|
||
旧账号仅密码登录
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message="旧账号仅密码登录"
|
||
description="仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。"
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
<Form form={form} onFinish={handleLegacyLogin} size="large" requiredMark={false}>
|
||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||
<Input
|
||
prefix={<UserOutlined />}
|
||
placeholder="用户名"
|
||
autoComplete="username"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||
<Input.Password
|
||
prefix={<LockOutlined />}
|
||
placeholder="密码"
|
||
autoComplete="current-password"
|
||
ref={passwordRef}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||
密码登录
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
<Button type="link" block onClick={() => switchMode('secure')} disabled={loading}>
|
||
<MobileOutlined /> 返回安全登录
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|