diff --git a/drivers/cloudreve_v4/util.go b/drivers/cloudreve_v4/util.go index f8fe5f269..43b8da925 100644 --- a/drivers/cloudreve_v4/util.go +++ b/drivers/cloudreve_v4/util.go @@ -143,7 +143,7 @@ func (d *CloudreveV4) login() error { return errors.New("password not enabled") } if prepareLogin.WebauthnEnabled { - return errors.New("webauthn not support") + return errors.New("passkey not support") } for range 5 { err = d.doLogin(siteConfig.LoginCaptcha) diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 7bff851de..6568c6181 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -172,7 +172,6 @@ func InitialSettings() []model.SettingItem { {Key: conf.FilenameCharMapping, Value: `{"/": "|"}`, Type: conf.TypeText, Group: model.GLOBAL}, {Key: conf.ForwardDirectLinkParams, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL}, {Key: conf.IgnoreDirectLinkParams, Value: "sign,openlist_ts,raw", Type: conf.TypeString, Group: model.GLOBAL}, - {Key: conf.WebauthnLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, {Key: conf.SharePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, {Key: conf.ShareArchivePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC}, {Key: conf.ShareForceProxy, Value: "true", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE}, diff --git a/internal/bootstrap/patch/v3_32_0/update_authn.go b/internal/bootstrap/patch/v3_32_0/update_authn.go index 721c3f582..0e1d72d21 100644 --- a/internal/bootstrap/patch/v3_32_0/update_authn.go +++ b/internal/bootstrap/patch/v3_32_0/update_authn.go @@ -7,7 +7,7 @@ import ( ) // UpdateAuthnForOldVersion updates users' authn -// First published: bdfc159 fix: webauthn logspam (#6181) by itsHenry +// First published: bdfc159 fix: passkey logspam (#6181) by itsHenry func UpdateAuthnForOldVersion() { users, _, err := op.GetUsers(1, -1) if err != nil { diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849c..a83764840 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -51,7 +51,6 @@ const ( FilenameCharMapping = "filename_char_mapping" ForwardDirectLinkParams = "forward_direct_link_params" IgnoreDirectLinkParams = "ignore_direct_link_params" - WebauthnLoginEnabled = "webauthn_login_enabled" SharePreview = "share_preview" ShareArchivePreview = "share_archive_preview" ShareForceProxy = "share_force_proxy" diff --git a/internal/db/user.go b/internal/db/user.go index 4b9c67ece..aa3b6ef5f 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -68,13 +68,18 @@ func UpdateAuthn(userID uint, authn string) error { return db.Model(&model.User{ID: userID}).Update("authn", authn).Error } -func RegisterAuthn(u *model.User, credential *webauthn.Credential) error { +func RegisterAuthn(u *model.User, credential *webauthn.Credential, residentKey, creatorIP, creatorUA string) error { if u == nil { return errors.New("user is nil") } - exists := u.WebAuthnCredentials() + exists := u.WebAuthnCredentialRecords() if credential != nil { - exists = append(exists, *credential) + exists = append(exists, model.WebAuthnCredentialRecord{ + Credential: *credential, + ResidentKey: residentKey, + CreatorIP: creatorIP, + CreatorUA: creatorUA, + }) } res, err := utils.Json.Marshal(exists) if err != nil { @@ -84,9 +89,9 @@ func RegisterAuthn(u *model.User, credential *webauthn.Credential) error { } func RemoveAuthn(u *model.User, id string) error { - exists := u.WebAuthnCredentials() + exists := u.WebAuthnCredentialRecords() for i := 0; i < len(exists); i++ { - idEncoded := base64.StdEncoding.EncodeToString(exists[i].ID) + idEncoded := base64.StdEncoding.EncodeToString(exists[i].Credential.ID) if idEncoded == id { exists[len(exists)-1], exists[i] = exists[i], exists[len(exists)-1] exists = exists[:len(exists)-1] @@ -100,3 +105,17 @@ func RemoveAuthn(u *model.User, id string) error { } return UpdateAuthn(u.ID, string(res)) } + +func HasLegacyAuthnCredentials() (bool, error) { + var users []model.User + err := db.Select("id", "authn").Where("authn <> '' AND authn <> '[]'").Find(&users).Error + if err != nil { + return false, err + } + for i := range users { + if users[i].HasLegacyWebAuthnCredential() { + return true, nil + } + } + return false, nil +} diff --git a/internal/model/user.go b/internal/model/user.go index 3bad4ebb9..1adc727ea 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -10,6 +10,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/OpenListTeam/OpenList/v4/pkg/utils/random" "github.com/OpenListTeam/go-cache" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/pkg/errors" ) @@ -37,6 +38,13 @@ var ( DefaultMaxAuthRetries = 5 ) +type WebAuthnCredentialRecord struct { + Credential webauthn.Credential `json:"credential"` + ResidentKey string `json:"residentKey,omitempty"` + CreatorIP string `json:"creatorIP,omitempty"` + CreatorUA string `json:"creatorUA,omitempty"` +} + type User struct { ID uint `json:"id" gorm:"primaryKey"` // unique key Username string `json:"username" gorm:"unique" binding:"required"` // username @@ -250,12 +258,57 @@ func (u *User) WebAuthnDisplayName() string { } func (u *User) WebAuthnCredentials() []webauthn.Credential { - var res []webauthn.Credential - err := json.Unmarshal([]byte(u.Authn), &res) + records := u.WebAuthnCredentialRecords() + res := make([]webauthn.Credential, 0, len(records)) + for i := range records { + res = append(res, records[i].Credential) + } + return res +} + +func (u *User) WebAuthnCredentialRecords() []WebAuthnCredentialRecord { + var records []WebAuthnCredentialRecord + err := json.Unmarshal([]byte(u.Authn), &records) + if err == nil { + recordParsed := false + for i := range records { + if len(records[i].Credential.ID) > 0 { + recordParsed = true + if records[i].ResidentKey == "" { + records[i].ResidentKey = string(protocol.ResidentKeyRequirementDiscouraged) + } + } + } + if recordParsed || u.Authn == "[]" || u.Authn == "" { + return records + } + } + + var credentials []webauthn.Credential + err = json.Unmarshal([]byte(u.Authn), &credentials) if err != nil { fmt.Println(err) + return nil } - return res + + records = make([]WebAuthnCredentialRecord, 0, len(credentials)) + for i := range credentials { + records = append(records, WebAuthnCredentialRecord{ + Credential: credentials[i], + ResidentKey: string(protocol.ResidentKeyRequirementDiscouraged), + }) + } + return records +} + +func (u *User) HasLegacyWebAuthnCredential() bool { + records := u.WebAuthnCredentialRecords() + for i := range records { + if records[i].ResidentKey == string(protocol.ResidentKeyRequirementDiscouraged) { + return true + } + } + return false } func (u *User) WebAuthnIcon() string { diff --git a/server/handles/webauthn.go b/server/handles/webauthn.go index c7ad4edfe..c7d81cf98 100644 --- a/server/handles/webauthn.go +++ b/server/handles/webauthn.go @@ -11,7 +11,6 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/db" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" - "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" @@ -19,34 +18,52 @@ import ( ) func BeginAuthnLogin(c *gin.Context) { - enabled := setting.GetBool(conf.WebauthnLoginEnabled) - if !enabled { - common.ErrorStrResp(c, "WebAuthn is not enabled", 403) - return - } authnInstance, err := authn.NewAuthnInstance(c) if err != nil { common.ErrorResp(c, err, 400) return } - + allowCredentials := c.DefaultQuery("allowCredentials", "") + username := c.Query("username") var ( - options *protocol.CredentialAssertion - sessionData *webauthn.SessionData + options *protocol.CredentialAssertion + sessionData *webauthn.SessionData + requireUsername bool ) - if username := c.Query("username"); username != "" { - var user *model.User - user, err = db.GetUserByName(username) - if err == nil { - options, sessionData, err = authnInstance.BeginLogin(user) + switch allowCredentials { + case "yes": + requireUsername = true + if username != "" { + var user *model.User + user, err = db.GetUserByName(username) + if err == nil { + options, sessionData, err = authnInstance.BeginLogin(user) + } } - } else { // client-side discoverable login + case "no": options, sessionData, err = authnInstance.BeginDiscoverableLogin() + default: + if username != "" { + var user *model.User + user, err = db.GetUserByName(username) + if err == nil { + options, sessionData, err = authnInstance.BeginLogin(user) + } + } else { // client-side discoverable login + requireUsername, err = db.HasLegacyAuthnCredentials() + if err == nil && !requireUsername { + options, sessionData, err = authnInstance.BeginDiscoverableLogin() + } + } } if err != nil { common.ErrorResp(c, err, 400) return } + if requireUsername && username == "" { + common.SuccessResp(c, gin.H{"require_username": true}) + return + } val, err := json.Marshal(sessionData) if err != nil { @@ -54,17 +71,22 @@ func BeginAuthnLogin(c *gin.Context) { return } common.SuccessResp(c, gin.H{ - "options": options, - "session": val, + "options": options, + "session": val, + "require_username": requireUsername, }) } -func FinishAuthnLogin(c *gin.Context) { - enabled := setting.GetBool(conf.WebauthnLoginEnabled) - if !enabled { - common.ErrorStrResp(c, "WebAuthn is not enabled", 403) +func LegacyAuthnStatus(c *gin.Context) { + hasLegacy, err := db.HasLegacyAuthnCredentials() + if err != nil { + common.ErrorResp(c, err, 400) return } + common.SuccessResp(c, gin.H{"has_legacy": hasLegacy}) +} + +func FinishAuthnLogin(c *gin.Context) { authnInstance, err := authn.NewAuthnInstance(c) if err != nil { common.ErrorResp(c, err, 400) @@ -120,19 +142,21 @@ func FinishAuthnLogin(c *gin.Context) { } func BeginAuthnRegistration(c *gin.Context) { - enabled := setting.GetBool(conf.WebauthnLoginEnabled) - if !enabled { - common.ErrorStrResp(c, "WebAuthn is not enabled", 403) + user := c.Request.Context().Value(conf.UserKey).(*model.User) + if user.HasLegacyWebAuthnCredential() && c.Query("upgrade") != "yes" { + common.ErrorStrResp(c, "legacy security key detected, please upgrade or delete it first", 400) return } - user := c.Request.Context().Value(conf.UserKey).(*model.User) authnInstance, err := authn.NewAuthnInstance(c) if err != nil { common.ErrorResp(c, err, 400) } - options, sessionData, err := authnInstance.BeginRegistration(user) + options, sessionData, err := authnInstance.BeginRegistration( + user, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + ) if err != nil { common.ErrorResp(c, err, 400) @@ -150,11 +174,6 @@ func BeginAuthnRegistration(c *gin.Context) { } func FinishAuthnRegistration(c *gin.Context) { - enabled := setting.GetBool(conf.WebauthnLoginEnabled) - if !enabled { - common.ErrorStrResp(c, "WebAuthn is not enabled", 403) - return - } user := c.Request.Context().Value(conf.UserKey).(*model.User) sessionDataString := c.GetHeader("Session") @@ -182,7 +201,13 @@ func FinishAuthnRegistration(c *gin.Context) { common.ErrorResp(c, err, 400) return } - err = db.RegisterAuthn(user, credential) + err = db.RegisterAuthn( + user, + credential, + string(protocol.ResidentKeyRequirementRequired), + c.ClientIP(), + c.Request.UserAgent(), + ) if err != nil { common.ErrorResp(c, err, 400) return @@ -220,17 +245,35 @@ func DeleteAuthnLogin(c *gin.Context) { } func GetAuthnCredentials(c *gin.Context) { - type WebAuthnCredentials struct { + type PasskeyCredentials struct { ID []byte `json:"id"` FingerPrint string `json:"fingerprint"` + CreatorIP string `json:"creator_ip"` + CreatorUA string `json:"creator_ua"` + IsLegacy bool `json:"is_legacy"` } user := c.Request.Context().Value(conf.UserKey).(*model.User) credentials := user.WebAuthnCredentials() - res := make([]WebAuthnCredentials, 0, len(credentials)) + records := user.WebAuthnCredentialRecords() + res := make([]PasskeyCredentials, 0, len(credentials)) for _, v := range credentials { - credential := WebAuthnCredentials{ + var creatorIP string + var creatorUA string + var isLegacy bool + for i := range records { + if string(records[i].Credential.ID) == string(v.ID) { + creatorIP = records[i].CreatorIP + creatorUA = records[i].CreatorUA + isLegacy = records[i].ResidentKey == string(protocol.ResidentKeyRequirementDiscouraged) + break + } + } + credential := PasskeyCredentials{ ID: v.ID, FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID), + CreatorIP: creatorIP, + CreatorUA: creatorUA, + IsLegacy: isLegacy, } res = append(res, credential) } diff --git a/server/router.go b/server/router.go index 57d1166ae..f61e7f7c9 100644 --- a/server/router.go +++ b/server/router.go @@ -67,7 +67,7 @@ func Init(e *gin.Engine) { api := g.Group("/api") auth := api.Group("", middlewares.Auth(false)) - webauthn := api.Group("/authn", middlewares.Authn) + authn := api.Group("/authn", middlewares.Authn) api.POST("/auth/login", handles.Login) api.POST("/auth/login/hash", handles.LoginHash) @@ -87,13 +87,14 @@ func Init(e *gin.Engine) { api.GET("/auth/get_sso_id", handles.SSOLoginCallback) api.GET("/auth/sso_get_token", handles.SSOLoginCallback) - // webauthn - api.GET("/authn/webauthn_begin_login", handles.BeginAuthnLogin) - api.POST("/authn/webauthn_finish_login", handles.FinishAuthnLogin) - webauthn.GET("/webauthn_begin_registration", handles.BeginAuthnRegistration) - webauthn.POST("/webauthn_finish_registration", handles.FinishAuthnRegistration) - webauthn.POST("/delete_authn", handles.DeleteAuthnLogin) - webauthn.GET("/getcredentials", handles.GetAuthnCredentials) + // passkey + api.GET("/authn/passkey_begin_login", handles.BeginAuthnLogin) + api.GET("/authn/passkey_legacy_status", handles.LegacyAuthnStatus) + api.POST("/authn/passkey_finish_login", handles.FinishAuthnLogin) + authn.GET("/passkey_begin_registration", handles.BeginAuthnRegistration) + authn.POST("/passkey_finish_registration", handles.FinishAuthnRegistration) + authn.POST("/delete_authn", handles.DeleteAuthnLogin) + authn.GET("/getcredentials", handles.GetAuthnCredentials) // no need auth public := api.Group("/public")