feat: 通用二步验证

This commit is contained in:
Seefs
2025-09-30 12:12:50 +08:00
parent 84cdd24116
commit e8425addf0
12 changed files with 798 additions and 323 deletions

View File

@@ -384,19 +384,9 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 验证2FA或Passkey后获取渠道密钥
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code,omitempty"` // 2FA验证码或备用码
Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey"
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -404,111 +394,6 @@ func GetChannelKey(c *gin.Context) {
return
}
// 检查用户支持的验证方式
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
has2FA := twoFA != nil && twoFA.IsEnabled
// 至少需要启用一种验证方式
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey无法查看密钥"))
return
}
// 根据请求的验证方式进行验证
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("2FA验证码不能为空"))
return
}
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey验证已在前端完成这里只需要检查是否有有效的Passkey验证会话
// 由于Passkey验证是基于WebAuthn协议的验证过程已经在PasskeyVerifyFinish中完成
// 这里我们可以设置一个临时标记来验证Passkey验证是否成功
default:
// 自动选择验证方式如果提供了code则使用2FA否则需要用户明确指定
if req.Code != "" && has2FA {
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
} else {
common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')"))
return
}
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
logMethod := req.Method
if logMethod == "" {
logMethod = "2fa"
}
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥
func GetChannelKeyWithPasskey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 检查用户是否已绑定Passkey
passkey, err := model.GetPasskeyByUserID(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey无法使用此验证方式"))
return
}
if passkey == nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
@@ -522,12 +407,12 @@ func GetChannelKeyWithPasskey(c *gin.Context) {
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId))
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey验证成功",
"message": "获取成功",
"data": map[string]interface{}{
"key": channel.Key,
},

View File

@@ -0,0 +1,313 @@
package controller
import (
"fmt"
"net/http"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-api/setting/system_setting"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Code string `json:"code,omitempty"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
// 这里只是验证 Passkey 验证流程是否已经完成
// 实际上,前端应该先调用这两个接口,然后再调用本接口
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
},
})
}
// GetVerificationStatus 获取验证状态
func GetVerificationStatus(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: true,
ExpiresAt: verifiedAt + SecureVerificationTimeout,
},
})
}
// CheckSecureVerification 检查是否已通过安全验证
// 返回 true 表示验证有效false 表示需要重新验证
func CheckSecureVerification(c *gin.Context) bool {
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
return false
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
return false
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
return false
}
return true
}
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
_ = session.Save()
}
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
// 整合了 begin 和 finish 流程
func PasskeyVerifyForSecure(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
credential, err := model.GetPasskeyByUserID(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
// 验证成功,设置 session
PasskeyVerifyAndSetSession(c)
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"verified": true,
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
},
})
}

View File

@@ -0,0 +1,131 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
// SecureVerificationRequired 安全验证中间件
// 检查用户是否在有效时间内通过了安全验证
// 如果未验证或验证已过期,返回 401 错误
func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查用户是否已登录
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查 session 中的验证时间戳
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要安全验证",
"code": "VERIFICATION_REQUIRED",
})
c.Abort()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
"code": "VERIFICATION_INVALID",
})
c.Abort()
return
}
// 检查验证是否过期
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
"code": "VERIFICATION_EXPIRED",
})
c.Abort()
return
}
// 验证有效,继续处理请求
c.Next()
}
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
func OptionalSecureVerification() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.Set("secure_verified", false)
c.Next()
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.Set("secure_verified", false)
c.Next()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.Set("secure_verified", false)
c.Next()
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.Set("secure_verified", false)
c.Next()
return
}
c.Set("secure_verified", true)
c.Set("secure_verified_at", verifiedAt)
c.Next()
}
}
// ClearSecureVerification 清除安全验证状态
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
}

View File

@@ -40,6 +40,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
@@ -124,8 +128,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

View File

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

View File

@@ -1043,7 +1043,7 @@ const SystemSetting = () => {
handleCheckboxChange('passkey.enabled', e)
}
>
{t('允许通过 Passkey 登录 & 注册')}
{t('允许通过 Passkey 登录 & 认证')}
</Form.Checkbox>
</Col>
</Row>

View File

@@ -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('获取密钥失败'));
}
};

View 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
};
}

View File

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

View File

@@ -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",

View File

@@ -87,5 +87,6 @@
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
"目标用户:{{username}}": "目标用户:{{username}}",
"Passkey 已重置": "Passkey 已重置",
"二步验证已重置": "二步验证已重置"
"二步验证已重置": "二步验证已重置",
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证"
}

View File

@@ -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 设置 sessionPasskey 验证已完成)
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;
}
};