-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(auth)!: migrate WebAuthn to Passkey. #2161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+110
to
+119
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | |
| } | |
| } | |
| rows, err := db.Model(&model.User{}). | |
| Select("id", "authn"). | |
| Where("authn <> '' AND authn <> '[]'"). | |
| Rows() | |
| if err != nil { | |
| return false, err | |
| } | |
| defer rows.Close() | |
| for rows.Next() { | |
| var u model.User | |
| if err := db.ScanRows(rows, &u); err != nil { | |
| return false, err | |
| } | |
| if u.HasLegacyWebAuthnCredential() { | |
| return true, nil | |
| } | |
| } | |
| if err := rows.Err(); err != nil { | |
| return false, err | |
| } |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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 | ||||
| } | ||||
| } | ||||
|
Comment on lines
+269
to
+285
|
||||
|
|
||||
| var credentials []webauthn.Credential | ||||
| err = json.Unmarshal([]byte(u.Authn), &credentials) | ||||
| if err != nil { | ||||
| fmt.Println(err) | ||||
|
||||
| fmt.Println(err) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,60 +11,82 @@ 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" | ||
| "github.com/go-webauthn/webauthn/webauthn" | ||
| ) | ||
|
|
||
| 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() | ||
| } | ||
|
Comment on lines
+52
to
+56
|
||
| } | ||
| } | ||
| 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 { | ||
| common.ErrorResp(c, err, 400) | ||
| 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}) | ||
|
Comment on lines
+80
to
+86
|
||
| } | ||
|
|
||
| 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) | ||
| } | ||
|
Comment on lines
151
to
154
|
||
|
|
||
| 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 | ||
| } | ||
|
Comment on lines
256
to
+269
|
||
| } | ||
| credential := PasskeyCredentials{ | ||
| ID: v.ID, | ||
| FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID), | ||
| CreatorIP: creatorIP, | ||
| CreatorUA: creatorUA, | ||
| IsLegacy: isLegacy, | ||
| } | ||
| res = append(res, credential) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment references a specific historical commit hash (bdfc159). Renaming the original commit message from "webauthn" to "passkey" can make it harder to trace the exact upstream change. Consider keeping the original message or mentioning both terms (e.g., "webauthn/passkey") for accuracy.