diff --git a/controller/channel.go b/controller/channel.go index 5d075f3c5..542f35fd6 100644 --- a/controller/channel.go +++ b/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,21 +404,108 @@ 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("验证码或备用码错误,请重试")) + // 根据请求的验证方式进行验证 + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("2FA验证码不能为空")) + return + } + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey验证已在前端完成,这里只需要检查是否有有效的Passkey验证会话 + // 由于Passkey验证是基于WebAuthn协议的,验证过程已经在PasskeyVerifyFinish中完成 + // 这里我们可以设置一个临时标记来验证Passkey验证是否成功 + + default: + // 自动选择验证方式:如果提供了code则使用2FA,否则需要用户明确指定 + if req.Code != "" && has2FA { + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + } else { + common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')")) + return + } + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + logMethod := req.Method + if logMethod == "" { + logMethod = "2fa" + } + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod)) + + // 统一的成功响应格式 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥 +func GetChannelKeyWithPasskey(c *gin.Context) { + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 检查用户是否已绑定Passkey + passkey, err := model.GetPasskeyByUserID(userId) + if err != nil { + common.ApiError(c, fmt.Errorf("用户未绑定Passkey,无法使用此验证方式")) + return + } + if passkey == nil { + common.ApiError(c, fmt.Errorf("用户未绑定Passkey")) return } @@ -434,12 +522,12 @@ func GetChannelKey(c *gin.Context) { } // 记录操作日志 - model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId)) - // 统一的成功响应格式 + // 返回渠道密钥 c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "验证成功", + "message": "Passkey验证成功", "data": map[string]interface{}{ "key": channel.Key, }, diff --git a/controller/misc.go b/controller/misc.go index 875142ffb..07f7d3f05 100644 --- a/controller/misc.go +++ b/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, } diff --git a/controller/passkey.go b/controller/passkey.go new file mode 100644 index 000000000..3bdec8f0f --- /dev/null +++ b/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 +} diff --git a/go.mod b/go.mod index 501d966d5..66a452cee 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 189d09de4..a62b83210 100644 --- a/go.sum +++ b/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= diff --git a/model/main.go b/model/main.go index 1a38d371b..14384caf9 100644 --- a/model/main.go +++ b/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"}, diff --git a/model/passkey.go b/model/passkey.go new file mode 100644 index 000000000..092639019 --- /dev/null +++ b/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 +} diff --git a/router/api-router.go b/router/api-router.go index e16d06628..31d4ba3f8 100644 --- a/router/api-router.go +++ b/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) diff --git a/service/passkey/service.go b/service/passkey/service.go new file mode 100644 index 000000000..62befb9d3 --- /dev/null +++ b/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" +} diff --git a/service/passkey/session.go b/service/passkey/session.go new file mode 100644 index 000000000..15e619326 --- /dev/null +++ b/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 +} diff --git a/service/passkey/user.go b/service/passkey/user.go new file mode 100644 index 000000000..8b8c559f0 --- /dev/null +++ b/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 +} diff --git a/setting/system_setting/passkey.go b/setting/system_setting/passkey.go new file mode 100644 index 000000000..54746e808 --- /dev/null +++ b/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 +} diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 32087ab02..828e71786 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/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 = () => { )} + {status.passkey_login && passkeySupported && ( + + )} + {t('或')} @@ -437,6 +510,18 @@ const LoginForm = () => {
+ {status.passkey_login && passkeySupported && ( + + )}
. + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../services/secureVerification'; +import SecureVerificationModal from '../modals/SecureVerificationModal'; +import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; + +/** + * 渠道密钥查看组件使用示例 + * 展示如何使用通用安全验证系统 + */ +const ChannelKeyViewExample = ({ channelId }) => { + const { t } = useTranslation(); + const [keyData, setKeyData] = useState(''); + const [showKeyModal, setShowKeyModal] = useState(false); + + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后处理结果 + if (result.success && result.data?.key) { + setKeyData(result.data.key); + setShowKeyModal(true); + } + }, + successMessage: t('密钥获取成功'), + }); + + // 开始查看密钥流程 + const handleViewKey = async () => { + const apiCall = createApiCalls.viewChannelKey(channelId); + + await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 可以指定首选验证方式 + }); + }; + + return ( + <> + {/* 查看密钥按钮 */} + + + {/* 安全验证模态框 */} + + + {/* 密钥显示模态框 */} + setShowKeyModal(false)} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + + + + ); +}; + +export default ChannelKeyViewExample; \ No newline at end of file diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx new file mode 100644 index 000000000..46770aa74 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + {t('确定')} + } + width={500} + style={{ maxWidth: '90vw' }} + > +
+
+ + + +
+ + {t('需要安全验证')} + + + {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')} + +
+ + {t('请前往个人设置 → 安全设置进行配置。')} + +
+
+ ); + } + + return ( + +
+ + + +
+ {title || t('安全验证')} +
+ } + visible={visible} + onCancel={onCancel} + footer={null} + width={600} + style={{ maxWidth: '90vw' }} + > +
+ {/* 安全提示 */} +
+
+ + + +
+ + {t('安全验证')} + + + {description || t('为了保护账户安全,请选择一种方式进行验证。')} + +
+
+
+ + {/* 验证方式选择 */} + + {has2FA && ( + + + + + + {t('两步验证')} +
+ } + itemKey='2fa' + > + +
+
+ + {t('验证码')} + + + + {t('支持6位TOTP验证码或8位备用码')} + +
+
+ + +
+
+
+ + )} + + {hasPasskey && passkeySupported && ( + + + + + {t('Passkey')} + + } + itemKey='passkey' + > + +
+
+
+ + + +
+ + {t('使用 Passkey 验证')} + + + {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} + +
+
+ + +
+
+
+
+ )} + + + + ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 15dfbd973..19a83515d 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/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} /> {/* 右侧:其他设置 */} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index f9a2c019d..abb55301a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/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 = () => { + + + {t('用以支持基于 WebAuthn 的无密码登录注册')} + + + + + handleCheckboxChange('passkey.enabled', e) + } + > + {t('允许通过 Passkey 登录 & 注册')} + + + + + + + + + + + + + + + + + + + + + + + handleCheckboxChange('passkey.allow_insecure_origin', e) + } + > + {t('允许不安全的 Origin(HTTP)')} + + + + + + {t('允许的 Origins')} + + {t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')} + + { + setPasskeyOrigins(value); + setInputs(prev => ({ + ...prev, + 'passkey.origins': value + })); + }} + placeholder={t('输入 Origin 后回车,如:https://example.com')} + style={{ width: '100%' }} + /> + + + + + + {t('用以防止恶意用户利用临时邮箱批量注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 017e7c1e6..b5baa55e5 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/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 ( @@ -476,6 +486,58 @@ const AccountManagement = ({ + {/* Passkey 设置 */} + +
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + +
+
+ {t('最后使用时间')}:{lastUsedLabel} +
+ {/*{passkeyEnabled && (*/} + {/*
*/} + {/* {t('备份支持')}:*/} + {/* {passkeyStatus?.backup_eligible*/} + {/* ? t('支持备份')*/} + {/* : t('不支持')}*/} + {/* ,{t('备份状态')}:*/} + {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} + {/*
*/} + {/*)}*/} + {!passkeySupported && ( +
+ {t('当前设备不支持 Passkey')} +
+ )} +
+
+
+ +
+
+ {/* 两步验证设置 */} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 2eb480e7a..c049fdc20 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/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); + // 使用通用安全验证 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 updateTwoFAState = (updates) => { - setTwoFAState((prev) => ({ ...prev, ...updates })); - }; - - // 重置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)} /> - {/* 使用TwoFactorAuthModal组件进行2FA验证 */} - {/* 使用ChannelKeyDisplay组件显示密钥 */} @@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => { {t('渠道密钥信息')} } - visible={twoFAState.showModal && twoFAState.showKey} - onCancel={resetTwoFAState} + visible={keyDisplayState.showModal} + onCancel={resetKeyDisplayState} footer={ - } @@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => { style={{ maxWidth: '90vw' }} > { @@ -253,6 +255,20 @@ const renderOperations = ( > {t('降级')} + +