Procházet zdrojové kódy

Merge pull request #1954 from QuantumNous/main

main -> alpha
Seefs před 5 měsíci
rodič
revize
c1492be131
44 změnil soubory, kde provedl 3732 přidání a 244 odebrání
  1. 1 0
      common/endpoint_defaults.go
  2. 1 0
      constant/endpoint_type.go
  3. 195 82
      controller/channel-test.go
  4. 4 31
      controller/channel.go
  5. 9 0
      controller/misc.go
  6. 497 0
      controller/passkey.go
  7. 313 0
      controller/secure_verification.go
  8. 14 6
      go.mod
  9. 26 11
      go.sum
  10. 3 2
      main.go
  11. 131 0
      middleware/secure_verification.go
  12. 2 0
      model/main.go
  13. 209 0
      model/passkey.go
  14. 1 0
      relay/channel/jina/adaptor.go
  15. 5 1
      relay/channel/openai/relay_responses.go
  16. 18 10
      relay/channel/volcengine/adaptor.go
  17. 14 1
      router/api-router.go
  18. 177 0
      service/passkey/service.go
  19. 50 0
      service/passkey/session.go
  20. 71 0
      service/passkey/user.go
  21. 3 0
      setting/operation_setting/tools.go
  22. 14 11
      setting/ratio_setting/model_ratio.go
  23. 49 0
      setting/system_setting/passkey.go
  24. 86 1
      web/src/components/auth/LoginForm.jsx
  25. 117 0
      web/src/components/common/examples/ChannelKeyViewExample.jsx
  26. 285 0
      web/src/components/common/modals/SecureVerificationModal.jsx
  27. 95 0
      web/src/components/settings/PersonalSetting.jsx
  28. 164 0
      web/src/components/settings/SystemSetting.jsx
  29. 75 0
      web/src/components/settings/personal/cards/AccountManagement.jsx
  30. 74 75
      web/src/components/table/channels/modals/EditChannelModal.jsx
  31. 27 1
      web/src/components/table/channels/modals/ModelTestModal.jsx
  32. 40 6
      web/src/components/table/users/UsersColumnDefs.jsx
  33. 55 1
      web/src/components/table/users/UsersTable.jsx
  34. 39 0
      web/src/components/table/users/modals/ResetPasskeyModal.jsx
  35. 39 0
      web/src/components/table/users/modals/ResetTwoFAModal.jsx
  36. 1 0
      web/src/helpers/index.js
  37. 137 0
      web/src/helpers/passkey.js
  38. 62 0
      web/src/helpers/secureApiCall.js
  39. 11 3
      web/src/hooks/channels/useChannelsData.jsx
  40. 246 0
      web/src/hooks/common/useSecureVerification.jsx
  41. 37 1
      web/src/hooks/users/useUsersData.jsx
  42. 59 0
      web/src/i18n/locales/en.json
  43. 59 1
      web/src/i18n/locales/zh.json
  44. 217 0
      web/src/services/secureVerification.js

+ 1 - 0
common/endpoint_defaults.go

@@ -23,6 +23,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
 	constant.EndpointTypeGemini:          {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
 	constant.EndpointTypeJinaRerank:      {Path: "/rerank", Method: "POST"},
 	constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
+	constant.EndpointTypeEmbeddings:      {Path: "/v1/embeddings", Method: "POST"},
 }
 
 // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在

+ 1 - 0
constant/endpoint_type.go

@@ -9,6 +9,7 @@ const (
 	EndpointTypeGemini          EndpointType = "gemini"
 	EndpointTypeJinaRerank      EndpointType = "jina-rerank"
 	EndpointTypeImageGeneration EndpointType = "image-generation"
+	EndpointTypeEmbeddings      EndpointType = "embeddings"
 	//EndpointTypeMidjourney     EndpointType = "midjourney-proxy"
 	//EndpointTypeSuno           EndpointType = "suno-proxy"
 	//EndpointTypeKling          EndpointType = "kling"

+ 195 - 82
controller/channel-test.go

@@ -38,7 +38,7 @@ type testResult struct {
 	newAPIError *types.NewAPIError
 }
 
-func testChannel(channel *model.Channel, testModel string) testResult {
+func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
 	tik := time.Now()
 	if channel.Type == constant.ChannelTypeMidjourney {
 		return testResult{
@@ -81,18 +81,26 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 
 	requestPath := "/v1/chat/completions"
 
-	// 先判断是否为 Embedding 模型
-	if strings.Contains(strings.ToLower(testModel), "embedding") ||
-		strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
-		strings.Contains(testModel, "bge-") || // bge 系列模型
-		strings.Contains(testModel, "embed") ||
-		channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
-		requestPath = "/v1/embeddings" // 修改请求路径
-	}
+	// 如果指定了端点类型,使用指定的端点类型
+	if endpointType != "" {
+		if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok {
+			requestPath = endpointInfo.Path
+		}
+	} else {
+		// 如果没有指定端点类型,使用原有的自动检测逻辑
+		// 先判断是否为 Embedding 模型
+		if strings.Contains(strings.ToLower(testModel), "embedding") ||
+			strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
+			strings.Contains(testModel, "bge-") || // bge 系列模型
+			strings.Contains(testModel, "embed") ||
+			channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
+			requestPath = "/v1/embeddings" // 修改请求路径
+		}
 
-	// VolcEngine 图像生成模型
-	if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
-		requestPath = "/v1/images/generations"
+		// VolcEngine 图像生成模型
+		if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
+			requestPath = "/v1/images/generations"
+		}
 	}
 
 	c.Request = &http.Request{
@@ -114,21 +122,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		}
 	}
 
-	// 重新检查模型类型并更新请求路径
-	if strings.Contains(strings.ToLower(testModel), "embedding") ||
-		strings.HasPrefix(testModel, "m3e") ||
-		strings.Contains(testModel, "bge-") ||
-		strings.Contains(testModel, "embed") ||
-		channel.Type == constant.ChannelTypeMokaAI {
-		requestPath = "/v1/embeddings"
-		c.Request.URL.Path = requestPath
-	}
-
-	if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
-		requestPath = "/v1/images/generations"
-		c.Request.URL.Path = requestPath
-	}
-
 	cache, err := model.GetUserCache(1)
 	if err != nil {
 		return testResult{
@@ -153,17 +146,54 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 			newAPIError: newAPIError,
 		}
 	}
-	request := buildTestRequest(testModel)
 
-	// Determine relay format based on request path
-	relayFormat := types.RelayFormatOpenAI
-	if c.Request.URL.Path == "/v1/embeddings" {
-		relayFormat = types.RelayFormatEmbedding
-	}
-	if c.Request.URL.Path == "/v1/images/generations" {
-		relayFormat = types.RelayFormatOpenAIImage
+	// Determine relay format based on endpoint type or request path
+	var relayFormat types.RelayFormat
+	if endpointType != "" {
+		// 根据指定的端点类型设置 relayFormat
+		switch constant.EndpointType(endpointType) {
+		case constant.EndpointTypeOpenAI:
+			relayFormat = types.RelayFormatOpenAI
+		case constant.EndpointTypeOpenAIResponse:
+			relayFormat = types.RelayFormatOpenAIResponses
+		case constant.EndpointTypeAnthropic:
+			relayFormat = types.RelayFormatClaude
+		case constant.EndpointTypeGemini:
+			relayFormat = types.RelayFormatGemini
+		case constant.EndpointTypeJinaRerank:
+			relayFormat = types.RelayFormatRerank
+		case constant.EndpointTypeImageGeneration:
+			relayFormat = types.RelayFormatOpenAIImage
+		case constant.EndpointTypeEmbeddings:
+			relayFormat = types.RelayFormatEmbedding
+		default:
+			relayFormat = types.RelayFormatOpenAI
+		}
+	} else {
+		// 根据请求路径自动检测
+		relayFormat = types.RelayFormatOpenAI
+		if c.Request.URL.Path == "/v1/embeddings" {
+			relayFormat = types.RelayFormatEmbedding
+		}
+		if c.Request.URL.Path == "/v1/images/generations" {
+			relayFormat = types.RelayFormatOpenAIImage
+		}
+		if c.Request.URL.Path == "/v1/messages" {
+			relayFormat = types.RelayFormatClaude
+		}
+		if strings.Contains(c.Request.URL.Path, "/v1beta/models") {
+			relayFormat = types.RelayFormatGemini
+		}
+		if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" {
+			relayFormat = types.RelayFormatRerank
+		}
+		if c.Request.URL.Path == "/v1/responses" {
+			relayFormat = types.RelayFormatOpenAIResponses
+		}
 	}
 
+	request := buildTestRequest(testModel, endpointType)
+
 	info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
 
 	if err != nil {
@@ -186,7 +216,8 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 	}
 
 	testModel = info.UpstreamModelName
-	request.Model = testModel
+	// 更新请求中的模型名称
+	request.SetModelName(testModel)
 
 	apiType, _ := common.ChannelType2APIType(channel.Type)
 	adaptor := relay.GetAdaptor(apiType)
@@ -216,33 +247,62 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 
 	var convertedRequest any
 	// 根据 RelayMode 选择正确的转换函数
-	if info.RelayMode == relayconstant.RelayModeEmbeddings {
-		// 创建一个 EmbeddingRequest
-		embeddingRequest := dto.EmbeddingRequest{
-			Input: request.Input,
-			Model: request.Model,
-		}
-		// 调用专门用于 Embedding 的转换函数
-		convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
-	} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
-		// 创建一个 ImageRequest
-		prompt := "cat"
-		if request.Prompt != nil {
-			if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
-				prompt = promptStr
+	switch info.RelayMode {
+	case relayconstant.RelayModeEmbeddings:
+		// Embedding 请求 - request 已经是正确的类型
+		if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok {
+			convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq)
+		} else {
+			return testResult{
+				context:     c,
+				localErr:    errors.New("invalid embedding request type"),
+				newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
 			}
 		}
-		imageRequest := dto.ImageRequest{
-			Prompt: prompt,
-			Model:  request.Model,
-			N:      uint(request.N),
-			Size:   request.Size,
+	case relayconstant.RelayModeImagesGenerations:
+		// 图像生成请求 - request 已经是正确的类型
+		if imageReq, ok := request.(*dto.ImageRequest); ok {
+			convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
+		} else {
+			return testResult{
+				context:     c,
+				localErr:    errors.New("invalid image request type"),
+				newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed),
+			}
+		}
+	case relayconstant.RelayModeRerank:
+		// Rerank 请求 - request 已经是正确的类型
+		if rerankReq, ok := request.(*dto.RerankRequest); ok {
+			convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq)
+		} else {
+			return testResult{
+				context:     c,
+				localErr:    errors.New("invalid rerank request type"),
+				newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed),
+			}
+		}
+	case relayconstant.RelayModeResponses:
+		// Response 请求 - request 已经是正确的类型
+		if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok {
+			convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq)
+		} else {
+			return testResult{
+				context:     c,
+				localErr:    errors.New("invalid response request type"),
+				newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed),
+			}
+		}
+	default:
+		// Chat/Completion 等其他请求类型
+		if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok {
+			convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq)
+		} else {
+			return testResult{
+				context:     c,
+				localErr:    errors.New("invalid general request type"),
+				newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed),
+			}
 		}
-		// 调用专门用于图像生成的转换函数
-		convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
-	} else {
-		// 对其他所有请求类型(如 Chat),保持原有逻辑
-		convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
 	}
 
 	if err != nil {
@@ -345,22 +405,82 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 	}
 }
 
-func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
-	testRequest := &dto.GeneralOpenAIRequest{
-		Model:  "", // this will be set later
-		Stream: false,
+func buildTestRequest(model string, endpointType string) dto.Request {
+	// 根据端点类型构建不同的测试请求
+	if endpointType != "" {
+		switch constant.EndpointType(endpointType) {
+		case constant.EndpointTypeEmbeddings:
+			// 返回 EmbeddingRequest
+			return &dto.EmbeddingRequest{
+				Model: model,
+				Input: []any{"hello world"},
+			}
+		case constant.EndpointTypeImageGeneration:
+			// 返回 ImageRequest
+			return &dto.ImageRequest{
+				Model:  model,
+				Prompt: "a cute cat",
+				N:      1,
+				Size:   "1024x1024",
+			}
+		case constant.EndpointTypeJinaRerank:
+			// 返回 RerankRequest
+			return &dto.RerankRequest{
+				Model:     model,
+				Query:     "What is Deep Learning?",
+				Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
+				TopN:      2,
+			}
+		case constant.EndpointTypeOpenAIResponse:
+			// 返回 OpenAIResponsesRequest
+			return &dto.OpenAIResponsesRequest{
+				Model: model,
+				Input: json.RawMessage("\"hi\""),
+			}
+		case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
+			// 返回 GeneralOpenAIRequest
+			maxTokens := uint(10)
+			if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
+				maxTokens = 3000
+			}
+			return &dto.GeneralOpenAIRequest{
+				Model:  model,
+				Stream: false,
+				Messages: []dto.Message{
+					{
+						Role:    "user",
+						Content: "hi",
+					},
+				},
+				MaxTokens: maxTokens,
+			}
+		}
 	}
 
+	// 自动检测逻辑(保持原有行为)
 	// 先判断是否为 Embedding 模型
-	if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型
-		strings.HasPrefix(model, "m3e") || // m3e 系列模型
+	if strings.Contains(strings.ToLower(model), "embedding") ||
+		strings.HasPrefix(model, "m3e") ||
 		strings.Contains(model, "bge-") {
-		testRequest.Model = model
-		// Embedding 请求
-		testRequest.Input = []any{"hello world"} // 修改为any,因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
-		return testRequest
+		// 返回 EmbeddingRequest
+		return &dto.EmbeddingRequest{
+			Model: model,
+			Input: []any{"hello world"},
+		}
 	}
-	// 并非Embedding 模型
+
+	// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
+	testRequest := &dto.GeneralOpenAIRequest{
+		Model:  model,
+		Stream: false,
+		Messages: []dto.Message{
+			{
+				Role:    "user",
+				Content: "hi",
+			},
+		},
+	}
+
 	if strings.HasPrefix(model, "o") {
 		testRequest.MaxCompletionTokens = 10
 	} else if strings.Contains(model, "thinking") {
@@ -373,12 +493,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
 		testRequest.MaxTokens = 10
 	}
 
-	testMessage := dto.Message{
-		Role:    "user",
-		Content: "hi",
-	}
-	testRequest.Model = model
-	testRequest.Messages = append(testRequest.Messages, testMessage)
 	return testRequest
 }
 
@@ -402,8 +516,9 @@ func TestChannel(c *gin.Context) {
 	//	}
 	//}()
 	testModel := c.Query("model")
+	endpointType := c.Query("endpoint_type")
 	tik := time.Now()
-	result := testChannel(channel, testModel)
+	result := testChannel(channel, testModel, endpointType)
 	if result.localErr != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
@@ -429,7 +544,6 @@ func TestChannel(c *gin.Context) {
 		"message": "",
 		"time":    consumedTime,
 	})
-	return
 }
 
 var testAllChannelsLock sync.Mutex
@@ -463,7 +577,7 @@ func testAllChannels(notify bool) error {
 		for _, channel := range channels {
 			isChannelEnabled := channel.Status == common.ChannelStatusEnabled
 			tik := time.Now()
-			result := testChannel(channel, "")
+			result := testChannel(channel, "", "")
 			tok := time.Now()
 			milliseconds := tok.Sub(tik).Milliseconds()
 
@@ -477,7 +591,7 @@ func testAllChannels(notify bool) error {
 			// 当错误检查通过,才检查响应时间
 			if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
 				if milliseconds > disableThreshold {
-					err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
+					err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
 					newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
 					shouldBanChannel = true
 				}
@@ -514,7 +628,6 @@ func TestAllChannels(c *gin.Context) {
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 var autoTestChannelsOnce sync.Once

+ 4 - 31
controller/channel.go

@@ -384,18 +384,9 @@ func GetChannel(c *gin.Context) {
 	return
 }
 
-// GetChannelKey 验证2FA后获取渠道密钥
+// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
+// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
 func GetChannelKey(c *gin.Context) {
-	type GetChannelKeyRequest struct {
-		Code string `json:"code" binding:"required"`
-	}
-
-	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 {
@@ -403,24 +394,6 @@ func GetChannelKey(c *gin.Context) {
 		return
 	}
 
-	// 获取2FA记录并验证
-	twoFA, err := model.GetTwoFAByUserId(userId)
-	if err != nil {
-		common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
-		return
-	}
-
-	if twoFA == nil || !twoFA.IsEnabled {
-		common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥"))
-		return
-	}
-
-	// 统一的2FA验证逻辑
-	if !validateTwoFactorAuth(twoFA, req.Code) {
-		common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
-		return
-	}
-
 	// 获取渠道信息(包含密钥)
 	channel, err := model.GetChannelById(channelId, true)
 	if err != nil {
@@ -436,10 +409,10 @@ func GetChannelKey(c *gin.Context) {
 	// 记录操作日志
 	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
 
-	// 统一的成功响应格式
+	// 返回渠道密钥
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
-		"message": "验证成功",
+		"message": "获取成功",
 		"data": map[string]interface{}{
 			"key": channel.Key,
 		},

+ 9 - 0
controller/misc.go

@@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) {
 	common.OptionMapRWMutex.RLock()
 	defer common.OptionMapRWMutex.RUnlock()
 
+	passkeySetting := system_setting.GetPasskeySettings()
+
 	data := gin.H{
 		"version":                     common.Version,
 		"start_time":                  common.StartTime,
@@ -94,6 +96,13 @@ func GetStatus(c *gin.Context) {
 		"oidc_enabled":                system_setting.GetOIDCSettings().Enabled,
 		"oidc_client_id":              system_setting.GetOIDCSettings().ClientId,
 		"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
+		"passkey_login":               passkeySetting.Enabled,
+		"passkey_display_name":        passkeySetting.RPDisplayName,
+		"passkey_rp_id":               passkeySetting.RPID,
+		"passkey_origins":             passkeySetting.Origins,
+		"passkey_allow_insecure":      passkeySetting.AllowInsecureOrigin,
+		"passkey_user_verification":   passkeySetting.UserVerification,
+		"passkey_attachment":          passkeySetting.AttachmentPreference,
 		"setup":                       constant.Setup,
 	}
 

+ 497 - 0
controller/passkey.go

@@ -0,0 +1,497 @@
+package controller
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"one-api/common"
+	"one-api/model"
+	passkeysvc "one-api/service/passkey"
+	"one-api/setting/system_setting"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+	"github.com/go-webauthn/webauthn/protocol"
+	webauthnlib "github.com/go-webauthn/webauthn/webauthn"
+)
+
+func PasskeyRegisterBegin(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未启用 Passkey 登录",
+		})
+		return
+	}
+
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	credential, err := model.GetPasskeyByUserID(user.Id)
+	if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
+		common.ApiError(c, err)
+		return
+	}
+	if errors.Is(err, model.ErrPasskeyNotFound) {
+		credential = nil
+	}
+
+	wa, err := passkeysvc.BuildWebAuthn(c.Request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	waUser := passkeysvc.NewWebAuthnUser(user, credential)
+	var options []webauthnlib.RegistrationOption
+	if credential != nil {
+		descriptor := credential.ToWebAuthnCredential().Descriptor()
+		options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
+	}
+
+	creation, sessionData, err := wa.BeginRegistration(waUser, options...)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"options": creation,
+		},
+	})
+}
+
+func PasskeyRegisterFinish(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未启用 Passkey 登录",
+		})
+		return
+	}
+
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	wa, err := passkeysvc.BuildWebAuthn(c.Request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	credentialRecord, err := model.GetPasskeyByUserID(user.Id)
+	if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
+		common.ApiError(c, err)
+		return
+	}
+	if errors.Is(err, model.ErrPasskeyNotFound) {
+		credentialRecord = nil
+	}
+
+	sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
+	credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
+	if passkeyCredential == nil {
+		common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
+		return
+	}
+
+	if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Passkey 注册成功",
+	})
+}
+
+func PasskeyDelete(c *gin.Context) {
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	if err := model.DeletePasskeyByUserID(user.Id); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Passkey 已解绑",
+	})
+}
+
+func PasskeyStatus(c *gin.Context) {
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	credential, err := model.GetPasskeyByUserID(user.Id)
+	if errors.Is(err, model.ErrPasskeyNotFound) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+			"data": gin.H{
+				"enabled": false,
+			},
+		})
+		return
+	}
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"enabled":      true,
+		"last_used_at": credential.LastUsedAt,
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    data,
+	})
+}
+
+func PasskeyLoginBegin(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		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
+	}
+
+	assertion, sessionData, err := wa.BeginDiscoverableLogin()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"options": assertion,
+		},
+	})
+}
+
+func PasskeyLoginFinish(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		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
+	}
+
+	sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
+		// 首先通过凭证ID查找用户
+		credential, err := model.GetPasskeyByCredentialID(rawID)
+		if err != nil {
+			return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
+		}
+
+		// 通过凭证获取用户
+		user := &model.User{Id: credential.UserID}
+		if err := user.FillUserById(); err != nil {
+			return nil, fmt.Errorf("用户信息获取失败: %w", err)
+		}
+
+		if user.Status != common.UserStatusEnabled {
+			return nil, errors.New("该用户已被禁用")
+		}
+
+		if len(userHandle) > 0 {
+			userID, parseErr := strconv.Atoi(string(userHandle))
+			if parseErr != nil {
+				// 记录异常但继续验证,因为某些客户端可能使用非数字格式
+				common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
+			} else if userID != user.Id {
+				return nil, errors.New("用户句柄与凭证不匹配")
+			}
+		}
+
+		return passkeysvc.NewWebAuthnUser(user, credential), nil
+	}
+
+	waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
+	if !ok {
+		common.ApiErrorMsg(c, "Passkey 登录状态异常")
+		return
+	}
+
+	modelUser := userWrapper.ModelUser()
+	if modelUser == nil {
+		common.ApiErrorMsg(c, "Passkey 登录状态异常")
+		return
+	}
+
+	if modelUser.Status != common.UserStatusEnabled {
+		common.ApiErrorMsg(c, "该用户已被禁用")
+		return
+	}
+
+	// 更新凭证信息
+	updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
+	if updatedCredential == nil {
+		common.ApiErrorMsg(c, "Passkey 凭证更新失败")
+		return
+	}
+	now := time.Now()
+	updatedCredential.LastUsedAt = &now
+	if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	setupLogin(modelUser, c)
+	return
+}
+
+func AdminResetPasskey(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiErrorMsg(c, "无效的用户 ID")
+		return
+	}
+
+	user := &model.User{Id: id}
+	if err := user.FillUserById(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
+		if errors.Is(err, model.ErrPasskeyNotFound) {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "该用户尚未绑定 Passkey",
+			})
+			return
+		}
+		common.ApiError(c, err)
+		return
+	}
+
+	if err := model.DeletePasskeyByUserID(user.Id); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Passkey 已重置",
+	})
+}
+
+func PasskeyVerifyBegin(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未启用 Passkey 登录",
+		})
+		return
+	}
+
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	credential, err := model.GetPasskeyByUserID(user.Id)
+	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)
+	assertion, sessionData, err := wa.BeginLogin(waUser)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"options": assertion,
+		},
+	})
+}
+
+func PasskeyVerifyFinish(c *gin.Context) {
+	if !system_setting.GetPasskeySettings().Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未启用 Passkey 登录",
+		})
+		return
+	}
+
+	user, err := getSessionUser(c)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	wa, err := passkeysvc.BuildWebAuthn(c.Request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	credential, err := model.GetPasskeyByUserID(user.Id)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该用户尚未绑定 Passkey",
+		})
+		return
+	}
+
+	sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	waUser := passkeysvc.NewWebAuthnUser(user, credential)
+	_, 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
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Passkey 验证成功",
+	})
+}
+
+func getSessionUser(c *gin.Context) (*model.User, error) {
+	session := sessions.Default(c)
+	idRaw := session.Get("id")
+	if idRaw == nil {
+		return nil, errors.New("未登录")
+	}
+	id, ok := idRaw.(int)
+	if !ok {
+		return nil, errors.New("无效的会话信息")
+	}
+	user := &model.User{Id: id}
+	if err := user.FillUserById(); err != nil {
+		return nil, err
+	}
+	if user.Status != common.UserStatusEnabled {
+		return nil, errors.New("该用户已被禁用")
+	}
+	return user, nil
+}

+ 313 - 0
controller/secure_verification.go

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

+ 14 - 6
go.mod

@@ -1,7 +1,9 @@
 module one-api
 
 // +heroku goVersion go1.18
-go 1.23.4
+go 1.24.0
+
+toolchain go1.24.6
 
 require (
 	github.com/Calcium-Ion/go-epay v0.0.4
@@ -20,6 +22,7 @@ require (
 	github.com/glebarez/sqlite v1.9.0
 	github.com/go-playground/validator/v10 v10.20.0
 	github.com/go-redis/redis/v8 v8.11.5
+	github.com/go-webauthn/webauthn v0.14.0
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.0
@@ -35,10 +38,10 @@ require (
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/sjson v1.2.5
 	github.com/tiktoken-go/tokenizer v0.6.2
-	golang.org/x/crypto v0.35.0
+	golang.org/x/crypto v0.42.0
 	golang.org/x/image v0.23.0
-	golang.org/x/net v0.35.0
-	golang.org/x/sync v0.11.0
+	golang.org/x/net v0.43.0
+	golang.org/x/sync v0.17.0
 	gorm.io/driver/mysql v1.4.3
 	gorm.io/driver/postgres v1.5.2
 	gorm.io/gorm v1.25.2
@@ -58,6 +61,7 @@ require (
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect
 	github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -65,8 +69,11 @@ require (
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sql-driver/mysql v1.7.0 // indirect
+	github.com/go-webauthn/x v0.1.25 // indirect
 	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
 	github.com/google/go-cmp v0.6.0 // indirect
+	github.com/google/go-tpm v0.9.5 // indirect
 	github.com/gorilla/context v1.1.1 // indirect
 	github.com/gorilla/securecookie v1.1.1 // indirect
 	github.com/gorilla/sessions v1.2.1 // indirect
@@ -91,11 +98,12 @@ require (
 	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.2.12 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	golang.org/x/arch v0.12.0 // indirect
 	golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
-	golang.org/x/sys v0.30.0 // indirect
-	golang.org/x/text v0.22.0 // indirect
+	golang.org/x/sys v0.36.0 // indirect
+	golang.org/x/text v0.29.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.22.5 // indirect

+ 26 - 11
go.sum

@@ -47,6 +47,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
 github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
@@ -89,16 +91,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
 github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
+github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
+github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
+github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
 github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
+github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@@ -200,8 +210,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
 github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
 github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
@@ -229,27 +240,31 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
 github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
 github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
 golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
-golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
+golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
+golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
 golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
 golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
-golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -261,14 +276,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

+ 3 - 2
main.go

@@ -185,8 +185,9 @@ func InitResources() error {
 	// This is a placeholder function for future resource initialization
 	err := godotenv.Load(".env")
 	if err != nil {
-		common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量")
-		common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
+		if common.DebugEnabled {
+			common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
+		}
 	}
 
 	// 加载环境变量

+ 131 - 0
middleware/secure_verification.go

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

+ 2 - 0
model/main.go

@@ -251,6 +251,7 @@ func migrateDB() error {
 		&Channel{},
 		&Token{},
 		&User{},
+		&PasskeyCredential{},
 		&Option{},
 		&Redemption{},
 		&Ability{},
@@ -283,6 +284,7 @@ func migrateDBFast() error {
 		{&Channel{}, "Channel"},
 		{&Token{}, "Token"},
 		{&User{}, "User"},
+		{&PasskeyCredential{}, "PasskeyCredential"},
 		{&Option{}, "Option"},
 		{&Redemption{}, "Redemption"},
 		{&Ability{}, "Ability"},

+ 209 - 0
model/passkey.go

@@ -0,0 +1,209 @@
+package model
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"one-api/common"
+	"strings"
+	"time"
+
+	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/go-webauthn/webauthn/webauthn"
+	"gorm.io/gorm"
+)
+
+var (
+	ErrPasskeyNotFound         = errors.New("passkey credential not found")
+	ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员")
+)
+
+type PasskeyCredential struct {
+	ID              int            `json:"id" gorm:"primaryKey"`
+	UserID          int            `json:"user_id" gorm:"uniqueIndex;not null"`
+	CredentialID    string         `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
+	PublicKey       string         `json:"public_key" gorm:"type:text;not null"`                        // base64 encoded
+	AttestationType string         `json:"attestation_type" gorm:"type:varchar(255)"`
+	AAGUID          string         `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
+	SignCount       uint32         `json:"sign_count" gorm:"default:0"`
+	CloneWarning    bool           `json:"clone_warning"`
+	UserPresent     bool           `json:"user_present"`
+	UserVerified    bool           `json:"user_verified"`
+	BackupEligible  bool           `json:"backup_eligible"`
+	BackupState     bool           `json:"backup_state"`
+	Transports      string         `json:"transports" gorm:"type:text"`
+	Attachment      string         `json:"attachment" gorm:"type:varchar(32)"`
+	LastUsedAt      *time.Time     `json:"last_used_at"`
+	CreatedAt       time.Time      `json:"created_at"`
+	UpdatedAt       time.Time      `json:"updated_at"`
+	DeletedAt       gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
+	if p == nil || strings.TrimSpace(p.Transports) == "" {
+		return nil
+	}
+	var transports []string
+	if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
+		return nil
+	}
+	result := make([]protocol.AuthenticatorTransport, 0, len(transports))
+	for _, transport := range transports {
+		result = append(result, protocol.AuthenticatorTransport(transport))
+	}
+	return result
+}
+
+func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) {
+	if len(list) == 0 {
+		p.Transports = ""
+		return
+	}
+	stringList := make([]string, len(list))
+	for i, transport := range list {
+		stringList[i] = string(transport)
+	}
+	encoded, err := json.Marshal(stringList)
+	if err != nil {
+		return
+	}
+	p.Transports = string(encoded)
+}
+
+func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential {
+	flags := webauthn.CredentialFlags{
+		UserPresent:    p.UserPresent,
+		UserVerified:   p.UserVerified,
+		BackupEligible: p.BackupEligible,
+		BackupState:    p.BackupState,
+	}
+
+	credID, _ := base64.StdEncoding.DecodeString(p.CredentialID)
+	pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey)
+	aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID)
+
+	return webauthn.Credential{
+		ID:              credID,
+		PublicKey:       pubKey,
+		AttestationType: p.AttestationType,
+		Transport:       p.TransportList(),
+		Flags:           flags,
+		Authenticator: webauthn.Authenticator{
+			AAGUID:       aaguid,
+			SignCount:    p.SignCount,
+			CloneWarning: p.CloneWarning,
+			Attachment:   protocol.AuthenticatorAttachment(p.Attachment),
+		},
+	}
+}
+
+func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential {
+	if credential == nil {
+		return nil
+	}
+	passkey := &PasskeyCredential{
+		UserID:          userID,
+		CredentialID:    base64.StdEncoding.EncodeToString(credential.ID),
+		PublicKey:       base64.StdEncoding.EncodeToString(credential.PublicKey),
+		AttestationType: credential.AttestationType,
+		AAGUID:          base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
+		SignCount:       credential.Authenticator.SignCount,
+		CloneWarning:    credential.Authenticator.CloneWarning,
+		UserPresent:     credential.Flags.UserPresent,
+		UserVerified:    credential.Flags.UserVerified,
+		BackupEligible:  credential.Flags.BackupEligible,
+		BackupState:     credential.Flags.BackupState,
+		Attachment:      string(credential.Authenticator.Attachment),
+	}
+	passkey.SetTransports(credential.Transport)
+	return passkey
+}
+
+func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) {
+	if credential == nil || p == nil {
+		return
+	}
+	p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID)
+	p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey)
+	p.AttestationType = credential.AttestationType
+	p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID)
+	p.SignCount = credential.Authenticator.SignCount
+	p.CloneWarning = credential.Authenticator.CloneWarning
+	p.UserPresent = credential.Flags.UserPresent
+	p.UserVerified = credential.Flags.UserVerified
+	p.BackupEligible = credential.Flags.BackupEligible
+	p.BackupState = credential.Flags.BackupState
+	p.Attachment = string(credential.Authenticator.Attachment)
+	p.SetTransports(credential.Transport)
+}
+
+func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
+	if userID == 0 {
+		common.SysLog("GetPasskeyByUserID: empty user ID")
+		return nil, ErrFriendlyPasskeyNotFound
+	}
+	var credential PasskeyCredential
+	if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			// 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志
+			return nil, ErrPasskeyNotFound
+		}
+		// 只有真正的数据库错误才记录日志
+		common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err))
+		return nil, ErrFriendlyPasskeyNotFound
+	}
+	return &credential, nil
+}
+
+func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
+	if len(credentialID) == 0 {
+		common.SysLog("GetPasskeyByCredentialID: empty credential ID")
+		return nil, ErrFriendlyPasskeyNotFound
+	}
+
+	credIDStr := base64.StdEncoding.EncodeToString(credentialID)
+	var credential PasskeyCredential
+	if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID)))
+			return nil, ErrFriendlyPasskeyNotFound
+		}
+		common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err))
+		return nil, ErrFriendlyPasskeyNotFound
+	}
+
+	return &credential, nil
+}
+
+func UpsertPasskeyCredential(credential *PasskeyCredential) error {
+	if credential == nil {
+		common.SysLog("UpsertPasskeyCredential: nil credential provided")
+		return fmt.Errorf("Passkey 保存失败,请重试")
+	}
+	return DB.Transaction(func(tx *gorm.DB) error {
+		// 使用Unscoped()进行硬删除,避免唯一索引冲突
+		if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
+			common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
+			return fmt.Errorf("Passkey 保存失败,请重试")
+		}
+		if err := tx.Create(credential).Error; err != nil {
+			common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
+			return fmt.Errorf("Passkey 保存失败,请重试")
+		}
+		return nil
+	})
+}
+
+func DeletePasskeyByUserID(userID int) error {
+	if userID == 0 {
+		common.SysLog("DeletePasskeyByUserID: empty user ID")
+		return fmt.Errorf("删除失败,请重试")
+	}
+	// 使用Unscoped()进行硬删除,避免唯一索引冲突
+	if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil {
+		common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err))
+		return fmt.Errorf("删除失败,请重试")
+	}
+	return nil
+}

+ 1 - 0
relay/channel/jina/adaptor.go

@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
 }
 
 func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
+	request.EncodingFormat = ""
 	return request, nil
 }
 

+ 5 - 1
relay/channel/openai/relay_responses.go

@@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
 				if streamResponse.Item != nil {
 					switch streamResponse.Item.Type {
 					case dto.BuildInCallWebSearchCall:
-						info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
+						if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
+							if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
+								webSearchTool.CallCount++
+							}
+						}
 					}
 				}
 			}

+ 18 - 10
relay/channel/volcengine/adaptor.go

@@ -195,21 +195,29 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 		baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
 	}
 
-	switch info.RelayMode {
-	case constant.RelayModeChatCompletions:
+	switch info.RelayFormat {
+	case types.RelayFormatClaude:
 		if strings.HasPrefix(info.UpstreamModelName, "bot") {
 			return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
 		}
 		return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
-	case constant.RelayModeEmbeddings:
-		return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
-	case constant.RelayModeImagesGenerations:
-		return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
-	case constant.RelayModeImagesEdits:
-		return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
-	case constant.RelayModeRerank:
-		return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
 	default:
+		switch info.RelayMode {
+		case constant.RelayModeChatCompletions:
+			if strings.HasPrefix(info.UpstreamModelName, "bot") {
+				return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
+			}
+			return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
+		case constant.RelayModeEmbeddings:
+			return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
+		case constant.RelayModeImagesGenerations:
+			return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
+		case constant.RelayModeImagesEdits:
+			return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
+		case constant.RelayModeRerank:
+			return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
+		default:
+		}
 	}
 	return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
 }

+ 14 - 1
router/api-router.go

@@ -40,11 +40,17 @@ 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)
 			userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
 			userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
+			userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
+			userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
 			//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
 			userRoute.GET("/logout", controller.Logout)
 			userRoute.GET("/epay/notify", controller.EpayNotify)
@@ -59,6 +65,12 @@ func SetApiRouter(router *gin.Engine) {
 				selfRoute.PUT("/self", controller.UpdateSelf)
 				selfRoute.DELETE("/self", controller.DeleteSelf)
 				selfRoute.GET("/token", controller.GenerateAccessToken)
+				selfRoute.GET("/passkey", controller.PasskeyStatus)
+				selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin)
+				selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish)
+				selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin)
+				selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish)
+				selfRoute.DELETE("/passkey", controller.PasskeyDelete)
 				selfRoute.GET("/aff", controller.GetAffCode)
 				selfRoute.GET("/topup/info", controller.GetTopUpInfo)
 				selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
@@ -87,6 +99,7 @@ func SetApiRouter(router *gin.Engine) {
 				adminRoute.POST("/manage", controller.ManageUser)
 				adminRoute.PUT("/", controller.UpdateUser)
 				adminRoute.DELETE("/:id", controller.DeleteUser)
+				adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey)
 
 				// Admin 2FA routes
 				adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
@@ -115,7 +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", 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)

+ 177 - 0
service/passkey/service.go

@@ -0,0 +1,177 @@
+package passkey
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"one-api/common"
+	"one-api/setting/system_setting"
+
+	"github.com/go-webauthn/webauthn/protocol"
+	webauthn "github.com/go-webauthn/webauthn/webauthn"
+)
+
+const (
+	RegistrationSessionKey = "passkey_registration_session"
+	LoginSessionKey        = "passkey_login_session"
+	VerifySessionKey       = "passkey_verify_session"
+)
+
+// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
+func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
+	settings := system_setting.GetPasskeySettings()
+	if settings == nil {
+		return nil, errors.New("未找到 Passkey 设置")
+	}
+
+	displayName := strings.TrimSpace(settings.RPDisplayName)
+	if displayName == "" {
+		displayName = common.SystemName
+	}
+
+	origins, err := resolveOrigins(r, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	rpID, err := resolveRPID(r, settings, origins)
+	if err != nil {
+		return nil, err
+	}
+
+	selection := protocol.AuthenticatorSelection{
+		ResidentKey:        protocol.ResidentKeyRequirementRequired,
+		RequireResidentKey: protocol.ResidentKeyRequired(),
+		UserVerification:   protocol.UserVerificationRequirement(settings.UserVerification),
+	}
+	if selection.UserVerification == "" {
+		selection.UserVerification = protocol.VerificationPreferred
+	}
+	if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" {
+		selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment)
+	}
+
+	config := &webauthn.Config{
+		RPID:                   rpID,
+		RPDisplayName:          displayName,
+		RPOrigins:              origins,
+		AuthenticatorSelection: selection,
+		Debug:                  common.DebugEnabled,
+		Timeouts: webauthn.TimeoutsConfig{
+			Login: webauthn.TimeoutConfig{
+				Enforce:    true,
+				Timeout:    2 * time.Minute,
+				TimeoutUVD: 2 * time.Minute,
+			},
+			Registration: webauthn.TimeoutConfig{
+				Enforce:    true,
+				Timeout:    2 * time.Minute,
+				TimeoutUVD: 2 * time.Minute,
+			},
+		},
+	}
+
+	return webauthn.New(config)
+}
+
+func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
+	originsStr := strings.TrimSpace(settings.Origins)
+	if originsStr != "" {
+		originList := strings.Split(originsStr, ",")
+		origins := make([]string, 0, len(originList))
+		for _, origin := range originList {
+			trimmed := strings.TrimSpace(origin)
+			if trimmed == "" {
+				continue
+			}
+			if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") {
+				return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed)
+			}
+			origins = append(origins, trimmed)
+		}
+		if len(origins) == 0 {
+			// 如果配置了Origins但过滤后为空,使用自动推导
+			goto autoDetect
+		}
+		return origins, nil
+	}
+
+autoDetect:
+	scheme := detectScheme(r)
+	if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") {
+		return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host)
+	}
+	// 优先使用请求的完整Host(包含端口)
+	host := r.Host
+
+	// 如果无法从请求获取Host,尝试从ServerAddress获取
+	if host == "" && system_setting.ServerAddress != "" {
+		if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" {
+			host = parsed.Host
+			if scheme == "" && parsed.Scheme != "" {
+				scheme = parsed.Scheme
+			}
+		}
+	}
+	if host == "" {
+		return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress)
+	}
+	if scheme == "" {
+		scheme = "https"
+	}
+	origin := fmt.Sprintf("%s://%s", scheme, host)
+	return []string{origin}, nil
+}
+
+func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) {
+	rpID := strings.TrimSpace(settings.RPID)
+	if rpID != "" {
+		return hostWithoutPort(rpID), nil
+	}
+	if len(origins) == 0 {
+		return "", errors.New("Passkey 未配置 Origin,无法推导 RPID")
+	}
+	parsed, err := url.Parse(origins[0])
+	if err != nil {
+		return "", fmt.Errorf("无法解析 Passkey Origin: %w", err)
+	}
+	return hostWithoutPort(parsed.Host), nil
+}
+
+func hostWithoutPort(host string) string {
+	host = strings.TrimSpace(host)
+	if host == "" {
+		return ""
+	}
+	if strings.Contains(host, ":") {
+		if host, _, err := net.SplitHostPort(host); err == nil {
+			return host
+		}
+	}
+	return host
+}
+
+func detectScheme(r *http.Request) string {
+	if r == nil {
+		return ""
+	}
+	if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
+		parts := strings.Split(proto, ",")
+		return strings.ToLower(strings.TrimSpace(parts[0]))
+	}
+	if r.TLS != nil {
+		return "https"
+	}
+	if r.URL != nil && r.URL.Scheme != "" {
+		return strings.ToLower(r.URL.Scheme)
+	}
+	if r.Header.Get("X-Forwarded-Protocol") != "" {
+		return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol")))
+	}
+	return "http"
+}

+ 50 - 0
service/passkey/session.go

@@ -0,0 +1,50 @@
+package passkey
+
+import (
+	"encoding/json"
+	"errors"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+	webauthn "github.com/go-webauthn/webauthn/webauthn"
+)
+
+var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
+
+func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error {
+	session := sessions.Default(c)
+	if data == nil {
+		session.Delete(key)
+		return session.Save()
+	}
+	payload, err := json.Marshal(data)
+	if err != nil {
+		return err
+	}
+	session.Set(key, string(payload))
+	return session.Save()
+}
+
+func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) {
+	session := sessions.Default(c)
+	raw := session.Get(key)
+	if raw == nil {
+		return nil, errSessionNotFound
+	}
+	session.Delete(key)
+	_ = session.Save()
+	var data webauthn.SessionData
+	switch value := raw.(type) {
+	case string:
+		if err := json.Unmarshal([]byte(value), &data); err != nil {
+			return nil, err
+		}
+	case []byte:
+		if err := json.Unmarshal(value, &data); err != nil {
+			return nil, err
+		}
+	default:
+		return nil, errors.New("Passkey 会话格式无效")
+	}
+	return &data, nil
+}

+ 71 - 0
service/passkey/user.go

@@ -0,0 +1,71 @@
+package passkey
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"one-api/model"
+
+	webauthn "github.com/go-webauthn/webauthn/webauthn"
+)
+
+type WebAuthnUser struct {
+	user       *model.User
+	credential *model.PasskeyCredential
+}
+
+func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser {
+	return &WebAuthnUser{user: user, credential: credential}
+}
+
+func (u *WebAuthnUser) WebAuthnID() []byte {
+	if u == nil || u.user == nil {
+		return nil
+	}
+	return []byte(strconv.Itoa(u.user.Id))
+}
+
+func (u *WebAuthnUser) WebAuthnName() string {
+	if u == nil || u.user == nil {
+		return ""
+	}
+	name := strings.TrimSpace(u.user.Username)
+	if name == "" {
+		return fmt.Sprintf("user-%d", u.user.Id)
+	}
+	return name
+}
+
+func (u *WebAuthnUser) WebAuthnDisplayName() string {
+	if u == nil || u.user == nil {
+		return ""
+	}
+	display := strings.TrimSpace(u.user.DisplayName)
+	if display != "" {
+		return display
+	}
+	return u.WebAuthnName()
+}
+
+func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
+	if u == nil || u.credential == nil {
+		return nil
+	}
+	cred := u.credential.ToWebAuthnCredential()
+	return []webauthn.Credential{cred}
+}
+
+func (u *WebAuthnUser) ModelUser() *model.User {
+	if u == nil {
+		return nil
+	}
+	return u.user
+}
+
+func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential {
+	if u == nil {
+		return nil
+	}
+	return u.credential
+}

+ 3 - 0
setting/operation_setting/tools.go

@@ -29,6 +29,7 @@ const (
 	Gemini25FlashLitePreviewInputAudioPrice = 0.50
 	Gemini25FlashNativeAudioInputAudioPrice = 3.00
 	Gemini20FlashInputAudioPrice            = 0.70
+	GeminiRoboticsER15InputAudioPrice       = 1.00
 )
 
 const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
 		return Gemini25FlashProductionInputAudioPrice
 	} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
 		return Gemini20FlashInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
+		return GeminiRoboticsER15InputAudioPrice
 	}
 	return 0
 }

+ 14 - 11
setting/ratio_setting/model_ratio.go

@@ -179,6 +179,7 @@ var defaultModelRatio = map[string]float64{
 	"gemini-2.5-flash-lite-preview-thinking-*":  0.05,
 	"gemini-2.5-flash-lite-preview-06-17":       0.05,
 	"gemini-2.5-flash":                          0.15,
+	"gemini-robotics-er-1.5-preview":            0.15,
 	"gemini-embedding-001":                      0.075,
 	"text-embedding-004":                        0.001,
 	"chatglm_turbo":                             0.3572,     // ¥0.005 / 1k tokens
@@ -252,17 +253,17 @@ var defaultModelRatio = map[string]float64{
 	"grok-vision-beta":      2.5,
 	"grok-3-fast-beta":      2.5,
 	"grok-3-mini-fast-beta": 0.3,
-    // submodel
-	"NousResearch/Hermes-4-405B-FP8":               0.8,
-	"Qwen/Qwen3-235B-A22B-Thinking-2507":           0.6,
-	"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":      0.8,
-	"Qwen/Qwen3-235B-A22B-Instruct-2507":           0.3,
-	"zai-org/GLM-4.5-FP8":                          0.8,
-	"openai/gpt-oss-120b":                          0.5,
-	"deepseek-ai/DeepSeek-R1-0528":                 0.8,
-	"deepseek-ai/DeepSeek-R1":                      0.8,
-	"deepseek-ai/DeepSeek-V3-0324":                 0.8,
-	"deepseek-ai/DeepSeek-V3.1":                    0.8,
+	// submodel
+	"NousResearch/Hermes-4-405B-FP8":          0.8,
+	"Qwen/Qwen3-235B-A22B-Thinking-2507":      0.6,
+	"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
+	"Qwen/Qwen3-235B-A22B-Instruct-2507":      0.3,
+	"zai-org/GLM-4.5-FP8":                     0.8,
+	"openai/gpt-oss-120b":                     0.5,
+	"deepseek-ai/DeepSeek-R1-0528":            0.8,
+	"deepseek-ai/DeepSeek-R1":                 0.8,
+	"deepseek-ai/DeepSeek-V3-0324":            0.8,
+	"deepseek-ai/DeepSeek-V3.1":               0.8,
 }
 
 var defaultModelPrice = map[string]float64{
@@ -587,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
 				return 4, false
 			}
 			return 2.5 / 0.3, false
+		} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
+			return 2.5 / 0.3, false
 		}
 		return 4, false
 	}

+ 49 - 0
setting/system_setting/passkey.go

@@ -0,0 +1,49 @@
+package system_setting
+
+import (
+	"net/url"
+	"one-api/common"
+	"one-api/setting/config"
+	"strings"
+)
+
+type PasskeySettings struct {
+	Enabled              bool   `json:"enabled"`
+	RPDisplayName        string `json:"rp_display_name"`
+	RPID                 string `json:"rp_id"`
+	Origins              string `json:"origins"`
+	AllowInsecureOrigin  bool   `json:"allow_insecure_origin"`
+	UserVerification     string `json:"user_verification"`
+	AttachmentPreference string `json:"attachment_preference"`
+}
+
+var defaultPasskeySettings = PasskeySettings{
+	Enabled:              false,
+	RPDisplayName:        common.SystemName,
+	RPID:                 "",
+	Origins:              "",
+	AllowInsecureOrigin:  false,
+	UserVerification:     "preferred",
+	AttachmentPreference: "",
+}
+
+func init() {
+	config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
+}
+
+func GetPasskeySettings() *PasskeySettings {
+	if defaultPasskeySettings.RPID == "" && ServerAddress != "" {
+		// 从ServerAddress提取域名作为RPID
+		// ServerAddress可能是 "https://newapi.pro" 这种格式
+		serverAddr := strings.TrimSpace(ServerAddress)
+		if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
+			defaultPasskeySettings.RPID = parsed.Host
+		} else {
+			defaultPasskeySettings.RPID = serverAddr
+		}
+	}
+	if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" {
+		defaultPasskeySettings.Origins = ServerAddress
+	}
+	return &defaultPasskeySettings
+}

+ 86 - 1
web/src/components/auth/LoginForm.jsx

@@ -32,6 +32,9 @@ import {
   onGitHubOAuthClicked,
   onOIDCClicked,
   onLinuxDOOAuthClicked,
+  prepareCredentialRequestOptions,
+  buildAssertionResult,
+  isPasskeySupported,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
 import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
@@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
 
-import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
+import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
 import OIDCIcon from '../common/logo/OIDCIcon';
 import WeChatIcon from '../common/logo/WeChatIcon';
 import LinuxDoIcon from '../common/logo/LinuxDoIcon';
@@ -74,6 +77,8 @@ const LoginForm = () => {
     useState(false);
   const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
   const [showTwoFA, setShowTwoFA] = useState(false);
+  const [passkeySupported, setPasskeySupported] = useState(false);
+  const [passkeyLoading, setPasskeyLoading] = useState(false);
 
   const logo = getLogo();
   const systemName = getSystemName();
@@ -95,6 +100,12 @@ const LoginForm = () => {
     }
   }, [status]);
 
+  useEffect(() => {
+    isPasskeySupported()
+      .then(setPasskeySupported)
+      .catch(() => setPasskeySupported(false));
+  }, []);
+
   useEffect(() => {
     if (searchParams.get('expired')) {
       showError(t('未登录或登录已过期,请重新登录'));
@@ -266,6 +277,55 @@ const LoginForm = () => {
     setEmailLoginLoading(false);
   };
 
+  const handlePasskeyLogin = async () => {
+    if (!passkeySupported) {
+      showInfo('当前环境无法使用 Passkey 登录');
+      return;
+    }
+    if (!window.PublicKeyCredential) {
+      showInfo('当前浏览器不支持 Passkey');
+      return;
+    }
+
+    setPasskeyLoading(true);
+    try {
+      const beginRes = await API.post('/api/user/passkey/login/begin');
+      const { success, message, data } = beginRes.data;
+      if (!success) {
+        showError(message || '无法发起 Passkey 登录');
+        return;
+      }
+
+      const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
+      const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
+      const payload = buildAssertionResult(assertion);
+      if (!payload) {
+        showError('Passkey 验证失败,请重试');
+        return;
+      }
+
+      const finishRes = await API.post('/api/user/passkey/login/finish', payload);
+      const finish = finishRes.data;
+      if (finish.success) {
+        userDispatch({ type: 'login', payload: finish.data });
+        setUserData(finish.data);
+        updateAPI();
+        showSuccess('登录成功!');
+        navigate('/console');
+      } else {
+        showError(finish.message || 'Passkey 登录失败,请重试');
+      }
+    } catch (error) {
+      if (error?.name === 'AbortError') {
+        showInfo('已取消 Passkey 登录');
+      } else {
+        showError('Passkey 登录失败,请重试');
+      }
+    } finally {
+      setPasskeyLoading(false);
+    }
+  };
+
   // 包装的重置密码点击处理
   const handleResetPasswordClick = () => {
     setResetPasswordLoading(true);
@@ -385,6 +445,19 @@ const LoginForm = () => {
                   </div>
                 )}
 
+                {status.passkey_login && passkeySupported && (
+                  <Button
+                    theme='outline'
+                    className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
+                    type='tertiary'
+                    icon={<IconKey size='large' />}
+                    onClick={handlePasskeyLogin}
+                    loading={passkeyLoading}
+                  >
+                    <span className='ml-3'>{t('使用 Passkey 登录')}</span>
+                  </Button>
+                )}
+
                 <Divider margin='12px' align='center'>
                   {t('或')}
                 </Divider>
@@ -437,6 +510,18 @@ const LoginForm = () => {
               </Title>
             </div>
             <div className='px-2 py-8'>
+              {status.passkey_login && passkeySupported && (
+                <Button
+                  theme='outline'
+                  type='tertiary'
+                  className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
+                  icon={<IconKey size='large' />}
+                  onClick={handlePasskeyLogin}
+                  loading={passkeyLoading}
+                >
+                  <span className='ml-3'>{t('使用 Passkey 登录')}</span>
+                </Button>
+              )}
               <Form className='space-y-3'>
                 <Form.Input
                   field='username'

+ 117 - 0
web/src/components/common/examples/ChannelKeyViewExample.jsx

@@ -0,0 +1,117 @@
+/*
+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
+*/
+
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Modal } from '@douyinfe/semi-ui';
+import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
+import { createApiCalls } from '../../../services/secureVerification';
+import SecureVerificationModal from '../modals/SecureVerificationModal';
+import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
+
+/**
+ * 渠道密钥查看组件使用示例
+ * 展示如何使用通用安全验证系统
+ */
+const ChannelKeyViewExample = ({ channelId }) => {
+  const { t } = useTranslation();
+  const [keyData, setKeyData] = useState('');
+  const [showKeyModal, setShowKeyModal] = useState(false);
+
+  // 使用通用安全验证 Hook
+  const {
+    isModalVisible,
+    verificationMethods,
+    verificationState,
+    startVerification,
+    executeVerification,
+    cancelVerification,
+    setVerificationCode,
+    switchVerificationMethod,
+  } = useSecureVerification({
+    onSuccess: (result) => {
+      // 验证成功后处理结果
+      if (result.success && result.data?.key) {
+        setKeyData(result.data.key);
+        setShowKeyModal(true);
+      }
+    },
+    successMessage: t('密钥获取成功'),
+  });
+
+  // 开始查看密钥流程
+  const handleViewKey = async () => {
+    const apiCall = createApiCalls.viewChannelKey(channelId);
+    
+    await startVerification(apiCall, {
+      title: t('查看渠道密钥'),
+      description: t('为了保护账户安全,请验证您的身份。'),
+      preferredMethod: 'passkey', // 可以指定首选验证方式
+    });
+  };
+
+  return (
+    <>
+      {/* 查看密钥按钮 */}
+      <Button
+        type='primary'
+        theme='outline'
+        onClick={handleViewKey}
+      >
+        {t('查看密钥')}
+      </Button>
+
+      {/* 安全验证模态框 */}
+      <SecureVerificationModal
+        visible={isModalVisible}
+        verificationMethods={verificationMethods}
+        verificationState={verificationState}
+        onVerify={executeVerification}
+        onCancel={cancelVerification}
+        onCodeChange={setVerificationCode}
+        onMethodSwitch={switchVerificationMethod}
+        title={verificationState.title}
+        description={verificationState.description}
+      />
+
+      {/* 密钥显示模态框 */}
+      <Modal
+        title={t('渠道密钥信息')}
+        visible={showKeyModal}
+        onCancel={() => setShowKeyModal(false)}
+        footer={
+          <Button type='primary' onClick={() => setShowKeyModal(false)}>
+            {t('完成')}
+          </Button>
+        }
+        width={700}
+        style={{ maxWidth: '90vw' }}
+      >
+        <ChannelKeyDisplay
+          keyData={keyData}
+          showSuccessIcon={true}
+          successText={t('密钥获取成功')}
+          showWarning={true}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default ChannelKeyViewExample;

+ 285 - 0
web/src/components/common/modals/SecureVerificationModal.jsx

@@ -0,0 +1,285 @@
+/*
+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
+*/
+
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
+
+/**
+ * 通用安全验证模态框组件
+ * 配合 useSecureVerification Hook 使用
+ * @param {Object} props
+ * @param {boolean} props.visible - 是否显示模态框
+ * @param {Object} props.verificationMethods - 可用的验证方式
+ * @param {Object} props.verificationState - 当前验证状态
+ * @param {Function} props.onVerify - 验证回调
+ * @param {Function} props.onCancel - 取消回调
+ * @param {Function} props.onCodeChange - 验证码变化回调
+ * @param {Function} props.onMethodSwitch - 验证方式切换回调
+ * @param {string} props.title - 模态框标题
+ * @param {string} props.description - 验证描述文本
+ */
+const SecureVerificationModal = ({
+  visible,
+  verificationMethods,
+  verificationState,
+  onVerify,
+  onCancel,
+  onCodeChange,
+  onMethodSwitch,
+  title,
+  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();
+    }
+  };
+
+  // 如果用户没有启用任何验证方式
+  if (visible && !has2FA && !hasPasskey) {
+    return (
+      <Modal
+        title={title || t('安全验证')}
+        visible={visible}
+        onCancel={onCancel}
+        footer={
+          <Button onClick={onCancel}>{t('确定')}</Button>
+        }
+        width={500}
+        style={{ maxWidth: '90vw' }}
+      >
+        <div className='text-center py-6'>
+          <div className='mb-4'>
+            <svg
+              className='w-16 h-16 text-yellow-500 mx-auto mb-4'
+              fill='currentColor'
+              viewBox='0 0 20 20'
+            >
+              <path
+                fillRule='evenodd'
+                d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
+                clipRule='evenodd'
+              />
+            </svg>
+          </div>
+          <Typography.Title heading={4} className='mb-2'>
+            {t('需要安全验证')}
+          </Typography.Title>
+          <Typography.Text type='tertiary'>
+            {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
+          </Typography.Text>
+          <br />
+          <Typography.Text type='tertiary'>
+            {t('请前往个人设置 → 安全设置进行配置。')}
+          </Typography.Text>
+        </div>
+      </Modal>
+    );
+  }
+
+  return (
+    <Modal
+      title={title || t('安全验证')}
+      visible={visible}
+      onCancel={loading ? undefined : onCancel}
+      closeOnEsc={!loading}
+      footer={null}
+      width={460}
+      centered
+      style={{
+        maxWidth: 'calc(100vw - 32px)'
+      }}
+      bodyStyle={{
+        padding: '20px 24px'
+      }}
+    >
+      <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='line'
+          size='default'
+          style={{ margin: 0 }}
+        >
+          {has2FA && (
+            <TabPane
+              tab={t('两步验证')}
+              itemKey='2fa'
+            >
+              <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>
+
+                <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={t('Passkey')}
+              itemKey='passkey'
+            >
+              <div style={{ paddingTop: '20px' }}>
+                <div style={{
+                  textAlign: 'center',
+                  padding: '24px 16px',
+                  marginBottom: '20px'
+                }}>
+                  <div style={{
+                    width: 56,
+                    height: 56,
+                    margin: '0 auto 16px',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    borderRadius: '50%',
+                    background: 'var(--semi-color-primary-light-default)',
+                  }}>
+                    <svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
+                      <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
+                    </svg>
+                  </div>
+                  <Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
+                    {t('使用 Passkey 验证')}
+                  </Typography.Title>
+                  <Typography.Text
+                    type='tertiary'
+                    style={{
+                      display: 'block',
+                      margin: 0,
+                      fontSize: '13px',
+                      lineHeight: '1.5'
+                    }}
+                  >
+                    {t('点击验证按钮,使用您的生物特征或安全密钥')}
+                  </Typography.Text>
+                </div>
+
+                <div style={{
+                  display: 'flex',
+                  justifyContent: 'flex-end',
+                  gap: '8px',
+                  flexWrap: 'wrap'
+                }}>
+                  <Button onClick={onCancel} disabled={loading}>
+                    {t('取消')}
+                  </Button>
+                  <Button
+                    theme='solid'
+                    type='primary'
+                    loading={loading}
+                    disabled={loading}
+                    onClick={() => onVerify(method)}
+                  >
+                    {t('验证 Passkey')}
+                  </Button>
+                </div>
+              </div>
+            </TabPane>
+          )}
+        </Tabs>
+      </div>
+    </Modal>
+  );
+};
+
+export default SecureVerificationModal;

+ 95 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -26,6 +26,9 @@ import {
   showInfo,
   showSuccess,
   setStatusData,
+  prepareCredentialCreationOptions,
+  buildRegistrationResult,
+  isPasskeySupported,
   setUserData,
 } from '../../helpers';
 import { UserContext } from '../../context/User';
@@ -67,6 +70,10 @@ const PersonalSetting = () => {
   const [disableButton, setDisableButton] = useState(false);
   const [countdown, setCountdown] = useState(30);
   const [systemToken, setSystemToken] = useState('');
+  const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
+  const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
+  const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
+  const [passkeySupported, setPasskeySupported] = useState(false);
   const [notificationSettings, setNotificationSettings] = useState({
     warningType: 'email',
     warningThreshold: 100000,
@@ -113,6 +120,10 @@ const PersonalSetting = () => {
     })();
 
     getUserData();
+
+    isPasskeySupported()
+      .then(setPasskeySupported)
+      .catch(() => setPasskeySupported(false));
   }, []);
 
   useEffect(() => {
@@ -161,12 +172,90 @@ const PersonalSetting = () => {
     }
   };
 
+  const loadPasskeyStatus = async () => {
+    try {
+      const res = await API.get('/api/user/passkey');
+      const { success, data, message } = res.data;
+      if (success) {
+        setPasskeyStatus({
+          enabled: data?.enabled || false,
+          last_used_at: data?.last_used_at || null,
+          backup_eligible: data?.backup_eligible || false,
+          backup_state: data?.backup_state || false,
+        });
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      // 忽略错误,保留默认状态
+    }
+  };
+
+  const handleRegisterPasskey = async () => {
+    if (!passkeySupported || !window.PublicKeyCredential) {
+      showInfo(t('当前设备不支持 Passkey'));
+      return;
+    }
+    setPasskeyRegisterLoading(true);
+    try {
+      const beginRes = await API.post('/api/user/passkey/register/begin');
+      const { success, message, data } = beginRes.data;
+      if (!success) {
+        showError(message || t('无法发起 Passkey 注册'));
+        return;
+      }
+
+      const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
+      const credential = await navigator.credentials.create({ publicKey });
+      const payload = buildRegistrationResult(credential);
+      if (!payload) {
+        showError(t('Passkey 注册失败,请重试'));
+        return;
+      }
+
+      const finishRes = await API.post('/api/user/passkey/register/finish', payload);
+      if (finishRes.data.success) {
+        showSuccess(t('Passkey 注册成功'));
+        await loadPasskeyStatus();
+      } else {
+        showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
+      }
+    } catch (error) {
+      if (error?.name === 'AbortError') {
+        showInfo(t('已取消 Passkey 注册'));
+      } else {
+        showError(t('Passkey 注册失败,请重试'));
+      }
+    } finally {
+      setPasskeyRegisterLoading(false);
+    }
+  };
+
+  const handleRemovePasskey = async () => {
+    setPasskeyDeleteLoading(true);
+    try {
+      const res = await API.delete('/api/user/passkey');
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess(t('Passkey 已解绑'));
+        await loadPasskeyStatus();
+      } else {
+        showError(message || t('操作失败,请重试'));
+      }
+    } catch (error) {
+      showError(t('操作失败,请重试'));
+    } finally {
+      setPasskeyDeleteLoading(false);
+    }
+  };
+
   const getUserData = async () => {
     let res = await API.get(`/api/user/self`);
     const { success, message, data } = res.data;
     if (success) {
       userDispatch({ type: 'login', payload: data });
       setUserData(data);
+      await loadPasskeyStatus();
     } else {
       showError(message);
     }
@@ -354,6 +443,12 @@ const PersonalSetting = () => {
               handleSystemTokenClick={handleSystemTokenClick}
               setShowChangePasswordModal={setShowChangePasswordModal}
               setShowAccountDeleteModal={setShowAccountDeleteModal}
+              passkeyStatus={passkeyStatus}
+              passkeySupported={passkeySupported}
+              passkeyRegisterLoading={passkeyRegisterLoading}
+              passkeyDeleteLoading={passkeyDeleteLoading}
+              onPasskeyRegister={handleRegisterPasskey}
+              onPasskeyDelete={handleRemovePasskey}
             />
 
             {/* 右侧:其他设置 */}

+ 164 - 0
web/src/components/settings/SystemSetting.jsx

@@ -30,6 +30,7 @@ import {
   Spin,
   Card,
   Radio,
+  Select,
 } from '@douyinfe/semi-ui';
 const { Text } = Typography;
 import {
@@ -77,6 +78,13 @@ const SystemSetting = () => {
     TurnstileSiteKey: '',
     TurnstileSecretKey: '',
     RegisterEnabled: '',
+    'passkey.enabled': '',
+    'passkey.rp_display_name': '',
+    'passkey.rp_id': '',
+    'passkey.origins': [],
+    'passkey.allow_insecure_origin': '',
+    'passkey.user_verification': 'preferred',
+    'passkey.attachment_preference': '',
     EmailDomainRestrictionEnabled: '',
     EmailAliasRestrictionEnabled: '',
     SMTPSSLEnabled: '',
@@ -173,9 +181,25 @@ const SystemSetting = () => {
           case 'SMTPSSLEnabled':
           case 'LinuxDOOAuthEnabled':
           case 'oidc.enabled':
+          case 'passkey.enabled':
+          case 'passkey.allow_insecure_origin':
           case 'WorkerAllowHttpImageRequestEnabled':
             item.value = toBoolean(item.value);
             break;
+          case 'passkey.origins':
+            // origins是逗号分隔的字符串,直接使用
+            item.value = item.value || '';
+            break;
+          case 'passkey.rp_display_name':
+          case 'passkey.rp_id':
+          case 'passkey.attachment_preference':
+            // 确保字符串字段不为null/undefined
+            item.value = item.value || '';
+            break;
+          case 'passkey.user_verification':
+            // 确保有默认值
+            item.value = item.value || 'preferred';
+            break;
           case 'Price':
           case 'MinTopUp':
             item.value = parseFloat(item.value);
@@ -582,6 +606,36 @@ const SystemSetting = () => {
     }
   };
 
+  const submitPasskeySettings = async () => {
+    // 使用formApi直接获取当前表单值
+    const formValues = formApiRef.current?.getValues() || {};
+
+    const options = [];
+
+    options.push({
+      key: 'passkey.rp_display_name',
+      value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
+    });
+    options.push({
+      key: 'passkey.rp_id',
+      value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
+    });
+    options.push({
+      key: 'passkey.user_verification',
+      value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
+    });
+    options.push({
+      key: 'passkey.attachment_preference',
+      value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
+    });
+    options.push({
+      key: 'passkey.origins',
+      value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
+    });
+
+    await updateOptions(options);
+  };
+
   const handleCheckboxChange = async (optionKey, event) => {
     const value = event.target.checked;
 
@@ -957,6 +1011,116 @@ const SystemSetting = () => {
                 </Form.Section>
               </Card>
 
+              <Card>
+                <Form.Section text={t('配置 Passkey')}>
+                  <Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
+                  <Banner
+                    type='info'
+                    description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
+                    style={{ marginBottom: 20, marginTop: 16 }}
+                  />
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Checkbox
+                        field="['passkey.enabled']"
+                        noLabel
+                        onChange={(e) =>
+                          handleCheckboxChange('passkey.enabled', e)
+                        }
+                      >
+                        {t('允许通过 Passkey 登录 & 认证')}
+                      </Form.Checkbox>
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                  >
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field="['passkey.rp_display_name']"
+                        label={t('服务显示名称')}
+                        placeholder={t('默认使用系统名称')}
+                        extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
+                      />
+                    </Col>
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field="['passkey.rp_id']"
+                        label={t('网站域名标识')}
+                        placeholder={t('例如:example.com')}
+                        extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')}
+                      />
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Select
+                        field="['passkey.user_verification']"
+                        label={t('安全验证级别')}
+                        placeholder={t('是否要求指纹/面容等生物识别')}
+                        optionList={[
+                          { label: t('推荐使用(用户可选)'), value: 'preferred' },
+                          { label: t('强制要求'), value: 'required' },
+                          { label: t('不建议使用'), value: 'discouraged' },
+                        ]}
+                        extraText={t('推荐:用户可以选择是否使用指纹等验证')}
+                      />
+                    </Col>
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Select
+                        field="['passkey.attachment_preference']"
+                        label={t('设备类型偏好')}
+                        placeholder={t('选择支持的认证设备类型')}
+                        optionList={[
+                          { label: t('不限制'), value: '' },
+                          { label: t('本设备内置'), value: 'platform' },
+                          { label: t('外接设备'), value: 'cross-platform' },
+                        ]}
+                        extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
+                      />
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Checkbox
+                        field="['passkey.allow_insecure_origin']"
+                        noLabel
+                        extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
+                        onChange={(e) =>
+                          handleCheckboxChange('passkey.allow_insecure_origin', e)
+                        }
+                      >
+                        {t('允许不安全的 Origin(HTTP)')}
+                      </Form.Checkbox>
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Input
+                        field="['passkey.origins']"
+                        label={t('允许的 Origins')}
+                        placeholder={t('填写带https的域名,逗号分隔')}
+                        extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')}
+                      />
+                    </Col>
+                  </Row>
+                  <Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
+                    {t('保存 Passkey 设置')}
+                  </Button>
+                </Form.Section>
+              </Card>
+
               <Card>
                 <Form.Section text={t('配置邮箱域名白名单')}>
                   <Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

+ 75 - 0
web/src/components/settings/personal/cards/AccountManagement.jsx

@@ -59,6 +59,12 @@ const AccountManagement = ({
   handleSystemTokenClick,
   setShowChangePasswordModal,
   setShowAccountDeleteModal,
+  passkeyStatus,
+  passkeySupported,
+  passkeyRegisterLoading,
+  passkeyDeleteLoading,
+  onPasskeyRegister,
+  onPasskeyDelete,
 }) => {
   const renderAccountInfo = (accountId, label) => {
     if (!accountId || accountId === '') {
@@ -86,6 +92,10 @@ const AccountManagement = ({
   };
   const isBound = (accountId) => Boolean(accountId);
   const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
+  const passkeyEnabled = passkeyStatus?.enabled;
+  const lastUsedLabel = passkeyStatus?.last_used_at
+    ? new Date(passkeyStatus.last_used_at).toLocaleString()
+    : t('尚未使用');
 
   return (
     <Card className='!rounded-2xl'>
@@ -476,6 +486,71 @@ const AccountManagement = ({
                   </div>
                 </Card>
 
+                {/* Passkey 设置 */}
+                <Card className='!rounded-xl w-full'>
+                  <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
+                    <div className='flex items-start w-full sm:w-auto'>
+                      <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
+                        <IconKey size='large' className='text-slate-600' />
+                      </div>
+                      <div>
+                        <Typography.Title heading={6} className='mb-1'>
+                          {t('Passkey 登录')}
+                        </Typography.Title>
+                        <Typography.Text type='tertiary' className='text-sm'>
+                          {passkeyEnabled
+                            ? t('已启用 Passkey,无需密码即可登录')
+                            : t('使用 Passkey 实现免密且更安全的登录体验')}
+                        </Typography.Text>
+                        <div className='mt-2 text-xs text-gray-500 space-y-1'>
+                          <div>
+                            {t('最后使用时间')}:{lastUsedLabel}
+                          </div>
+                          {/*{passkeyEnabled && (*/}
+                          {/*  <div>*/}
+                          {/*    {t('备份支持')}:*/}
+                          {/*    {passkeyStatus?.backup_eligible*/}
+                          {/*      ? t('支持备份')*/}
+                          {/*      : t('不支持')}*/}
+                          {/*    ,{t('备份状态')}:*/}
+                          {/*    {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
+                          {/*  </div>*/}
+                          {/*)}*/}
+                          {!passkeySupported && (
+                            <div className='text-amber-600'>
+                              {t('当前设备不支持 Passkey')}
+                            </div>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+                    <Button
+                      type={passkeyEnabled ? 'danger' : 'primary'}
+                      theme={passkeyEnabled ? 'solid' : 'solid'}
+                      onClick={
+                        passkeyEnabled
+                          ? () => {
+                              Modal.confirm({
+                                title: t('确认解绑 Passkey'),
+                                content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
+                                okText: t('确认解绑'),
+                                cancelText: t('取消'),
+                                okType: 'danger',
+                                onOk: onPasskeyDelete,
+                              });
+                            }
+                          : onPasskeyRegister
+                      }
+                      className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
+                      icon={<IconKey />}
+                      disabled={!passkeySupported && !passkeyEnabled}
+                      loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
+                    >
+                      {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
+                    </Button>
+                  </div>
+                </Card>
+
                 {/* 两步验证设置 */}
                 <TwoFASetting t={t} />
 

+ 74 - 75
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -56,8 +56,10 @@ import {
 } from '../../../../helpers';
 import ModelSelectModal from './ModelSelectModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
-import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
+import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
 import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
+import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
+import { createApiCalls } from '../../../../services/secureVerification';
 import {
   IconSave,
   IconClose,
@@ -194,43 +196,51 @@ const EditChannelModal = (props) => {
   const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
   const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
 
-  // 2FA验证查看密钥相关状态
-  const [twoFAState, setTwoFAState] = useState({
+  // 密钥显示状态
+  const [keyDisplayState, setKeyDisplayState] = useState({
     showModal: false,
-    code: '',
-    loading: false,
-    showKey: false,
     keyData: '',
   });
 
-  // 专门的2FA验证状态(用于TwoFactorAuthModal)
-  const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
-  const [verifyCode, setVerifyCode] = useState('');
-  const [verifyLoading, setVerifyLoading] = useState(false);
-
-  // 2FA状态更新辅助函数
-  const updateTwoFAState = (updates) => {
-    setTwoFAState((prev) => ({ ...prev, ...updates }));
-  };
+  // 使用通用安全验证 Hook
+  const {
+    isModalVisible,
+    verificationMethods,
+    verificationState,
+    withVerification,
+    executeVerification,
+    cancelVerification,
+    setVerificationCode,
+    switchVerificationMethod,
+  } = useSecureVerification({
+    onSuccess: (result) => {
+      // 验证成功后显示密钥
+      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,
+        });
+      }
+    },
+  });
 
-  // 重置2FA状态
-  const resetTwoFAState = () => {
-    setTwoFAState({
+  // 重置密钥显示状态
+  const resetKeyDisplayState = () => {
+    setKeyDisplayState({
       showModal: false,
-      code: '',
-      loading: false,
-      showKey: false,
       keyData: '',
     });
   };
 
-  // 重置2FA验证状态
-  const reset2FAVerifyState = () => {
-    setShow2FAVerifyModal(false);
-    setVerifyCode('');
-    setVerifyLoading(false);
-  };
-
   // 渠道额外设置状态
   const [channelSettings, setChannelSettings] = useState({
     force_format: false,
@@ -603,42 +613,33 @@ const EditChannelModal = (props) => {
     }
   };
 
-  // 使用TwoFactorAuthModal的验证函数
-  const handleVerify2FA = async () => {
-    if (!verifyCode) {
-      showError(t('请输入验证码或备用码'));
-      return;
-    }
-
-    setVerifyLoading(true);
+  // 查看渠道密钥(透明验证)
+  const handleShow2FAModal = async () => {
     try {
-      const res = await API.post(`/api/channel/${channelId}/key`, {
-        code: verifyCode,
-      });
-      if (res.data.success) {
-        // 验证成功,显示密钥
-        updateTwoFAState({
+      // 使用 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,
-          showKey: true,
-          keyData: res.data.data.key,
+          keyData: result.data.key,
         });
-        reset2FAVerifyState();
-        showSuccess(t('验证成功'));
-      } else {
-        showError(res.data.message);
       }
     } catch (error) {
-      showError(t('获取密钥失败'));
-    } finally {
-      setVerifyLoading(false);
+      console.error('Failed to view channel key:', error);
+      showError(error.message || t('获取密钥失败'));
     }
   };
 
-  // 显示2FA验证模态框 - 使用TwoFactorAuthModal
-  const handleShow2FAModal = () => {
-    setShow2FAVerifyModal(true);
-  };
-
   useEffect(() => {
     const modelMap = new Map();
 
@@ -742,10 +743,8 @@ const EditChannelModal = (props) => {
     }
     // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
     setInputs(getInitValues());
-    // 重置2FA状态
-    resetTwoFAState();
-    // 重置2FA验证状态
-    reset2FAVerifyState();
+    // 重置密钥显示状态
+    resetKeyDisplayState();
   };
 
   const handleVertexUploadChange = ({ fileList }) => {
@@ -2499,17 +2498,17 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
       </SideSheet>
-      {/* 使用TwoFactorAuthModal组件进行2FA验证 */}
-      <TwoFactorAuthModal
-        visible={show2FAVerifyModal}
-        code={verifyCode}
-        loading={verifyLoading}
-        onCodeChange={setVerifyCode}
-        onVerify={handleVerify2FA}
-        onCancel={reset2FAVerifyState}
-        title={t('查看渠道密钥')}
-        description={t('为了保护账户安全,请验证您的两步验证码。')}
-        placeholder={t('请输入验证码或备用码')}
+      {/* 使用通用安全验证模态框 */}
+      <SecureVerificationModal
+        visible={isModalVisible}
+        verificationMethods={verificationMethods}
+        verificationState={verificationState}
+        onVerify={executeVerification}
+        onCancel={cancelVerification}
+        onCodeChange={setVerificationCode}
+        onMethodSwitch={switchVerificationMethod}
+        title={verificationState.title}
+        description={verificationState.description}
       />
 
       {/* 使用ChannelKeyDisplay组件显示密钥 */}
@@ -2532,10 +2531,10 @@ const EditChannelModal = (props) => {
             {t('渠道密钥信息')}
           </div>
         }
-        visible={twoFAState.showModal && twoFAState.showKey}
-        onCancel={resetTwoFAState}
+        visible={keyDisplayState.showModal}
+        onCancel={resetKeyDisplayState}
         footer={
-          <Button type='primary' onClick={resetTwoFAState}>
+          <Button type='primary' onClick={resetKeyDisplayState}>
             {t('完成')}
           </Button>
         }
@@ -2543,7 +2542,7 @@ const EditChannelModal = (props) => {
         style={{ maxWidth: '90vw' }}
       >
         <ChannelKeyDisplay
-          keyData={twoFAState.keyData}
+          keyData={keyDisplayState.keyData}
           showSuccessIcon={true}
           successText={t('密钥获取成功')}
           showWarning={true}

+ 27 - 1
web/src/components/table/channels/modals/ModelTestModal.jsx

@@ -25,6 +25,7 @@ import {
   Table,
   Tag,
   Typography,
+  Select,
 } from '@douyinfe/semi-ui';
 import { IconSearch } from '@douyinfe/semi-icons';
 import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -45,6 +46,8 @@ const ModelTestModal = ({
   testChannel,
   modelTablePage,
   setModelTablePage,
+  selectedEndpointType,
+  setSelectedEndpointType,
   allSelectingRef,
   isMobile,
   t,
@@ -59,6 +62,17 @@ const ModelTestModal = ({
         )
     : [];
 
+  const endpointTypeOptions = [
+    { value: '', label: t('自动检测') },
+    { value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
+    { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
+    { value: 'anthropic', label: 'Anthropic (/v1/messages)' },
+    { value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
+    { value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
+    { value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
+    { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
+  ];
+
   const handleCopySelected = () => {
     if (selectedModelKeys.length === 0) {
       showError(t('请先选择模型!'));
@@ -152,7 +166,7 @@ const ModelTestModal = ({
         return (
           <Button
             type='tertiary'
-            onClick={() => testChannel(currentTestChannel, record.model)}
+            onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
             loading={isTesting}
             size='small'
           >
@@ -228,6 +242,18 @@ const ModelTestModal = ({
     >
       {hasChannel && (
         <div className='model-test-scroll'>
+          {/* 端点类型选择器 */}
+          <div className='flex items-center gap-2 w-full mb-2'>
+            <Typography.Text strong>{t('端点类型')}:</Typography.Text>
+            <Select
+              value={selectedEndpointType}
+              onChange={setSelectedEndpointType}
+              optionList={endpointTypeOptions}
+              className='!w-full'
+              placeholder={t('选择端点类型')}
+            />
+          </div>
+
           {/* 搜索与操作按钮 */}
           <div className='flex items-center justify-end gap-2 w-full mb-2'>
             <Input

+ 40 - 6
web/src/components/table/users/UsersColumnDefs.jsx

@@ -26,7 +26,9 @@ import {
   Progress,
   Popover,
   Typography,
+  Dropdown,
 } from '@douyinfe/semi-ui';
+import { IconMore } from '@douyinfe/semi-icons';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 
 /**
@@ -204,6 +206,8 @@ const renderOperations = (
     showDemoteModal,
     showEnableDisableModal,
     showDeleteModal,
+    showResetPasskeyModal,
+    showResetTwoFAModal,
     t,
   },
 ) => {
@@ -211,6 +215,28 @@ const renderOperations = (
     return <></>;
   }
 
+  const moreMenu = [
+    {
+      node: 'item',
+      name: t('重置 Passkey'),
+      onClick: () => showResetPasskeyModal(record),
+    },
+    {
+      node: 'item',
+      name: t('重置 2FA'),
+      onClick: () => showResetTwoFAModal(record),
+    },
+    {
+      node: 'divider',
+    },
+    {
+      node: 'item',
+      name: t('注销'),
+      type: 'danger',
+      onClick: () => showDeleteModal(record),
+    },
+  ];
+
   return (
     <Space>
       {record.status === 1 ? (
@@ -253,13 +279,17 @@ const renderOperations = (
       >
         {t('降级')}
       </Button>
-      <Button
-        type='danger'
-        size='small'
-        onClick={() => showDeleteModal(record)}
+      <Dropdown
+        menu={moreMenu}
+        trigger='click'
+        position='bottomRight'
       >
-        {t('注销')}
-      </Button>
+        <Button
+          type='tertiary'
+          size='small'
+          icon={<IconMore />}
+        />
+      </Dropdown>
     </Space>
   );
 };
@@ -275,6 +305,8 @@ export const getUsersColumns = ({
   showDemoteModal,
   showEnableDisableModal,
   showDeleteModal,
+  showResetPasskeyModal,
+  showResetTwoFAModal,
 }) => {
   return [
     {
@@ -329,6 +361,8 @@ export const getUsersColumns = ({
           showDemoteModal,
           showEnableDisableModal,
           showDeleteModal,
+          showResetPasskeyModal,
+          showResetTwoFAModal,
           t,
         }),
     },

+ 55 - 1
web/src/components/table/users/UsersTable.jsx

@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
 import DemoteUserModal from './modals/DemoteUserModal';
 import EnableDisableUserModal from './modals/EnableDisableUserModal';
 import DeleteUserModal from './modals/DeleteUserModal';
+import ResetPasskeyModal from './modals/ResetPasskeyModal';
+import ResetTwoFAModal from './modals/ResetTwoFAModal';
 
 const UsersTable = (usersData) => {
   const {
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
     setShowEditUser,
     manageUser,
     refresh,
+    resetUserPasskey,
+    resetUserTwoFA,
     t,
   } = usersData;
 
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
   const [showDeleteModal, setShowDeleteModal] = useState(false);
   const [modalUser, setModalUser] = useState(null);
   const [enableDisableAction, setEnableDisableAction] = useState('');
+  const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
+  const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
 
   // Modal handlers
   const showPromoteUserModal = (user) => {
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
     setShowDeleteModal(true);
   };
 
+  const showResetPasskeyUserModal = (user) => {
+    setModalUser(user);
+    setShowResetPasskeyModal(true);
+  };
+
+  const showResetTwoFAUserModal = (user) => {
+    setModalUser(user);
+    setShowResetTwoFAModal(true);
+  };
+
   // Modal confirm handlers
   const handlePromoteConfirm = () => {
     manageUser(modalUser.id, 'promote', modalUser);
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
     setShowEnableDisableModal(false);
   };
 
+  const handleResetPasskeyConfirm = async () => {
+    await resetUserPasskey(modalUser);
+    setShowResetPasskeyModal(false);
+  };
+
+  const handleResetTwoFAConfirm = async () => {
+    await resetUserTwoFA(modalUser);
+    setShowResetTwoFAModal(false);
+  };
+
   // Get all columns
   const columns = useMemo(() => {
     return getUsersColumns({
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
       showDemoteModal: showDemoteUserModal,
       showEnableDisableModal: showEnableDisableUserModal,
       showDeleteModal: showDeleteUserModal,
+      showResetPasskeyModal: showResetPasskeyUserModal,
+      showResetTwoFAModal: showResetTwoFAUserModal,
     });
-  }, [t, setEditingUser, setShowEditUser]);
+  }, [
+    t,
+    setEditingUser,
+    setShowEditUser,
+    showPromoteUserModal,
+    showDemoteUserModal,
+    showEnableDisableUserModal,
+    showDeleteUserModal,
+    showResetPasskeyUserModal,
+    showResetTwoFAUserModal,
+  ]);
 
   // Handle compact mode by removing fixed positioning
   const tableColumns = useMemo(() => {
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
         manageUser={manageUser}
         t={t}
       />
+
+      <ResetPasskeyModal
+        visible={showResetPasskeyModal}
+        onCancel={() => setShowResetPasskeyModal(false)}
+        onConfirm={handleResetPasskeyConfirm}
+        user={modalUser}
+        t={t}
+      />
+
+      <ResetTwoFAModal
+        visible={showResetTwoFAModal}
+        onCancel={() => setShowResetTwoFAModal(false)}
+        onConfirm={handleResetTwoFAConfirm}
+        user={modalUser}
+        t={t}
+      />
     </>
   );
 };

+ 39 - 0
web/src/components/table/users/modals/ResetPasskeyModal.jsx

@@ -0,0 +1,39 @@
+/*
+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
+*/
+
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+
+const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
+  return (
+    <Modal
+      title={t('确认重置 Passkey')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onConfirm}
+      type='warning'
+    >
+      {t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
+      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+    </Modal>
+  );
+};
+
+export default ResetPasskeyModal;
+

+ 39 - 0
web/src/components/table/users/modals/ResetTwoFAModal.jsx

@@ -0,0 +1,39 @@
+/*
+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
+*/
+
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+
+const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
+  return (
+    <Modal
+      title={t('确认重置两步验证')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onConfirm}
+      type='warning'
+    >
+      {t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
+      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+    </Modal>
+  );
+};
+
+export default ResetTwoFAModal;
+

+ 1 - 0
web/src/helpers/index.js

@@ -27,3 +27,4 @@ export * from './data';
 export * from './token';
 export * from './boolean';
 export * from './dashboard';
+export * from './passkey';

+ 137 - 0
web/src/helpers/passkey.js

@@ -0,0 +1,137 @@
+export function base64UrlToBuffer(base64url) {
+  if (!base64url) return new ArrayBuffer(0);
+  let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
+  const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
+  const rawData = window.atob(base64);
+  const buffer = new ArrayBuffer(rawData.length);
+  const uintArray = new Uint8Array(buffer);
+  for (let i = 0; i < rawData.length; i += 1) {
+    uintArray[i] = rawData.charCodeAt(i);
+  }
+  return buffer;
+}
+
+export function bufferToBase64Url(buffer) {
+  if (!buffer) return '';
+  const uintArray = new Uint8Array(buffer);
+  let binary = '';
+  for (let i = 0; i < uintArray.byteLength; i += 1) {
+    binary += String.fromCharCode(uintArray[i]);
+  }
+  return window
+    .btoa(binary)
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/g, '');
+}
+
+export function prepareCredentialCreationOptions(payload) {
+  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  if (!options) {
+    throw new Error('无法从服务端响应中解析 Passkey 注册参数');
+  }
+  const publicKey = {
+    ...options,
+    challenge: base64UrlToBuffer(options.challenge),
+    user: {
+      ...options.user,
+      id: base64UrlToBuffer(options.user?.id),
+    },
+  };
+
+  if (Array.isArray(options.excludeCredentials)) {
+    publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
+      ...item,
+      id: base64UrlToBuffer(item.id),
+    }));
+  }
+
+  if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
+    delete publicKey.attestationFormats;
+  }
+
+  return publicKey;
+}
+
+export function prepareCredentialRequestOptions(payload) {
+  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  if (!options) {
+    throw new Error('无法从服务端响应中解析 Passkey 登录参数');
+  }
+  const publicKey = {
+    ...options,
+    challenge: base64UrlToBuffer(options.challenge),
+  };
+
+  if (Array.isArray(options.allowCredentials)) {
+    publicKey.allowCredentials = options.allowCredentials.map((item) => ({
+      ...item,
+      id: base64UrlToBuffer(item.id),
+    }));
+  }
+
+  return publicKey;
+}
+
+export function buildRegistrationResult(credential) {
+  if (!credential) return null;
+
+  const { response } = credential;
+  const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
+
+  return {
+    id: credential.id,
+    rawId: bufferToBase64Url(credential.rawId),
+    type: credential.type,
+    authenticatorAttachment: credential.authenticatorAttachment,
+    response: {
+      attestationObject: bufferToBase64Url(response.attestationObject),
+      clientDataJSON: bufferToBase64Url(response.clientDataJSON),
+      transports,
+    },
+    clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
+  };
+}
+
+export function buildAssertionResult(assertion) {
+  if (!assertion) return null;
+
+  const { response } = assertion;
+
+  return {
+    id: assertion.id,
+    rawId: bufferToBase64Url(assertion.rawId),
+    type: assertion.type,
+    authenticatorAttachment: assertion.authenticatorAttachment,
+    response: {
+      authenticatorData: bufferToBase64Url(response.authenticatorData),
+      clientDataJSON: bufferToBase64Url(response.clientDataJSON),
+      signature: bufferToBase64Url(response.signature),
+      userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
+    },
+    clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
+  };
+}
+
+export async function isPasskeySupported() {
+  if (typeof window === 'undefined' || !window.PublicKeyCredential) {
+    return false;
+  }
+  if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
+    try {
+      const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
+      if (available) return true;
+    } catch (error) {
+      // ignore
+    }
+  }
+  if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
+    try {
+      return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+    } catch (error) {
+      return false;
+    }
+  }
+  return true;
+}
+

+ 62 - 0
web/src/helpers/secureApiCall.js

@@ -0,0 +1,62 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact 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
+  };
+}

+ 11 - 3
web/src/hooks/channels/useChannelsData.jsx

@@ -80,6 +80,7 @@ export const useChannelsData = () => {
   const [selectedModelKeys, setSelectedModelKeys] = useState([]);
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [modelTablePage, setModelTablePage] = useState(1);
+  const [selectedEndpointType, setSelectedEndpointType] = useState('');
   
   // 使用 ref 来避免闭包问题,类似旧版实现
   const shouldStopBatchTestingRef = useRef(false);
@@ -691,7 +692,7 @@ export const useChannelsData = () => {
   };
 
   // Test channel - 单个模型测试,参考旧版实现
-  const testChannel = async (record, model) => {
+  const testChannel = async (record, model, endpointType = '') => {
     const testKey = `${record.id}-${model}`;
 
     // 检查是否应该停止批量测试
@@ -703,7 +704,11 @@ export const useChannelsData = () => {
     setTestingModels(prev => new Set([...prev, model]));
 
     try {
-      const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
+      let url = `/api/channel/test/${record.id}?model=${model}`;
+      if (endpointType) {
+        url += `&endpoint_type=${endpointType}`;
+      }
+      const res = await API.get(url);
 
       // 检查是否在请求期间被停止
       if (shouldStopBatchTestingRef.current && isBatchTesting) {
@@ -820,7 +825,7 @@ export const useChannelsData = () => {
           .replace('${total}', models.length)
         );
 
-        const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
+        const batchPromises = batch.map(model => testChannel(currentTestChannel, model, selectedEndpointType));
         const batchResults = await Promise.allSettled(batchPromises);
         results.push(...batchResults);
 
@@ -902,6 +907,7 @@ export const useChannelsData = () => {
     setTestingModels(new Set());
     setSelectedModelKeys([]);
     setModelTablePage(1);
+    setSelectedEndpointType('');
     // 可选择性保留测试结果,这里不清空以便用户查看
   };
 
@@ -989,6 +995,8 @@ export const useChannelsData = () => {
     isBatchTesting,
     modelTablePage,
     setModelTablePage,
+    selectedEndpointType,
+    setSelectedEndpointType,
     allSelectingRef,
 
     // Multi-key management states

+ 246 - 0
web/src/hooks/common/useSecureVerification.jsx

@@ -0,0 +1,246 @@
+/*
+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
+*/
+
+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
+ * @param {Object} options - 配置选项
+ * @param {Function} options.onSuccess - 验证成功回调
+ * @param {Function} options.onError - 验证失败回调
+ * @param {string} options.successMessage - 成功提示消息
+ * @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
+ */
+export const useSecureVerification = ({ 
+  onSuccess, 
+  onError, 
+  successMessage,
+  autoReset = true 
+} = {}) => {
+  const { t } = useTranslation();
+
+  // 验证方式可用性状态
+  const [verificationMethods, setVerificationMethods] = useState({
+    has2FA: false,
+    hasPasskey: false,
+    passkeySupported: false
+  });
+
+  // 模态框状态
+  const [isModalVisible, setIsModalVisible] = useState(false);
+
+  // 当前验证状态
+  const [verificationState, setVerificationState] = useState({
+    method: null, // '2fa' | 'passkey'
+    loading: false,
+    code: '',
+    apiCall: null
+  });
+
+  // 检查可用的验证方式
+  const checkVerificationMethods = useCallback(async () => {
+    const methods = await SecureVerificationService.checkAvailableVerificationMethods();
+    setVerificationMethods(methods);
+    return methods;
+  }, []);
+
+  // 初始化时检查验证方式
+  useEffect(() => {
+    checkVerificationMethods();
+  }, [checkVerificationMethods]);
+
+  // 重置状态
+  const resetState = useCallback(() => {
+    setVerificationState({
+      method: null,
+      loading: false,
+      code: '',
+      apiCall: null
+    });
+    setIsModalVisible(false);
+  }, []);
+
+  // 开始验证流程
+  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);
+      onError?.(new Error(errorMessage));
+      return false;
+    }
+
+    // 设置默认验证方式
+    let defaultMethod = preferredMethod;
+    if (!defaultMethod) {
+      if (methods.hasPasskey && methods.passkeySupported) {
+        defaultMethod = 'passkey';
+      } else if (methods.has2FA) {
+        defaultMethod = '2fa';
+      }
+    }
+
+    setVerificationState(prev => ({
+      ...prev,
+      method: defaultMethod,
+      apiCall,
+      title,
+      description
+    }));
+    setIsModalVisible(true);
+
+    return true;
+  }, [checkVerificationMethods, onError, t]);
+
+  // 执行验证
+  const executeVerification = useCallback(async (method, code = '') => {
+    if (!verificationState.apiCall) {
+      showError(t('验证配置错误'));
+      return;
+    }
+
+    setVerificationState(prev => ({ ...prev, loading: true }));
+
+    try {
+      // 先调用验证 API,成功后后端会设置 session
+      await SecureVerificationService.verify(method, code);
+
+      // 验证成功,调用业务 API(此时中间件会通过)
+      const result = await verificationState.apiCall();
+
+      // 显示成功消息
+      if (successMessage) {
+        showSuccess(successMessage);
+      }
+
+      // 调用成功回调
+      onSuccess?.(result, method);
+
+      // 自动重置状态
+      if (autoReset) {
+        resetState();
+      }
+
+      return result;
+    } catch (error) {
+      showError(error.message || t('验证失败,请重试'));
+      onError?.(error);
+      throw error;
+    } finally {
+      setVerificationState(prev => ({ ...prev, loading: false }));
+    }
+  }, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
+
+  // 设置验证码
+  const setVerificationCode = useCallback((code) => {
+    setVerificationState(prev => ({ ...prev, code }));
+  }, []);
+
+  // 切换验证方式
+  const switchVerificationMethod = useCallback((method) => {
+    setVerificationState(prev => ({ ...prev, method, code: '' }));
+  }, []);
+
+  // 取消验证
+  const cancelVerification = useCallback(() => {
+    resetState();
+  }, [resetState]);
+
+  // 检查是否可以使用某种验证方式
+  const canUseMethod = useCallback((method) => {
+    switch (method) {
+      case '2fa':
+        return verificationMethods.has2FA;
+      case 'passkey':
+        return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
+      default:
+        return false;
+    }
+  }, [verificationMethods]);
+
+  // 获取推荐的验证方式
+  const getRecommendedMethod = useCallback(() => {
+    if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
+      return 'passkey';
+    }
+    if (verificationMethods.has2FA) {
+      return '2fa';
+    }
+    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,
+    cancelVerification,
+    resetState,
+    setVerificationCode,
+    switchVerificationMethod,
+    checkVerificationMethods,
+
+    // 辅助方法
+    canUseMethod,
+    getRecommendedMethod,
+    withVerification, // 新增:自动处理验证的包装函数
+
+    // 便捷属性
+    hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
+    isLoading: verificationState.loading,
+    currentMethod: verificationState.method,
+    code: verificationState.code
+  };
+};

+ 37 - 1
web/src/hooks/users/useUsersData.jsx

@@ -86,7 +86,7 @@ export const useUsersData = () => {
   };
 
   // Search users with keyword and group
-  const searchUsers = async (
+const searchUsers = async (
     startIdx,
     pageSize,
     searchKeyword = null,
@@ -154,6 +154,40 @@ export const useUsersData = () => {
     setLoading(false);
   };
 
+  const resetUserPasskey = async (user) => {
+    if (!user) {
+      return;
+    }
+    try {
+      const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess(t('Passkey 已重置'));
+      } else {
+        showError(message || t('操作失败,请重试'));
+      }
+    } catch (error) {
+      showError(t('操作失败,请重试'));
+    }
+  };
+
+  const resetUserTwoFA = async (user) => {
+    if (!user) {
+      return;
+    }
+    try {
+      const res = await API.delete(`/api/user/${user.id}/2fa`);
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess(t('二步验证已重置'));
+      } else {
+        showError(message || t('操作失败,请重试'));
+      }
+    } catch (error) {
+      showError(t('操作失败,请重试'));
+    }
+  };
+
   // Handle page change
   const handlePageChange = (page) => {
     setActivePage(page);
@@ -271,6 +305,8 @@ export const useUsersData = () => {
     loadUsers,
     searchUsers,
     manageUser,
+    resetUserPasskey,
+    resetUserTwoFA,
     handlePageChange,
     handlePageSizeChange,
     handleRow,

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

@@ -6,6 +6,7 @@
   "登 录": "Log In",
   "注 册": "Sign Up",
   "使用 邮箱或用户名 登录": "Sign in with Email or Username",
+  "使用 Passkey 认证": "Authenticate with Passkey",
   "使用 GitHub 继续": "Continue with GitHub",
   "使用 OIDC 继续": "Continue with OIDC",
   "使用 微信 继续": "Continue with WeChat",
@@ -332,6 +333,10 @@
   "通过密码注册时需要进行邮箱验证": "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",
+  "确认解绑 Passkey": "Confirm Unbind Passkey",
+  "解绑后将无法使用 Passkey 登录,确定要继续吗?": "After unbinding, you will not be able to login with Passkey. Are you sure you want to continue?",
+  "确认解绑": "Confirm Unbind",
   "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "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",
@@ -2132,6 +2137,60 @@
   "域名黑名单": "Domain Blacklist",
   "白名单": "Whitelist",
   "黑名单": "Blacklist",
+  "Passkey 登录": "Passkey Sign-in",
+  "已启用 Passkey,无需密码即可登录": "Passkey enabled. Passwordless login available.",
+  "使用 Passkey 实现免密且更安全的登录体验": "Use Passkey for a passwordless and more secure login experience.",
+  "最后使用时间": "Last used time",
+  "备份支持": "Backup support",
+  "支持备份": "Supported",
+  "不支持": "Not supported",
+  "备份状态": "Backup state",
+  "已备份": "Backed up",
+  "未备份": "Not backed up",
+  "当前设备不支持 Passkey": "Passkey is not supported on this device",
+  "注册 Passkey": "Register Passkey",
+  "解绑 Passkey": "Remove Passkey",
+  "Passkey 注册成功": "Passkey registration successful",
+  "Passkey 注册失败,请重试": "Passkey registration failed. Please try again.",
+  "已取消 Passkey 注册": "Passkey registration cancelled",
+  "Passkey 已解绑": "Passkey removed",
+  "操作失败,请重试": "Operation failed, please retry",
+  "重置 Passkey": "Reset Passkey",
+  "重置 2FA": "Reset 2FA",
+  "确认重置 Passkey": "Confirm Passkey Reset",
+  "确认重置两步验证": "Confirm Two-Factor Reset",
+  "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "This will detach the user's current Passkey. They will need to register again on next login.",
+  "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "This will disable the user's current two-factor setup. No verification code will be required until they enable it again.",
+  "目标用户:{{username}}": "Target user: {{username}}",
+  "Passkey 已重置": "Passkey has been reset",
+  "二步验证已重置": "Two-factor authentication has been reset",
+  "配置 Passkey": "Configure Passkey",
+  "用以支持基于 WebAuthn 的无密码登录注册": "Support WebAuthn-based passwordless login and registration",
+  "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey is a passwordless authentication method based on WebAuthn standard, supporting fingerprint, face recognition, hardware keys and other authentication methods",
+  "服务显示名称": "Service Display Name",
+  "默认使用系统名称": "Default uses system name",
+  "用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
+  "网站域名标识": "Website Domain ID",
+  "例如:example.com": "e.g.: example.com",
+  "留空自动使用当前域名": "Leave blank to auto-use current domain",
+  "安全验证级别": "Security Verification Level",
+  "是否要求指纹/面容等生物识别": "Whether to require fingerprint/face recognition",
+  "preferred": "preferred",
+  "required": "required",
+  "discouraged": "discouraged",
+  "推荐:用户可以选择是否使用指纹等验证": "Recommended: Users can choose whether to use fingerprint verification",
+  "设备类型偏好": "Device Type Preference",
+  "选择支持的认证设备类型": "Choose supported authentication device types",
+  "platform": "platform",
+  "cross-platform": "cross-platform",
+  "本设备:手机指纹/面容,外接:USB安全密钥": "Built-in: phone fingerprint/face, External: USB security key",
+  "允许不安全的 Origin(HTTP)": "Allow insecure Origin (HTTP)",
+  "仅用于开发环境,生产环境应使用 HTTPS": "For development only, use HTTPS in production",
+  "允许的 Origins": "Allowed Origins",
+  "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment",
+  "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
+  "保存 Passkey 设置": "Save Passkey Settings",
+  "黑名单": "Blacklist",
   "common": {
     "changeLanguage": "Change Language"
   }

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

@@ -5,6 +5,7 @@
   "关于": "关于",
   "登录": "登录",
   "注册": "注册",
+  "使用 Passkey 认证": "使用 Passkey 认证",
   "退出": "退出",
   "语言": "语言",
   "展开侧边栏": "展开侧边栏",
@@ -36,5 +37,62 @@
   "common": {
     "changeLanguage": "切换语言"
   },
-  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
+  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
+  "Passkey 认证": "Passkey 认证",
+  "已启用 Passkey,可进行无密码认证": "已启用 Passkey,可进行无密码认证",
+  "使用 Passkey 实现免密且更安全的认证体验": "使用 Passkey 实现免密且更安全的认证体验",
+  "最后使用时间": "最后使用时间",
+  "备份支持": "备份支持",
+  "支持备份": "支持备份",
+  "不支持": "不支持",
+  "备份状态": "备份状态",
+  "已备份": "已备份",
+  "未备份": "未备份",
+  "当前设备不支持 Passkey": "当前设备不支持 Passkey",
+  "注册 Passkey": "注册 Passkey",
+  "解绑 Passkey": "解绑 Passkey",
+  "配置 Passkey": "配置 Passkey",
+  "用以支持基于 WebAuthn 的无密码登录注册": "用以支持基于 WebAuthn 的无密码登录注册",
+  "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式",
+  "服务显示名称": "服务显示名称",
+  "默认使用系统名称": "默认使用系统名称",
+  "用户注册时看到的网站名称,比如'我的网站'": "用户注册时看到的网站名称,比如'我的网站'",
+  "网站域名标识": "网站域名标识",
+  "例如:example.com": "例如:example.com",
+  "留空自动使用当前域名": "留空自动使用当前域名",
+  "安全验证级别": "安全验证级别",
+  "是否要求指纹/面容等生物识别": "是否要求指纹/面容等生物识别",
+  "preferred": "preferred",
+  "required": "required",
+  "discouraged": "discouraged",
+  "推荐:用户可以选择是否使用指纹等验证": "推荐:用户可以选择是否使用指纹等验证",
+  "设备类型偏好": "设备类型偏好",
+  "选择支持的认证设备类型": "选择支持的认证设备类型",
+  "platform": "platform",
+  "cross-platform": "cross-platform",
+  "本设备:手机指纹/面容,外接:USB安全密钥": "本设备:手机指纹/面容,外接:USB安全密钥",
+  "允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
+  "仅用于开发环境,生产环境应使用 HTTPS": "仅用于开发环境,生产环境应使用 HTTPS",
+  "允许的 Origins": "允许的 Origins",
+  "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署",
+  "输入 Origin 后回车,如:https://example.com": "输入 Origin 后回车,如:https://example.com",
+  "保存 Passkey 设置": "保存 Passkey 设置",
+  "Passkey 注册成功": "Passkey 注册成功",
+  "Passkey 注册失败,请重试": "Passkey 注册失败,请重试",
+  "已取消 Passkey 注册": "已取消 Passkey 注册",
+  "Passkey 已解绑": "Passkey 已解绑",
+  "操作失败,请重试": "操作失败,请重试",
+  "重置 Passkey": "重置 Passkey",
+  "重置 2FA": "重置 2FA",
+  "确认重置 Passkey": "确认重置 Passkey",
+  "确认重置两步验证": "确认重置两步验证",
+  "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。",
+  "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
+  "目标用户:{{username}}": "目标用户:{{username}}",
+  "Passkey 已重置": "Passkey 已重置",
+  "二步验证已重置": "二步验证已重置",
+  "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
+  "确认解绑 Passkey": "确认解绑 Passkey",
+  "解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
+  "确认解绑": "确认解绑"
 }

+ 217 - 0
web/src/services/secureVerification.js

@@ -0,0 +1,217 @@
+/*
+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
+*/
+
+import { API, showError } from '../helpers';
+import {
+  prepareCredentialRequestOptions,
+  buildAssertionResult,
+  isPasskeySupported
+} from '../helpers/passkey';
+
+/**
+ * 通用安全验证服务
+ * 验证状态完全由后端 Session 控制,前端不存储任何状态
+ */
+export class SecureVerificationService {
+  /**
+   * 检查用户可用的验证方式
+   * @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
+   */
+  static async checkAvailableVerificationMethods() {
+    try {
+      const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
+        API.get('/api/user/2fa/status'),
+        API.get('/api/user/passkey'),
+        isPasskeySupported()
+      ]);
+
+      console.log('=== DEBUGGING VERIFICATION METHODS ===');
+      console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2));
+      console.log('Passkey Response:', JSON.stringify(passkeyResponse, null, 2));
+      
+      const has2FA = twoFAResponse.data?.success && twoFAResponse.data?.data?.enabled === true;
+      const hasPasskey = passkeyResponse.data?.success && passkeyResponse.data?.data?.enabled === true;
+      
+      console.log('has2FA calculation:', {
+        success: twoFAResponse.data?.success,
+        dataExists: !!twoFAResponse.data?.data,
+        enabled: twoFAResponse.data?.data?.enabled,
+        result: has2FA
+      });
+      
+      console.log('hasPasskey calculation:', {
+        success: passkeyResponse.data?.success,
+        dataExists: !!passkeyResponse.data?.data,
+        enabled: passkeyResponse.data?.data?.enabled,
+        result: hasPasskey
+      });
+
+      const result = {
+        has2FA,
+        hasPasskey,
+        passkeySupported
+      };
+      
+      return result;
+    } catch (error) {
+      console.error('Failed to check verification methods:', error);
+      return {
+        has2FA: false,
+        hasPasskey: false,
+        passkeySupported: false
+      };
+    }
+  }
+
+  /**
+   * 执行2FA验证
+   * @param {string} code - 验证码
+   * @returns {Promise<void>}
+   */
+  static async verify2FA(code) {
+    if (!code?.trim()) {
+      throw new Error('请输入验证码或备用码');
+    }
+
+    // 调用通用验证 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验证
+   * @returns {Promise<void>}
+   */
+  static async verifyPasskey() {
+    try {
+      // 开始Passkey验证
+      const beginResponse = await API.post('/api/user/passkey/verify/begin');
+      if (!beginResponse.data?.success) {
+        throw new Error(beginResponse.data?.message || '开始验证失败');
+      }
+
+      // 准备WebAuthn选项
+      const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
+
+      // 执行WebAuthn验证
+      const credential = await navigator.credentials.get({ publicKey });
+      if (!credential) {
+        throw new Error('Passkey 验证被取消');
+      }
+
+      // 构建验证结果
+      const assertionResult = buildAssertionResult(credential);
+
+      // 完成验证
+      const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
+      if (!finishResponse.data?.success) {
+        throw new Error(finishResponse.data?.message || '验证失败');
+      }
+
+      // 调用通用验证 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 验证被取消或超时');
+      } else if (error.name === 'InvalidStateError') {
+        throw new Error('Passkey 验证状态无效');
+      } else {
+        throw error;
+      }
+    }
+  }
+
+  /**
+   * 通用验证方法,根据验证类型执行相应的验证流程
+   * @param {string} method - 验证方式: '2fa' | 'passkey'
+   * @param {string} code - 2FA验证码(当method为'2fa'时必需)
+   * @returns {Promise<void>}
+   */
+  static async verify(method, code = '') {
+    switch (method) {
+      case '2fa':
+        return await this.verify2FA(code);
+      case 'passkey':
+        return await this.verifyPasskey();
+      default:
+        throw new Error(`不支持的验证方式: ${method}`);
+    }
+  }
+}
+
+/**
+ * 预设的API调用函数工厂
+ */
+export const createApiCalls = {
+  /**
+   * 创建查看渠道密钥的API调用
+   * @param {number} channelId - 渠道ID
+   */
+  viewChannelKey: (channelId) => async () => {
+    // 新系统中,验证已通过中间件处理,直接调用 API 即可
+    const response = await API.post(`/api/channel/${channelId}/key`, {});
+    return response.data;
+  },
+
+  /**
+   * 创建自定义API调用
+   * @param {string} url - API URL
+   * @param {string} method - HTTP方法,默认为 'POST'
+   * @param {Object} extraData - 额外的请求数据
+   */
+  custom: (url, method = 'POST', extraData = {}) => async () => {
+    // 新系统中,验证已通过中间件处理
+    const data = extraData;
+
+    let response;
+    switch (method.toUpperCase()) {
+      case 'GET':
+        response = await API.get(url, { params: data });
+        break;
+      case 'POST':
+        response = await API.post(url, data);
+        break;
+      case 'PUT':
+        response = await API.put(url, data);
+        break;
+      case 'DELETE':
+        response = await API.delete(url, { data });
+        break;
+      default:
+        throw new Error(`不支持的HTTP方法: ${method}`);
+    }
+    return response.data;
+  }
+};