feat: 通用二步验证
This commit is contained in:
@@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui';
|
||||
import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
/**
|
||||
* 通用安全验证模态框组件
|
||||
@@ -47,14 +47,28 @@ const SecureVerificationModal = ({
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [verifySuccess, setVerifySuccess] = useState(false);
|
||||
|
||||
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
||||
const { method, loading, code } = verificationState;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsAnimating(true);
|
||||
setVerifySuccess(false);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
||||
onVerify(method, code);
|
||||
}
|
||||
if (e.key === 'Escape' && !loading) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// 如果用户没有启用任何验证方式
|
||||
@@ -101,165 +115,165 @@ const SecureVerificationModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{title || t('安全验证')}
|
||||
</div>
|
||||
}
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onCancel={loading ? undefined : onCancel}
|
||||
closeOnEsc={!loading}
|
||||
footer={null}
|
||||
width={600}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
width={460}
|
||||
centered
|
||||
style={{
|
||||
maxWidth: 'calc(100vw - 32px)'
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '20px 24px'
|
||||
}}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* 安全提示 */}
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-blue-800 dark:text-blue-200'
|
||||
>
|
||||
{t('安全验证')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||
{description || t('为了保护账户安全,请选择一种方式进行验证。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
type="tertiary"
|
||||
style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{/* 验证方式选择 */}
|
||||
<Tabs activeKey={method} onChange={onMethodSwitch} type='card'>
|
||||
<Tabs
|
||||
activeKey={method}
|
||||
onChange={onMethodSwitch}
|
||||
type='line'
|
||||
size='default'
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{has2FA && (
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center space-x-2'>
|
||||
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path d='M10 12a2 2 0 100-4 2 2 0 000 4z' />
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('两步验证')}</span>
|
||||
</div>
|
||||
}
|
||||
tab={t('两步验证')}
|
||||
itemKey='2fa'
|
||||
>
|
||||
<Card className='border-0 shadow-none bg-transparent'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('验证码')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={t('请输入认证器验证码或备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={method === '2fa'}
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code.trim() || loading}
|
||||
onClick={() => onVerify(method, code)}
|
||||
>
|
||||
{t('验证')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Input
|
||||
placeholder={t('请输入6位验证码或8位备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={method === '2fa'}
|
||||
disabled={loading}
|
||||
prefix={
|
||||
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
</svg>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Typography.Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '20px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{t('从认证器应用中获取验证码,或使用备用码')}
|
||||
</Typography.Text>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code.trim() || loading}
|
||||
onClick={() => onVerify(method, code)}
|
||||
>
|
||||
{t('验证')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
|
||||
{hasPasskey && passkeySupported && (
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center space-x-2'>
|
||||
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('Passkey')}</span>
|
||||
</div>
|
||||
}
|
||||
tab={t('Passkey')}
|
||||
itemKey='passkey'
|
||||
>
|
||||
<Card className='border-0 shadow-none bg-transparent'>
|
||||
<div className='space-y-4'>
|
||||
<div className='text-center py-4'>
|
||||
<div className='mb-4'>
|
||||
<svg
|
||||
className='w-16 h-16 text-blue-500 mx-auto'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('使用 Passkey 验证')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' className='block mb-4'>
|
||||
{t('点击下方按钮,使用您的生物特征或安全密钥进行验证')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => onVerify(method)}
|
||||
>
|
||||
{loading ? t('验证中...') : t('验证 Passkey')}
|
||||
</Button>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
}}>
|
||||
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
|
||||
{t('使用 Passkey 验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{t('点击验证按钮,使用您的生物特征或安全密钥')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => onVerify(method)}
|
||||
>
|
||||
{t('验证 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -1043,7 +1043,7 @@ const SystemSetting = () => {
|
||||
handleCheckboxChange('passkey.enabled', e)
|
||||
}
|
||||
>
|
||||
{t('允许通过 Passkey 登录 & 注册')}
|
||||
{t('允许通过 Passkey 登录 & 认证')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -206,7 +206,7 @@ const EditChannelModal = (props) => {
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
startVerification,
|
||||
withVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
setVerificationCode,
|
||||
@@ -214,12 +214,20 @@ const EditChannelModal = (props) => {
|
||||
} = useSecureVerification({
|
||||
onSuccess: (result) => {
|
||||
// 验证成功后显示密钥
|
||||
if (result.success && result.data?.key) {
|
||||
console.log('Verification success, result:', result);
|
||||
if (result && result.success && result.data?.key) {
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
keyData: result.data.key,
|
||||
});
|
||||
} else if (result && result.key) {
|
||||
// 直接返回了 key(没有包装在 data 中)
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
keyData: result.key,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -604,19 +612,30 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 显示安全验证模态框并开始验证流程
|
||||
// 查看渠道密钥(透明验证)
|
||||
const handleShow2FAModal = async () => {
|
||||
try {
|
||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||
|
||||
await startVerification(apiCall, {
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 优先使用 Passkey
|
||||
});
|
||||
// 使用 withVerification 包装,会自动处理需要验证的情况
|
||||
const result = await withVerification(
|
||||
createApiCalls.viewChannelKey(channelId),
|
||||
{
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 优先使用 Passkey
|
||||
}
|
||||
);
|
||||
|
||||
// 如果直接返回了结果(已验证),显示密钥
|
||||
if (result && result.success && result.data?.key) {
|
||||
showSuccess(t('密钥获取成功'));
|
||||
setKeyDisplayState({
|
||||
showModal: true,
|
||||
keyData: result.data.key,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start verification:', error);
|
||||
showError(error.message || t('启动验证失败'));
|
||||
console.error('Failed to view channel key:', error);
|
||||
showError(error.message || t('获取密钥失败'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
62
web/src/helpers/secureApiCall.js
Normal file
62
web/src/helpers/secureApiCall.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
/**
|
||||
* 安全 API 调用包装器
|
||||
* 自动处理需要验证的 403 错误,透明地触发验证流程
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查错误是否是需要安全验证的错误
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isVerificationRequiredError(error) {
|
||||
if (!error.response) return false;
|
||||
|
||||
const { status, data } = error.response;
|
||||
|
||||
// 检查是否是 403 错误且包含验证相关的错误码
|
||||
if (status === 403 && data) {
|
||||
const verificationCodes = [
|
||||
'VERIFICATION_REQUIRED',
|
||||
'VERIFICATION_EXPIRED',
|
||||
'VERIFICATION_INVALID'
|
||||
];
|
||||
|
||||
return verificationCodes.includes(data.code);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误中提取验证需求信息
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {Object} 验证需求信息
|
||||
*/
|
||||
export function extractVerificationInfo(error) {
|
||||
const data = error.response?.data || {};
|
||||
|
||||
return {
|
||||
code: data.code,
|
||||
message: data.message || '需要安全验证',
|
||||
required: true
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SecureVerificationService } from '../../services/secureVerification';
|
||||
import { showError, showSuccess } from '../../helpers';
|
||||
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||
|
||||
/**
|
||||
* 通用安全验证 Hook
|
||||
@@ -82,10 +83,10 @@ export const useSecureVerification = ({
|
||||
// 开始验证流程
|
||||
const startVerification = useCallback(async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
|
||||
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
|
||||
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
@@ -111,7 +112,7 @@ export const useSecureVerification = ({
|
||||
description
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
|
||||
|
||||
return true;
|
||||
}, [checkVerificationMethods, onError, t]);
|
||||
|
||||
@@ -125,10 +126,11 @@ export const useSecureVerification = ({
|
||||
setVerificationState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
const result = await SecureVerificationService.verify(method, {
|
||||
code,
|
||||
apiCall: verificationState.apiCall
|
||||
});
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
@@ -191,12 +193,36 @@ export const useSecureVerification = ({
|
||||
return null;
|
||||
}, [verificationMethods]);
|
||||
|
||||
/**
|
||||
* 包装 API 调用,自动处理验证错误
|
||||
* 当 API 返回需要验证的错误时,自动弹出验证模态框
|
||||
* @param {Function} apiCall - API 调用函数
|
||||
* @param {Object} options - 验证选项(同 startVerification)
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
const withVerification = useCallback(async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
}, [startVerification]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
|
||||
|
||||
// 方法
|
||||
startVerification,
|
||||
executeVerification,
|
||||
@@ -205,11 +231,12 @@ export const useSecureVerification = ({
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
checkVerificationMethods,
|
||||
|
||||
|
||||
// 辅助方法
|
||||
canUseMethod,
|
||||
getRecommendedMethod,
|
||||
|
||||
withVerification, // 新增:自动处理验证的包装函数
|
||||
|
||||
// 便捷属性
|
||||
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
isLoading: verificationState.loading,
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
|
||||
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
|
||||
"允许通过微信登录 & 注册": "Allow login & registration via WeChat",
|
||||
"允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey",
|
||||
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way",
|
||||
"启用 Turnstile 用户校验": "Enable Turnstile user verification",
|
||||
"配置 SMTP": "Configure SMTP",
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
|
||||
"目标用户:{{username}}": "目标用户:{{username}}",
|
||||
"Passkey 已重置": "Passkey 已重置",
|
||||
"二步验证已重置": "二步验证已重置"
|
||||
"二步验证已重置": "二步验证已重置",
|
||||
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证"
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { API, showError } from '../helpers';
|
||||
import {
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported
|
||||
import {
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported
|
||||
} from '../helpers/passkey';
|
||||
|
||||
/**
|
||||
* 通用安全验证服务
|
||||
* 验证状态完全由后端 Session 控制,前端不存储任何状态
|
||||
*/
|
||||
export class SecureVerificationService {
|
||||
/**
|
||||
@@ -81,36 +82,41 @@ export class SecureVerificationService {
|
||||
/**
|
||||
* 执行2FA验证
|
||||
* @param {string} code - 验证码
|
||||
* @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数
|
||||
* @returns {Promise<any>} API响应结果
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verify2FA(code, apiCall) {
|
||||
static async verify2FA(code) {
|
||||
if (!code?.trim()) {
|
||||
throw new Error('请输入验证码或备用码');
|
||||
}
|
||||
|
||||
return await apiCall({
|
||||
// 调用通用验证 API,验证成功后后端会设置 session
|
||||
const verifyResponse = await API.post('/api/verify', {
|
||||
method: '2fa',
|
||||
code: code.trim()
|
||||
});
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 验证成功,session 已在后端设置
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行Passkey验证
|
||||
* @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数
|
||||
* @returns {Promise<any>} API响应结果
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verifyPasskey(apiCall) {
|
||||
static async verifyPasskey() {
|
||||
try {
|
||||
// 开始Passkey验证
|
||||
const beginResponse = await API.post('/api/user/passkey/verify/begin');
|
||||
if (!beginResponse.success) {
|
||||
throw new Error(beginResponse.message);
|
||||
if (!beginResponse.data?.success) {
|
||||
throw new Error(beginResponse.data?.message || '开始验证失败');
|
||||
}
|
||||
|
||||
// 准备WebAuthn选项
|
||||
const publicKey = prepareCredentialRequestOptions(beginResponse.data);
|
||||
|
||||
const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
|
||||
|
||||
// 执行WebAuthn验证
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) {
|
||||
@@ -119,17 +125,23 @@ export class SecureVerificationService {
|
||||
|
||||
// 构建验证结果
|
||||
const assertionResult = buildAssertionResult(credential);
|
||||
|
||||
|
||||
// 完成验证
|
||||
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
|
||||
if (!finishResponse.success) {
|
||||
throw new Error(finishResponse.message);
|
||||
if (!finishResponse.data?.success) {
|
||||
throw new Error(finishResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 调用业务API
|
||||
return await apiCall({
|
||||
// 调用通用验证 API 设置 session(Passkey 验证已完成)
|
||||
const verifyResponse = await API.post('/api/verify', {
|
||||
method: 'passkey'
|
||||
});
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 验证成功,session 已在后端设置
|
||||
} catch (error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
throw new Error('Passkey 验证被取消或超时');
|
||||
@@ -144,17 +156,15 @@ export class SecureVerificationService {
|
||||
/**
|
||||
* 通用验证方法,根据验证类型执行相应的验证流程
|
||||
* @param {string} method - 验证方式: '2fa' | 'passkey'
|
||||
* @param {Object} params - 参数对象
|
||||
* @param {string} params.code - 2FA验证码(当method为'2fa'时必需)
|
||||
* @param {Function} params.apiCall - API调用函数
|
||||
* @returns {Promise<any>} API响应结果
|
||||
* @param {string} code - 2FA验证码(当method为'2fa'时必需)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verify(method, { code, apiCall }) {
|
||||
static async verify(method, code = '') {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return await this.verify2FA(code, apiCall);
|
||||
return await this.verify2FA(code);
|
||||
case 'passkey':
|
||||
return await this.verifyPasskey(apiCall);
|
||||
return await this.verifyPasskey();
|
||||
default:
|
||||
throw new Error(`不支持的验证方式: ${method}`);
|
||||
}
|
||||
@@ -169,8 +179,10 @@ export const createApiCalls = {
|
||||
* 创建查看渠道密钥的API调用
|
||||
* @param {number} channelId - 渠道ID
|
||||
*/
|
||||
viewChannelKey: (channelId) => async (verificationData) => {
|
||||
return await API.post(`/api/channel/${channelId}/key`, verificationData);
|
||||
viewChannelKey: (channelId) => async () => {
|
||||
// 新系统中,验证已通过中间件处理,直接调用 API 即可
|
||||
const response = await API.post(`/api/channel/${channelId}/key`, {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -179,20 +191,27 @@ export const createApiCalls = {
|
||||
* @param {string} method - HTTP方法,默认为 'POST'
|
||||
* @param {Object} extraData - 额外的请求数据
|
||||
*/
|
||||
custom: (url, method = 'POST', extraData = {}) => async (verificationData) => {
|
||||
const data = { ...extraData, ...verificationData };
|
||||
|
||||
custom: (url, method = 'POST', extraData = {}) => async () => {
|
||||
// 新系统中,验证已通过中间件处理
|
||||
const data = extraData;
|
||||
|
||||
let response;
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
return await API.get(url, { params: data });
|
||||
response = await API.get(url, { params: data });
|
||||
break;
|
||||
case 'POST':
|
||||
return await API.post(url, data);
|
||||
response = await API.post(url, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
return await API.put(url, data);
|
||||
response = await API.put(url, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
return await API.delete(url, { data });
|
||||
response = await API.delete(url, { data });
|
||||
break;
|
||||
default:
|
||||
throw new Error(`不支持的HTTP方法: ${method}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user