From e8425addf08da67ca487fc71b42cdf9b5a22d17a Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 12:12:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=9A=E7=94=A8=E4=BA=8C=E6=AD=A5?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel.go | 123 +------ controller/secure_verification.go | 313 ++++++++++++++++++ middleware/secure_verification.go | 131 ++++++++ router/api-router.go | 7 +- .../common/modals/SecureVerificationModal.jsx | 298 +++++++++-------- web/src/components/settings/SystemSetting.jsx | 2 +- .../channels/modals/EditChannelModal.jsx | 43 ++- web/src/helpers/secureApiCall.js | 62 ++++ .../hooks/common/useSecureVerification.jsx | 47 ++- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 3 +- web/src/services/secureVerification.js | 91 +++-- 12 files changed, 798 insertions(+), 323 deletions(-) create mode 100644 controller/secure_verification.go create mode 100644 middleware/secure_verification.go create mode 100644 web/src/helpers/secureApiCall.js diff --git a/controller/channel.go b/controller/channel.go index 542f35fd6..4aedee3b3 100644 --- a/controller/channel.go +++ b/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, }, diff --git a/controller/secure_verification.go b/controller/secure_verification.go new file mode 100644 index 000000000..1c5f0981a --- /dev/null +++ b/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, + }, + }) +} diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go new file mode 100644 index 000000000..19fae9a59 --- /dev/null +++ b/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() +} diff --git a/router/api-router.go b/router/api-router.go index 31d4ba3f8..4afc0a0fa 100644 --- a/router/api-router.go +++ b/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) diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx index 46770aa74..06f18c7e6 100644 --- a/web/src/components/common/modals/SecureVerificationModal.jsx +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -17,9 +17,9 @@ along with this program. If not, see . 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 ( -
- - - -
- {title || t('安全验证')} - - } + 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' + }} > -
- {/* 安全提示 */} -
-
- - - -
- - {t('安全验证')} - - - {description || t('为了保护账户安全,请选择一种方式进行验证。')} - -
-
-
+
+ {/* 描述信息 */} + {description && ( + + {description} + + )} {/* 验证方式选择 */} - + {has2FA && ( - - - - - {t('两步验证')} -
- } + tab={t('两步验证')} itemKey='2fa' > - -
-
- - {t('验证码')} - - - - {t('支持6位TOTP验证码或8位备用码')} - -
-
- - -
+
+
+ + + + } + style={{ width: '100%' }} + />
- + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
+ + +
+
)} {hasPasskey && passkeySupported && ( - - - - {t('Passkey')} -
- } + tab={t('Passkey')} itemKey='passkey' > - -
-
-
- - - -
- - {t('使用 Passkey 验证')} - - - {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} - -
-
- - +
+
+
+ + +
+ + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} +
- + +
+ + +
+
)} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index abb55301a..f0c2dbc3a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -1043,7 +1043,7 @@ const SystemSetting = () => { handleCheckboxChange('passkey.enabled', e) } > - {t('允许通过 Passkey 登录 & 注册')} + {t('允许通过 Passkey 登录 & 认证')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 27499f824..54b4525d6 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/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('获取密钥失败')); } }; diff --git a/web/src/helpers/secureApiCall.js b/web/src/helpers/secureApiCall.js new file mode 100644 index 000000000..b82a6ae92 --- /dev/null +++ b/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 . + +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 + }; +} \ No newline at end of file diff --git a/web/src/hooks/common/useSecureVerification.jsx b/web/src/hooks/common/useSecureVerification.jsx index 271345d1c..e60a104db 100644 --- a/web/src/hooks/common/useSecureVerification.jsx +++ b/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} + */ + 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, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e221c3b28..5586e0a83 100644 --- a/web/src/i18n/locales/en.json +++ b/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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 26c418205..e6dafac18 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -87,5 +87,6 @@ "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。", "目标用户:{{username}}": "目标用户:{{username}}", "Passkey 已重置": "Passkey 已重置", - "二步验证已重置": "二步验证已重置" + "二步验证已重置": "二步验证已重置", + "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证" } diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 1af53204b..93cdd0a4d 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -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} API响应结果 + * @returns {Promise} */ - 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} API响应结果 + * @returns {Promise} */ - 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} API响应结果 + * @param {string} code - 2FA验证码(当method为'2fa'时必需) + * @returns {Promise} */ - 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; } }; \ No newline at end of file