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