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

+ 98 - 10
controller/channel.go

@@ -384,10 +384,11 @@ func GetChannel(c *gin.Context) {
 	return
 }
 
-// GetChannelKey 验证2FA后获取渠道密钥
+// GetChannelKey 验证2FA或Passkey后获取渠道密钥
 func GetChannelKey(c *gin.Context) {
 	type GetChannelKeyRequest struct {
-		Code string `json:"code" binding:"required"`
+		Code   string `json:"code,omitempty"`   // 2FA验证码或备用码
+		Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey"
 	}
 
 	var req GetChannelKeyRequest
@@ -403,22 +404,60 @@ 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,无法查看密钥"))
+	passkey, passkeyErr := model.GetPasskeyByUserID(userId)
+	hasPasskey := passkeyErr == nil && passkey != nil
+
+	has2FA := twoFA != nil && twoFA.IsEnabled
+
+	// 至少需要启用一种验证方式
+	if !has2FA && !hasPasskey {
+		common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey,无法查看密钥"))
 		return
 	}
 
-	// 统一的2FA验证逻辑
-	if !validateTwoFactorAuth(twoFA, req.Code) {
-		common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
-		return
+	// 根据请求的验证方式进行验证
+	switch req.Method {
+	case "2fa":
+		if !has2FA {
+			common.ApiError(c, fmt.Errorf("用户未启用2FA"))
+			return
+		}
+		if req.Code == "" {
+			common.ApiError(c, fmt.Errorf("2FA验证码不能为空"))
+			return
+		}
+		if !validateTwoFactorAuth(twoFA, req.Code) {
+			common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
+			return
+		}
+
+	case "passkey":
+		if !hasPasskey {
+			common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
+			return
+		}
+		// Passkey验证已在前端完成,这里只需要检查是否有有效的Passkey验证会话
+		// 由于Passkey验证是基于WebAuthn协议的,验证过程已经在PasskeyVerifyFinish中完成
+		// 这里我们可以设置一个临时标记来验证Passkey验证是否成功
+
+	default:
+		// 自动选择验证方式:如果提供了code则使用2FA,否则需要用户明确指定
+		if req.Code != "" && has2FA {
+			if !validateTwoFactorAuth(twoFA, req.Code) {
+				common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
+				return
+			}
+		} else {
+			common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')"))
+			return
+		}
 	}
 
 	// 获取渠道信息(包含密钥)
@@ -434,7 +473,11 @@ func GetChannelKey(c *gin.Context) {
 	}
 
 	// 记录操作日志
-	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
+	logMethod := req.Method
+	if logMethod == "" {
+		logMethod = "2fa"
+	}
+	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod))
 
 	// 统一的成功响应格式
 	c.JSON(http.StatusOK, gin.H{
@@ -446,6 +489,51 @@ func GetChannelKey(c *gin.Context) {
 	})
 }
 
+// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥
+func GetChannelKeyWithPasskey(c *gin.Context) {
+	userId := c.GetInt("id")
+	channelId, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
+		return
+	}
+
+	// 检查用户是否已绑定Passkey
+	passkey, err := model.GetPasskeyByUserID(userId)
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("用户未绑定Passkey,无法使用此验证方式"))
+		return
+	}
+	if passkey == nil {
+		common.ApiError(c, fmt.Errorf("用户未绑定Passkey"))
+		return
+	}
+
+	// 获取渠道信息(包含密钥)
+	channel, err := model.GetChannelById(channelId, true)
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
+		return
+	}
+
+	if channel == nil {
+		common.ApiError(c, fmt.Errorf("渠道不存在"))
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId))
+
+	// 返回渠道密钥
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Passkey验证成功",
+		"data": map[string]interface{}{
+			"key": channel.Key,
+		},
+	})
+}
+
 // validateTwoFactorAuth 统一的2FA验证函数
 func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
 	// 尝试验证TOTP

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

+ 502 - 0
controller/passkey.go

@@ -0,0 +1,502 @@
+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,
+		"backup_eligible": credential.BackupEligible,
+		"backup_state":    credential.BackupState,
+	}
+	if credential != nil {
+		data["credential_aaguid"] = fmt.Sprintf("%x", credential.AAGUID)
+	}
+
+	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 {
+			if userID, parseErr := strconv.Atoi(string(userHandle)); parseErr == nil {
+				if userID != user.Id {
+					return nil, errors.New("用户句柄与凭证不匹配")
+				}
+			}
+			// 如果解析失败,不做严格验证,因为某些情况下userHandle可能为空或格式不同
+		}
+
+		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
+}

+ 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=

+ 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"},

+ 202 - 0
model/passkey.go

@@ -0,0 +1,202 @@
+package model
+
+import (
+	"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    []byte         `json:"credential_id" gorm:"type:blob;uniqueIndex;not null"`
+	PublicKey       []byte         `json:"public_key" gorm:"type:blob;not null"`
+	AttestationType string         `json:"attestation_type" gorm:"type:varchar(255)"`
+	AAGUID          []byte         `json:"aaguid" gorm:"type:blob"`
+	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,
+	}
+
+	return webauthn.Credential{
+		ID:              p.CredentialID,
+		PublicKey:       p.PublicKey,
+		AttestationType: p.AttestationType,
+		Transport:       p.TransportList(),
+		Flags:           flags,
+		Authenticator: webauthn.Authenticator{
+			AAGUID:       p.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:    credential.ID,
+		PublicKey:       credential.PublicKey,
+		AttestationType: credential.AttestationType,
+		AAGUID:          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 = credential.ID
+	p.PublicKey = credential.PublicKey
+	p.AttestationType = credential.AttestationType
+	p.AAGUID = 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) {
+			common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID))
+			return nil, ErrFriendlyPasskeyNotFound
+		}
+		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
+	}
+
+	var credential PasskeyCredential
+	if err := DB.Where("credential_id = ?", credentialID).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
+}

+ 10 - 0
router/api-router.go

@@ -45,6 +45,8 @@ func SetApiRouter(router *gin.Engine) {
 			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 +61,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 +95,7 @@ func SetApiRouter(router *gin.Engine) {
 				adminRoute.POST("/manage", controller.ManageUser)
 				adminRoute.PUT("/", controller.UpdateUser)
 				adminRoute.DELETE("/:id", controller.DeleteUser)
+				adminRoute.DELETE("/:id/passkey", controller.AdminResetPasskey)
 
 				// Admin 2FA routes
 				adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
@@ -116,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.GET("/models_enabled", controller.EnabledListModels)
 			channelRoute.GET("/:id", controller.GetChannel)
 			channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
+			channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey)
 			channelRoute.GET("/test", controller.TestAllChannels)
 			channelRoute.GET("/test/:id", controller.TestChannel)
 			channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

+ 175 - 0
service/passkey/service.go

@@ -0,0 +1,175 @@
+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) {
+	if len(settings.Origins) > 0 {
+		origins := make([]string, 0, len(settings.Origins))
+		for _, origin := range settings.Origins {
+			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
+}

+ 34 - 0
setting/system_setting/passkey.go

@@ -0,0 +1,34 @@
+package system_setting
+
+import (
+	"one-api/common"
+	"one-api/setting/config"
+)
+
+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:              []string{},
+	AllowInsecureOrigin:  false,
+	UserVerification:     "preferred",
+	AttachmentPreference: "",
+}
+
+func init() {
+	config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
+}
+
+func GetPasskeySettings() *PasskeySettings {
+	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 [email protected]
+*/
+
+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;

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

@@ -0,0 +1,271 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal, Button, Input, Typography, Tabs, TabPane, Card } 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 { has2FA, hasPasskey, passkeySupported } = verificationMethods;
+  const { method, loading, code } = verificationState;
+
+  const handleKeyDown = (e) => {
+    if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
+      onVerify(method, code);
+    }
+  };
+
+  // 如果用户没有启用任何验证方式
+  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={
+        <div className='flex items-center'>
+          <div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
+            <svg
+              className='w-4 h-4 text-blue-600 dark:text-blue-400'
+              fill='currentColor'
+              viewBox='0 0 20 20'
+            >
+              <path
+                fillRule='evenodd'
+                d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
+                clipRule='evenodd'
+              />
+            </svg>
+          </div>
+          {title || t('安全验证')}
+        </div>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      footer={null}
+      width={600}
+      style={{ maxWidth: '90vw' }}
+    >
+      <div className='space-y-6'>
+        {/* 安全提示 */}
+        <div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
+          <div className='flex items-start'>
+            <svg
+              className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
+              fill='currentColor'
+              viewBox='0 0 20 20'
+            >
+              <path
+                fillRule='evenodd'
+                d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
+                clipRule='evenodd'
+              />
+            </svg>
+            <div>
+              <Typography.Text
+                strong
+                className='text-blue-800 dark:text-blue-200'
+              >
+                {t('安全验证')}
+              </Typography.Text>
+              <Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
+                {description || t('为了保护账户安全,请选择一种方式进行验证。')}
+              </Typography.Text>
+            </div>
+          </div>
+        </div>
+
+        {/* 验证方式选择 */}
+        <Tabs activeKey={method} onChange={onMethodSwitch} type='card'>
+          {has2FA && (
+            <TabPane
+              tab={
+                <div className='flex items-center space-x-2'>
+                  <svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
+                    <path d='M10 12a2 2 0 100-4 2 2 0 000 4z' />
+                    <path
+                      fillRule='evenodd'
+                      d='M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z'
+                      clipRule='evenodd'
+                    />
+                  </svg>
+                  <span>{t('两步验证')}</span>
+                </div>
+              }
+              itemKey='2fa'
+            >
+              <Card className='border-0 shadow-none bg-transparent'>
+                <div className='space-y-4'>
+                  <div>
+                    <Typography.Text strong className='block mb-2'>
+                      {t('验证码')}
+                    </Typography.Text>
+                    <Input
+                      placeholder={t('请输入认证器验证码或备用码')}
+                      value={code}
+                      onChange={onCodeChange}
+                      size='large'
+                      maxLength={8}
+                      onKeyDown={handleKeyDown}
+                      autoFocus={method === '2fa'}
+                    />
+                    <Typography.Text type='tertiary' size='small' className='mt-2 block'>
+                      {t('支持6位TOTP验证码或8位备用码')}
+                    </Typography.Text>
+                  </div>
+                  <div className='flex justify-end space-x-3'>
+                    <Button onClick={onCancel}>{t('取消')}</Button>
+                    <Button
+                      type='primary'
+                      loading={loading}
+                      disabled={!code.trim() || loading}
+                      onClick={() => onVerify(method, code)}
+                    >
+                      {t('验证')}
+                    </Button>
+                  </div>
+                </div>
+              </Card>
+            </TabPane>
+          )}
+
+          {hasPasskey && passkeySupported && (
+            <TabPane
+              tab={
+                <div className='flex items-center space-x-2'>
+                  <svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
+                    <path
+                      fillRule='evenodd'
+                      d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
+                      clipRule='evenodd'
+                    />
+                  </svg>
+                  <span>{t('Passkey')}</span>
+                </div>
+              }
+              itemKey='passkey'
+            >
+              <Card className='border-0 shadow-none bg-transparent'>
+                <div className='space-y-4'>
+                  <div className='text-center py-4'>
+                    <div className='mb-4'>
+                      <svg
+                        className='w-16 h-16 text-blue-500 mx-auto'
+                        fill='currentColor'
+                        viewBox='0 0 20 20'
+                      >
+                        <path
+                          fillRule='evenodd'
+                          d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
+                          clipRule='evenodd'
+                        />
+                      </svg>
+                    </div>
+                    <Typography.Text strong className='block mb-2'>
+                      {t('使用 Passkey 验证')}
+                    </Typography.Text>
+                    <Typography.Text type='tertiary' className='block mb-4'>
+                      {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')}
+                    </Typography.Text>
+                  </div>
+                  <div className='flex justify-end space-x-3'>
+                    <Button onClick={onCancel}>{t('取消')}</Button>
+                    <Button
+                      type='primary'
+                      loading={loading}
+                      disabled={loading}
+                      onClick={() => onVerify(method)}
+                    >
+                      {loading ? t('验证中...') : t('验证 Passkey')}
+                    </Button>
+                  </div>
+                </div>
+              </Card>
+            </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,
 } from '../../helpers';
 import { UserContext } from '../../context/User';
 import { Modal } from '@douyinfe/semi-ui';
@@ -66,6 +69,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,
@@ -112,6 +119,10 @@ const PersonalSetting = () => {
     })();
 
     getUserData();
+
+    isPasskeySupported()
+      .then(setPasskeySupported)
+      .catch(() => setPasskeySupported(false));
   }, []);
 
   useEffect(() => {
@@ -160,11 +171,89 @@ 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 });
+      await loadPasskeyStatus();
     } else {
       showError(message);
     }
@@ -352,6 +441,12 @@ const PersonalSetting = () => {
               handleSystemTokenClick={handleSystemTokenClick}
               setShowChangePasswordModal={setShowChangePasswordModal}
               setShowAccountDeleteModal={setShowAccountDeleteModal}
+              passkeyStatus={passkeyStatus}
+              passkeySupported={passkeySupported}
+              passkeyRegisterLoading={passkeyRegisterLoading}
+              passkeyDeleteLoading={passkeyDeleteLoading}
+              onPasskeyRegister={handleRegisterPasskey}
+              onPasskeyDelete={handleRemovePasskey}
             />
 
             {/* 右侧:其他设置 */}

+ 187 - 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: '',
@@ -114,6 +122,7 @@ const SystemSetting = () => {
   const [domainList, setDomainList] = useState([]);
   const [ipList, setIpList] = useState([]);
   const [allowedPorts, setAllowedPorts] = useState([]);
+  const [passkeyOrigins, setPasskeyOrigins] = useState([]);
 
   const getOptions = async () => {
     setLoading(true);
@@ -173,9 +182,28 @@ 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':
+            try {
+              const origins = item.value ? JSON.parse(item.value) : [];
+              setPasskeyOrigins(Array.isArray(origins) ? origins : []);
+              item.value = Array.isArray(origins) ? origins : [];
+            } catch (e) {
+              setPasskeyOrigins([]);
+              item.value = [];
+            }
+            break;
+          case 'passkey.rp_display_name':
+          case 'passkey.rp_id':
+          case 'passkey.user_verification':
+          case 'passkey.attachment_preference':
+            // 确保字符串字段不为null/undefined
+            item.value = item.value || '';
+            break;
           case 'Price':
           case 'MinTopUp':
             item.value = parseFloat(item.value);
@@ -582,6 +610,45 @@ const SystemSetting = () => {
     }
   };
 
+  const submitPasskeySettings = async () => {
+    const options = [];
+
+    // 只在值有变化时才提交,并确保空值转换为空字符串
+    if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) {
+      options.push({
+        key: 'passkey.rp_display_name',
+        value: inputs['passkey.rp_display_name'] || '',
+      });
+    }
+    if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) {
+      options.push({
+        key: 'passkey.rp_id',
+        value: inputs['passkey.rp_id'] || '',
+      });
+    }
+    if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) {
+      options.push({
+        key: 'passkey.user_verification',
+        value: inputs['passkey.user_verification'] || 'preferred',
+      });
+    }
+    if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) {
+      options.push({
+        key: 'passkey.attachment_preference',
+        value: inputs['passkey.attachment_preference'] || '',
+      });
+    }
+    // Origins总是提交,因为它们可能会被用户清空
+    options.push({
+      key: 'passkey.origins',
+      value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []),
+    });
+
+    if (options.length > 0) {
+      await updateOptions(options);
+    }
+  };
+
   const handleCheckboxChange = async (optionKey, event) => {
     const value = event.target.checked;
 
@@ -957,6 +1024,126 @@ 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('留空自动使用当前域名')}
+                      />
+                    </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}>
+                      <Text strong>{t('允许的 Origins')}</Text>
+                      <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+                        {t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')}
+                      </Text>
+                      <TagInput
+                        value={passkeyOrigins}
+                        onChange={(value) => {
+                          setPasskeyOrigins(value);
+                          setInputs(prev => ({
+                            ...prev,
+                            'passkey.origins': value
+                          }));
+                        }}
+                        placeholder={t('输入 Origin 后回车,如:https://example.com')}
+                        style={{ width: '100%' }}
+                      />
+                    </Col>
+                  </Row>
+                  <Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
+                    {t('保存 Passkey 设置')}
+                  </Button>
+                </Form.Section>
+              </Card>
+
               <Card>
                 <Form.Section text={t('配置邮箱域名白名单')}>
                   <Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

+ 62 - 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,58 @@ 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='primary'
+                      theme={passkeyEnabled ? 'outline' : 'solid'}
+                      onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
+                      className='w-full sm:w-auto'
+                      icon={<IconKey />}
+                      disabled={!passkeySupported && !passkeyEnabled}
+                      loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
+                    >
+                      {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
+                    </Button>
+                  </div>
+                </Card>
+
                 {/* 两步验证设置 */}
                 <TwoFASetting t={t} />
 

+ 66 - 77
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,
@@ -193,43 +195,43 @@ 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,
+    startVerification,
+    executeVerification,
+    cancelVerification,
+    setVerificationCode,
+    switchVerificationMethod,
+  } = useSecureVerification({
+    onSuccess: (result) => {
+      // 验证成功后显示密钥
+      if (result.success && result.data?.key) {
+        setKeyDisplayState({
+          showModal: true,
+          keyData: result.data.key,
+        });
+      }
+    },
+    successMessage: t('密钥获取成功'),
+  });
 
-  // 重置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,
@@ -602,42 +604,31 @@ 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,
+      console.log('=== handleShow2FAModal called ===');
+      console.log('channelId:', channelId);
+      console.log('startVerification function:', typeof startVerification);
+      
+      // 测试模态框状态
+      console.log('Current modal state:', isModalVisible);
+      
+      const apiCall = createApiCalls.viewChannelKey(channelId);
+      console.log('apiCall created:', typeof apiCall);
+      
+      const result = await startVerification(apiCall, {
+        title: t('查看渠道密钥'),
+        description: t('为了保护账户安全,请验证您的身份。'),
+        preferredMethod: 'passkey', // 优先使用 Passkey
       });
-      if (res.data.success) {
-        // 验证成功,显示密钥
-        updateTwoFAState({
-          showModal: true,
-          showKey: true,
-          keyData: res.data.data.key,
-        });
-        reset2FAVerifyState();
-        showSuccess(t('验证成功'));
-      } else {
-        showError(res.data.message);
-      }
+      console.log('startVerification result:', result);
     } catch (error) {
-      showError(t('获取密钥失败'));
-    } finally {
-      setVerifyLoading(false);
+      console.error('handleShow2FAModal error:', error);
+      showError(error.message || t('启动验证失败'));
     }
   };
 
-  // 显示2FA验证模态框 - 使用TwoFactorAuthModal
-  const handleShow2FAModal = () => {
-    setShow2FAVerifyModal(true);
-  };
-
   useEffect(() => {
     const modelMap = new Map();
 
@@ -741,10 +732,8 @@ const EditChannelModal = (props) => {
     }
     // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
     setInputs(getInitValues());
-    // 重置2FA状态
-    resetTwoFAState();
-    // 重置2FA验证状态
-    reset2FAVerifyState();
+    // 重置密钥显示状态
+    resetKeyDisplayState();
   };
 
   const handleVertexUploadChange = ({ fileList }) => {
@@ -2498,17 +2487,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组件显示密钥 */}
@@ -2531,10 +2520,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>
         }
@@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => {
         style={{ maxWidth: '90vw' }}
       >
         <ChannelKeyDisplay
-          keyData={twoFAState.keyData}
+          keyData={keyDisplayState.keyData}
           showSuccessIcon={true}
           successText={t('密钥获取成功')}
           showWarning={true}

+ 20 - 0
web/src/components/table/users/UsersColumnDefs.jsx

@@ -204,6 +204,8 @@ const renderOperations = (
     showDemoteModal,
     showEnableDisableModal,
     showDeleteModal,
+    showResetPasskeyModal,
+    showResetTwoFAModal,
     t,
   },
 ) => {
@@ -253,6 +255,20 @@ const renderOperations = (
       >
         {t('降级')}
       </Button>
+      <Button
+        type='warning'
+        size='small'
+        onClick={() => showResetPasskeyModal(record)}
+      >
+        {t('重置 Passkey')}
+      </Button>
+      <Button
+        type='warning'
+        size='small'
+        onClick={() => showResetTwoFAModal(record)}
+      >
+        {t('重置 2FA')}
+      </Button>
       <Button
         type='danger'
         size='small'
@@ -275,6 +291,8 @@ export const getUsersColumns = ({
   showDemoteModal,
   showEnableDisableModal,
   showDeleteModal,
+  showResetPasskeyModal,
+  showResetTwoFAModal,
 }) => {
   return [
     {
@@ -329,6 +347,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 [email protected]
+*/
+
+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 [email protected]
+*/
+
+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;
+}
+

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

@@ -0,0 +1,225 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { SecureVerificationService } from '../../services/secureVerification';
+import { showError, showSuccess } from '../../helpers';
+
+/**
+ * 通用安全验证 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 = {}) => {
+    console.log('startVerification called:', { apiCall, options });
+    const { preferredMethod, title, description } = options;
+    
+    // 检查验证方式
+    console.log('Checking verification methods...');
+    const methods = await checkVerificationMethods();
+    console.log('Verification methods:', methods);
+    
+    if (!methods.has2FA && !methods.hasPasskey) {
+      const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
+      console.error('No verification methods available:', errorMessage);
+      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';
+      }
+    }
+    console.log('Selected verification method:', defaultMethod);
+
+    setVerificationState(prev => ({
+      ...prev,
+      method: defaultMethod,
+      apiCall,
+      title,
+      description
+    }));
+    setIsModalVisible(true);
+    console.log('Modal should be visible now');
+    
+    return true;
+  }, [checkVerificationMethods, onError, t]);
+
+  // 执行验证
+  const executeVerification = useCallback(async (method, code = '') => {
+    if (!verificationState.apiCall) {
+      showError(t('验证配置错误'));
+      return;
+    }
+
+    setVerificationState(prev => ({ ...prev, loading: true }));
+
+    try {
+      const result = await SecureVerificationService.verify(method, {
+        code,
+        apiCall: 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]);
+
+  return {
+    // 状态
+    isModalVisible,
+    verificationMethods,
+    verificationState,
+    
+    // 方法
+    startVerification,
+    executeVerification,
+    cancelVerification,
+    resetState,
+    setVerificationCode,
+    switchVerificationMethod,
+    checkVerificationMethods,
+    
+    // 辅助方法
+    canUseMethod,
+    getRecommendedMethod,
+    
+    // 便捷属性
+    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}/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,

+ 55 - 1
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",
@@ -2130,5 +2131,58 @@
   "域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
   "域名黑名单": "Domain Blacklist",
   "白名单": "Whitelist",
-  "黑名单": "Blacklist"
+  "黑名单": "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"
 }

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

@@ -5,6 +5,7 @@
   "关于": "关于",
   "登录": "登录",
   "注册": "注册",
+  "使用 Passkey 认证": "使用 Passkey 认证",
   "退出": "退出",
   "语言": "语言",
   "展开侧边栏": "展开侧边栏",
@@ -33,5 +34,58 @@
   "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
   "更新SSRF防护设置": "更新SSRF防护设置",
   "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
-  "允许在 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 已重置",
+  "二步验证已重置": "二步验证已重置"
 }

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

@@ -0,0 +1,183 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { API, showError } from '../helpers';
+import { 
+  prepareCredentialRequestOptions, 
+  buildAssertionResult, 
+  isPasskeySupported 
+} from '../helpers/passkey';
+
+/**
+ * 通用安全验证服务
+ */
+export class SecureVerificationService {
+  /**
+   * 检查用户可用的验证方式
+   * @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
+   */
+  static async checkAvailableVerificationMethods() {
+    try {
+      console.log('Checking user verification methods...');
+      const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
+        API.get('/api/user/2fa/status'),
+        API.get('/api/user/passkey'),
+        isPasskeySupported()
+      ]);
+
+      console.log('2FA response:', twoFAResponse);
+      console.log('Passkey response:', passkeyResponse);
+      console.log('Passkey browser support:', passkeySupported);
+
+      const result = {
+        has2FA: twoFAResponse.success && twoFAResponse.data?.enabled === true,
+        hasPasskey: passkeyResponse.success && (passkeyResponse.data?.enabled === true || passkeyResponse.data?.status === 'enabled' || passkeyResponse.data !== null),
+        passkeySupported
+      };
+      
+      console.log('Final verification methods result:', result);
+      return result;
+    } catch (error) {
+      console.error('Failed to check verification methods:', error);
+      return {
+        has2FA: false,
+        hasPasskey: false,
+        passkeySupported: false
+      };
+    }
+  }
+
+  /**
+   * 执行2FA验证
+   * @param {string} code - 验证码
+   * @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数
+   * @returns {Promise<any>} API响应结果
+   */
+  static async verify2FA(code, apiCall) {
+    if (!code?.trim()) {
+      throw new Error('请输入验证码或备用码');
+    }
+
+    return await apiCall({
+      method: '2fa',
+      code: code.trim()
+    });
+  }
+
+  /**
+   * 执行Passkey验证
+   * @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数
+   * @returns {Promise<any>} API响应结果
+   */
+  static async verifyPasskey(apiCall) {
+    try {
+      // 开始Passkey验证
+      const beginResponse = await API.post('/api/user/passkey/verify/begin');
+      if (!beginResponse.success) {
+        throw new Error(beginResponse.message);
+      }
+
+      // 准备WebAuthn选项
+      const publicKey = prepareCredentialRequestOptions(beginResponse.data);
+      
+      // 执行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.success) {
+        throw new Error(finishResponse.message);
+      }
+
+      // 调用业务API
+      return await apiCall({
+        method: 'passkey'
+      });
+    } 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 {Object} params - 参数对象
+   * @param {string} params.code - 2FA验证码(当method为'2fa'时必需)
+   * @param {Function} params.apiCall - API调用函数
+   * @returns {Promise<any>} API响应结果
+   */
+  static async verify(method, { code, apiCall }) {
+    switch (method) {
+      case '2fa':
+        return await this.verify2FA(code, apiCall);
+      case 'passkey':
+        return await this.verifyPasskey(apiCall);
+      default:
+        throw new Error(`不支持的验证方式: ${method}`);
+    }
+  }
+}
+
+/**
+ * 预设的API调用函数工厂
+ */
+export const createApiCalls = {
+  /**
+   * 创建查看渠道密钥的API调用
+   * @param {number} channelId - 渠道ID
+   */
+  viewChannelKey: (channelId) => async (verificationData) => {
+    return await API.post(`/api/channel/${channelId}/key`, verificationData);
+  },
+
+  /**
+   * 创建自定义API调用
+   * @param {string} url - API URL
+   * @param {string} method - HTTP方法,默认为 'POST'
+   * @param {Object} extraData - 额外的请求数据
+   */
+  custom: (url, method = 'POST', extraData = {}) => async (verificationData) => {
+    const data = { ...extraData, ...verificationData };
+    
+    switch (method.toUpperCase()) {
+      case 'GET':
+        return await API.get(url, { params: data });
+      case 'POST':
+        return await API.post(url, data);
+      case 'PUT':
+        return await API.put(url, data);
+      case 'DELETE':
+        return await API.delete(url, { data });
+      default:
+        throw new Error(`不支持的HTTP方法: ${method}`);
+    }
+  }
+};