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 && (
+ }
+ onClick={handlePasskeyLogin}
+ loading={passkeyLoading}
+ >
+ {t('使用 Passkey 登录')}
+
+ )}
+