mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:25:00 +00:00
feat: 通用二步验证
This commit is contained in:
@@ -384,19 +384,9 @@ func GetChannel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelKey 验证2FA或Passkey后获取渠道密钥
|
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
|
||||||
|
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
|
||||||
func GetChannelKey(c *gin.Context) {
|
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")
|
userId := c.GetInt("id")
|
||||||
channelId, err := strconv.Atoi(c.Param("id"))
|
channelId, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -404,111 +394,6 @@ func GetChannelKey(c *gin.Context) {
|
|||||||
return
|
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)
|
channel, err := model.GetChannelById(channelId, true)
|
||||||
if err != nil {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Passkey验证成功",
|
"message": "获取成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"key": channel.Key,
|
"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)
|
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 := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
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", controller.ChannelListModels)
|
||||||
channelRoute.GET("/models_enabled", controller.EnabledListModels)
|
channelRoute.GET("/models_enabled", controller.EnabledListModels)
|
||||||
channelRoute.GET("/:id", controller.GetChannel)
|
channelRoute.GET("/:id", controller.GetChannel)
|
||||||
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
|
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
|
||||||
channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey)
|
|
||||||
channelRoute.GET("/test", controller.TestAllChannels)
|
channelRoute.GET("/test", controller.TestAllChannels)
|
||||||
channelRoute.GET("/test/:id", controller.TestChannel)
|
channelRoute.GET("/test/:id", controller.TestChannel)
|
||||||
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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,
|
description,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const [verifySuccess, setVerifySuccess] = useState(false);
|
||||||
|
|
||||||
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
||||||
const { method, loading, code } = verificationState;
|
const { method, loading, code } = verificationState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setIsAnimating(true);
|
||||||
|
setVerifySuccess(false);
|
||||||
|
} else {
|
||||||
|
setIsAnimating(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
||||||
onVerify(method, code);
|
onVerify(method, code);
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && !loading) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果用户没有启用任何验证方式
|
// 如果用户没有启用任何验证方式
|
||||||
@@ -101,165 +115,165 @@ const SecureVerificationModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={title || t('安全验证')}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onCancel={onCancel}
|
onCancel={loading ? undefined : onCancel}
|
||||||
|
closeOnEsc={!loading}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={460}
|
||||||
style={{ maxWidth: '90vw' }}
|
centered
|
||||||
|
style={{
|
||||||
|
maxWidth: 'calc(100vw - 32px)'
|
||||||
|
}}
|
||||||
|
bodyStyle={{
|
||||||
|
padding: '20px 24px'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className='space-y-6'>
|
<div style={{ width: '100%' }}>
|
||||||
{/* 安全提示 */}
|
{/* 描述信息 */}
|
||||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
{description && (
|
||||||
<div className='flex items-start'>
|
<Typography.Paragraph
|
||||||
<svg
|
type="tertiary"
|
||||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
style={{
|
||||||
fill='currentColor'
|
margin: '0 0 20px 0',
|
||||||
viewBox='0 0 20 20'
|
fontSize: '14px',
|
||||||
>
|
lineHeight: '1.6'
|
||||||
<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'
|
{description}
|
||||||
clipRule='evenodd'
|
</Typography.Paragraph>
|
||||||
/>
|
)}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 验证方式选择 */}
|
{/* 验证方式选择 */}
|
||||||
<Tabs activeKey={method} onChange={onMethodSwitch} type='card'>
|
<Tabs
|
||||||
|
activeKey={method}
|
||||||
|
onChange={onMethodSwitch}
|
||||||
|
type='line'
|
||||||
|
size='default'
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
{has2FA && (
|
{has2FA && (
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={t('两步验证')}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
itemKey='2fa'
|
itemKey='2fa'
|
||||||
>
|
>
|
||||||
<Card className='border-0 shadow-none bg-transparent'>
|
<div style={{ paddingTop: '20px' }}>
|
||||||
<div className='space-y-4'>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div>
|
<Input
|
||||||
<Typography.Text strong className='block mb-2'>
|
placeholder={t('请输入6位验证码或8位备用码')}
|
||||||
{t('验证码')}
|
value={code}
|
||||||
</Typography.Text>
|
onChange={onCodeChange}
|
||||||
<Input
|
size='large'
|
||||||
placeholder={t('请输入认证器验证码或备用码')}
|
maxLength={8}
|
||||||
value={code}
|
onKeyDown={handleKeyDown}
|
||||||
onChange={onCodeChange}
|
autoFocus={method === '2fa'}
|
||||||
size='large'
|
disabled={loading}
|
||||||
maxLength={8}
|
prefix={
|
||||||
onKeyDown={handleKeyDown}
|
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
|
||||||
autoFocus={method === '2fa'}
|
<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>
|
||||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
}
|
||||||
{t('支持6位TOTP验证码或8位备用码')}
|
style={{ width: '100%' }}
|
||||||
</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>
|
</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>
|
</TabPane>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPasskey && passkeySupported && (
|
{hasPasskey && passkeySupported && (
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={t('Passkey')}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
itemKey='passkey'
|
itemKey='passkey'
|
||||||
>
|
>
|
||||||
<Card className='border-0 shadow-none bg-transparent'>
|
<div style={{ paddingTop: '20px' }}>
|
||||||
<div className='space-y-4'>
|
<div style={{
|
||||||
<div className='text-center py-4'>
|
textAlign: 'center',
|
||||||
<div className='mb-4'>
|
padding: '24px 16px',
|
||||||
<svg
|
marginBottom: '20px'
|
||||||
className='w-16 h-16 text-blue-500 mx-auto'
|
}}>
|
||||||
fill='currentColor'
|
<div style={{
|
||||||
viewBox='0 0 20 20'
|
width: 56,
|
||||||
>
|
height: 56,
|
||||||
<path
|
margin: '0 auto 16px',
|
||||||
fillRule='evenodd'
|
display: 'flex',
|
||||||
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'
|
alignItems: 'center',
|
||||||
clipRule='evenodd'
|
justifyContent: 'center',
|
||||||
/>
|
borderRadius: '50%',
|
||||||
</svg>
|
background: 'var(--semi-color-primary-light-default)',
|
||||||
</div>
|
}}>
|
||||||
<Typography.Text strong className='block mb-2'>
|
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
|
||||||
{t('使用 Passkey 验证')}
|
<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' />
|
||||||
</Typography.Text>
|
</svg>
|
||||||
<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>
|
</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>
|
||||||
</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>
|
</TabPane>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1043,7 +1043,7 @@ const SystemSetting = () => {
|
|||||||
handleCheckboxChange('passkey.enabled', e)
|
handleCheckboxChange('passkey.enabled', e)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('允许通过 Passkey 登录 & 注册')}
|
{t('允许通过 Passkey 登录 & 认证')}
|
||||||
</Form.Checkbox>
|
</Form.Checkbox>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const EditChannelModal = (props) => {
|
|||||||
isModalVisible,
|
isModalVisible,
|
||||||
verificationMethods,
|
verificationMethods,
|
||||||
verificationState,
|
verificationState,
|
||||||
startVerification,
|
withVerification,
|
||||||
executeVerification,
|
executeVerification,
|
||||||
cancelVerification,
|
cancelVerification,
|
||||||
setVerificationCode,
|
setVerificationCode,
|
||||||
@@ -214,12 +214,20 @@ const EditChannelModal = (props) => {
|
|||||||
} = useSecureVerification({
|
} = useSecureVerification({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
// 验证成功后显示密钥
|
// 验证成功后显示密钥
|
||||||
if (result.success && result.data?.key) {
|
console.log('Verification success, result:', result);
|
||||||
|
if (result && result.success && result.data?.key) {
|
||||||
showSuccess(t('密钥获取成功'));
|
showSuccess(t('密钥获取成功'));
|
||||||
setKeyDisplayState({
|
setKeyDisplayState({
|
||||||
showModal: true,
|
showModal: true,
|
||||||
keyData: result.data.key,
|
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 () => {
|
const handleShow2FAModal = async () => {
|
||||||
try {
|
try {
|
||||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
// 使用 withVerification 包装,会自动处理需要验证的情况
|
||||||
|
const result = await withVerification(
|
||||||
await startVerification(apiCall, {
|
createApiCalls.viewChannelKey(channelId),
|
||||||
title: t('查看渠道密钥'),
|
{
|
||||||
description: t('为了保护账户安全,请验证您的身份。'),
|
title: t('查看渠道密钥'),
|
||||||
preferredMethod: 'passkey', // 优先使用 Passkey
|
description: t('为了保护账户安全,请验证您的身份。'),
|
||||||
});
|
preferredMethod: 'passkey', // 优先使用 Passkey
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果直接返回了结果(已验证),显示密钥
|
||||||
|
if (result && result.success && result.data?.key) {
|
||||||
|
showSuccess(t('密钥获取成功'));
|
||||||
|
setKeyDisplayState({
|
||||||
|
showModal: true,
|
||||||
|
keyData: result.data.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start verification:', error);
|
console.error('Failed to view channel key:', error);
|
||||||
showError(error.message || t('启动验证失败'));
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { SecureVerificationService } from '../../services/secureVerification';
|
import { SecureVerificationService } from '../../services/secureVerification';
|
||||||
import { showError, showSuccess } from '../../helpers';
|
import { showError, showSuccess } from '../../helpers';
|
||||||
|
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用安全验证 Hook
|
* 通用安全验证 Hook
|
||||||
@@ -82,10 +83,10 @@ export const useSecureVerification = ({
|
|||||||
// 开始验证流程
|
// 开始验证流程
|
||||||
const startVerification = useCallback(async (apiCall, options = {}) => {
|
const startVerification = useCallback(async (apiCall, options = {}) => {
|
||||||
const { preferredMethod, title, description } = options;
|
const { preferredMethod, title, description } = options;
|
||||||
|
|
||||||
// 检查验证方式
|
// 检查验证方式
|
||||||
const methods = await checkVerificationMethods();
|
const methods = await checkVerificationMethods();
|
||||||
|
|
||||||
if (!methods.has2FA && !methods.hasPasskey) {
|
if (!methods.has2FA && !methods.hasPasskey) {
|
||||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||||
showError(errorMessage);
|
showError(errorMessage);
|
||||||
@@ -111,7 +112,7 @@ export const useSecureVerification = ({
|
|||||||
description
|
description
|
||||||
}));
|
}));
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, [checkVerificationMethods, onError, t]);
|
}, [checkVerificationMethods, onError, t]);
|
||||||
|
|
||||||
@@ -125,10 +126,11 @@ export const useSecureVerification = ({
|
|||||||
setVerificationState(prev => ({ ...prev, loading: true }));
|
setVerificationState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await SecureVerificationService.verify(method, {
|
// 先调用验证 API,成功后后端会设置 session
|
||||||
code,
|
await SecureVerificationService.verify(method, code);
|
||||||
apiCall: verificationState.apiCall
|
|
||||||
});
|
// 验证成功,调用业务 API(此时中间件会通过)
|
||||||
|
const result = await verificationState.apiCall();
|
||||||
|
|
||||||
// 显示成功消息
|
// 显示成功消息
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
@@ -191,12 +193,36 @@ export const useSecureVerification = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [verificationMethods]);
|
}, [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 {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
isModalVisible,
|
isModalVisible,
|
||||||
verificationMethods,
|
verificationMethods,
|
||||||
verificationState,
|
verificationState,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
startVerification,
|
startVerification,
|
||||||
executeVerification,
|
executeVerification,
|
||||||
@@ -205,11 +231,12 @@ export const useSecureVerification = ({
|
|||||||
setVerificationCode,
|
setVerificationCode,
|
||||||
switchVerificationMethod,
|
switchVerificationMethod,
|
||||||
checkVerificationMethods,
|
checkVerificationMethods,
|
||||||
|
|
||||||
// 辅助方法
|
// 辅助方法
|
||||||
canUseMethod,
|
canUseMethod,
|
||||||
getRecommendedMethod,
|
getRecommendedMethod,
|
||||||
|
withVerification, // 新增:自动处理验证的包装函数
|
||||||
|
|
||||||
// 便捷属性
|
// 便捷属性
|
||||||
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||||
isLoading: verificationState.loading,
|
isLoading: verificationState.loading,
|
||||||
|
|||||||
@@ -333,6 +333,7 @@
|
|||||||
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
|
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
|
||||||
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
|
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
|
||||||
"允许通过微信登录 & 注册": "Allow login & registration via WeChat",
|
"允许通过微信登录 & 注册": "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",
|
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "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",
|
"启用 Turnstile 用户校验": "Enable Turnstile user verification",
|
||||||
"配置 SMTP": "Configure SMTP",
|
"配置 SMTP": "Configure SMTP",
|
||||||
|
|||||||
@@ -87,5 +87,6 @@
|
|||||||
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
|
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
|
||||||
"目标用户:{{username}}": "目标用户:{{username}}",
|
"目标用户:{{username}}": "目标用户:{{username}}",
|
||||||
"Passkey 已重置": "Passkey 已重置",
|
"Passkey 已重置": "Passkey 已重置",
|
||||||
"二步验证已重置": "二步验证已重置"
|
"二步验证已重置": "二步验证已重置",
|
||||||
|
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { API, showError } from '../helpers';
|
import { API, showError } from '../helpers';
|
||||||
import {
|
import {
|
||||||
prepareCredentialRequestOptions,
|
prepareCredentialRequestOptions,
|
||||||
buildAssertionResult,
|
buildAssertionResult,
|
||||||
isPasskeySupported
|
isPasskeySupported
|
||||||
} from '../helpers/passkey';
|
} from '../helpers/passkey';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用安全验证服务
|
* 通用安全验证服务
|
||||||
|
* 验证状态完全由后端 Session 控制,前端不存储任何状态
|
||||||
*/
|
*/
|
||||||
export class SecureVerificationService {
|
export class SecureVerificationService {
|
||||||
/**
|
/**
|
||||||
@@ -81,36 +82,41 @@ export class SecureVerificationService {
|
|||||||
/**
|
/**
|
||||||
* 执行2FA验证
|
* 执行2FA验证
|
||||||
* @param {string} code - 验证码
|
* @param {string} code - 验证码
|
||||||
* @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数
|
* @returns {Promise<void>}
|
||||||
* @returns {Promise<any>} API响应结果
|
|
||||||
*/
|
*/
|
||||||
static async verify2FA(code, apiCall) {
|
static async verify2FA(code) {
|
||||||
if (!code?.trim()) {
|
if (!code?.trim()) {
|
||||||
throw new Error('请输入验证码或备用码');
|
throw new Error('请输入验证码或备用码');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await apiCall({
|
// 调用通用验证 API,验证成功后后端会设置 session
|
||||||
|
const verifyResponse = await API.post('/api/verify', {
|
||||||
method: '2fa',
|
method: '2fa',
|
||||||
code: code.trim()
|
code: code.trim()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!verifyResponse.data?.success) {
|
||||||
|
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,session 已在后端设置
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行Passkey验证
|
* 执行Passkey验证
|
||||||
* @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数
|
* @returns {Promise<void>}
|
||||||
* @returns {Promise<any>} API响应结果
|
|
||||||
*/
|
*/
|
||||||
static async verifyPasskey(apiCall) {
|
static async verifyPasskey() {
|
||||||
try {
|
try {
|
||||||
// 开始Passkey验证
|
// 开始Passkey验证
|
||||||
const beginResponse = await API.post('/api/user/passkey/verify/begin');
|
const beginResponse = await API.post('/api/user/passkey/verify/begin');
|
||||||
if (!beginResponse.success) {
|
if (!beginResponse.data?.success) {
|
||||||
throw new Error(beginResponse.message);
|
throw new Error(beginResponse.data?.message || '开始验证失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 准备WebAuthn选项
|
// 准备WebAuthn选项
|
||||||
const publicKey = prepareCredentialRequestOptions(beginResponse.data);
|
const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
|
||||||
|
|
||||||
// 执行WebAuthn验证
|
// 执行WebAuthn验证
|
||||||
const credential = await navigator.credentials.get({ publicKey });
|
const credential = await navigator.credentials.get({ publicKey });
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
@@ -119,17 +125,23 @@ export class SecureVerificationService {
|
|||||||
|
|
||||||
// 构建验证结果
|
// 构建验证结果
|
||||||
const assertionResult = buildAssertionResult(credential);
|
const assertionResult = buildAssertionResult(credential);
|
||||||
|
|
||||||
// 完成验证
|
// 完成验证
|
||||||
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
|
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
|
||||||
if (!finishResponse.success) {
|
if (!finishResponse.data?.success) {
|
||||||
throw new Error(finishResponse.message);
|
throw new Error(finishResponse.data?.message || '验证失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用业务API
|
// 调用通用验证 API 设置 session(Passkey 验证已完成)
|
||||||
return await apiCall({
|
const verifyResponse = await API.post('/api/verify', {
|
||||||
method: 'passkey'
|
method: 'passkey'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!verifyResponse.data?.success) {
|
||||||
|
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,session 已在后端设置
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'NotAllowedError') {
|
if (error.name === 'NotAllowedError') {
|
||||||
throw new Error('Passkey 验证被取消或超时');
|
throw new Error('Passkey 验证被取消或超时');
|
||||||
@@ -144,17 +156,15 @@ export class SecureVerificationService {
|
|||||||
/**
|
/**
|
||||||
* 通用验证方法,根据验证类型执行相应的验证流程
|
* 通用验证方法,根据验证类型执行相应的验证流程
|
||||||
* @param {string} method - 验证方式: '2fa' | 'passkey'
|
* @param {string} method - 验证方式: '2fa' | 'passkey'
|
||||||
* @param {Object} params - 参数对象
|
* @param {string} code - 2FA验证码(当method为'2fa'时必需)
|
||||||
* @param {string} params.code - 2FA验证码(当method为'2fa'时必需)
|
* @returns {Promise<void>}
|
||||||
* @param {Function} params.apiCall - API调用函数
|
|
||||||
* @returns {Promise<any>} API响应结果
|
|
||||||
*/
|
*/
|
||||||
static async verify(method, { code, apiCall }) {
|
static async verify(method, code = '') {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case '2fa':
|
case '2fa':
|
||||||
return await this.verify2FA(code, apiCall);
|
return await this.verify2FA(code);
|
||||||
case 'passkey':
|
case 'passkey':
|
||||||
return await this.verifyPasskey(apiCall);
|
return await this.verifyPasskey();
|
||||||
default:
|
default:
|
||||||
throw new Error(`不支持的验证方式: ${method}`);
|
throw new Error(`不支持的验证方式: ${method}`);
|
||||||
}
|
}
|
||||||
@@ -169,8 +179,10 @@ export const createApiCalls = {
|
|||||||
* 创建查看渠道密钥的API调用
|
* 创建查看渠道密钥的API调用
|
||||||
* @param {number} channelId - 渠道ID
|
* @param {number} channelId - 渠道ID
|
||||||
*/
|
*/
|
||||||
viewChannelKey: (channelId) => async (verificationData) => {
|
viewChannelKey: (channelId) => async () => {
|
||||||
return await API.post(`/api/channel/${channelId}/key`, verificationData);
|
// 新系统中,验证已通过中间件处理,直接调用 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 {string} method - HTTP方法,默认为 'POST'
|
||||||
* @param {Object} extraData - 额外的请求数据
|
* @param {Object} extraData - 额外的请求数据
|
||||||
*/
|
*/
|
||||||
custom: (url, method = 'POST', extraData = {}) => async (verificationData) => {
|
custom: (url, method = 'POST', extraData = {}) => async () => {
|
||||||
const data = { ...extraData, ...verificationData };
|
// 新系统中,验证已通过中间件处理
|
||||||
|
const data = extraData;
|
||||||
|
|
||||||
|
let response;
|
||||||
switch (method.toUpperCase()) {
|
switch (method.toUpperCase()) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
return await API.get(url, { params: data });
|
response = await API.get(url, { params: data });
|
||||||
|
break;
|
||||||
case 'POST':
|
case 'POST':
|
||||||
return await API.post(url, data);
|
response = await API.post(url, data);
|
||||||
|
break;
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
return await API.put(url, data);
|
response = await API.put(url, data);
|
||||||
|
break;
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
return await API.delete(url, { data });
|
response = await API.delete(url, { data });
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`不支持的HTTP方法: ${method}`);
|
throw new Error(`不支持的HTTP方法: ${method}`);
|
||||||
}
|
}
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user