Seefs 3 месяцев назад
Родитель
Сommit
e8425addf0

+ 4 - 119
controller/channel.go

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

+ 313 - 0
controller/secure_verification.go

@@ -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,
+		},
+	})
+}

+ 131 - 0
middleware/secure_verification.go

@@ -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()
+}

+ 5 - 2
router/api-router.go

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

+ 156 - 142
web/src/components/common/modals/SecureVerificationModal.jsx

@@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-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>
+
+                <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>
-              </Card>
+              </div>
             </TabPane>
           )}
         </Tabs>

+ 1 - 1
web/src/components/settings/SystemSetting.jsx

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

+ 31 - 12
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -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 - 0
web/src/helpers/secureApiCall.js

@@ -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 [email protected]
+*/
+
+/**
+ * 安全 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
+  };
+}

+ 37 - 10
web/src/hooks/common/useSecureVerification.jsx

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

+ 1 - 0
web/src/i18n/locales/en.json

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

+ 2 - 1
web/src/i18n/locales/zh.json

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

+ 55 - 36
web/src/services/secureVerification.js

@@ -18,14 +18,15 @@ For commercial licensing, please contact [email protected]
 */
 
 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;
   }
 };