Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion drivers/cloudreve_v4/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion internal/bootstrap/patch/v3_32_0/update_authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 25, 2026

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.

Suggested change
// First published: bdfc159 fix: passkey logspam (#6181) by itsHenry
// First published: bdfc159 fix: webauthn/passkey logspam (#6181) by itsHenry

Copilot uses AI. Check for mistakes.
func UpdateAuthnForOldVersion() {
users, _, err := op.GetUsers(1, -1)
if err != nil {
Expand Down
1 change: 0 additions & 1 deletion internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 24 additions & 5 deletions internal/db/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand All @@ -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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HasLegacyAuthnCredentials loads every user with a non-empty authn field into memory and JSON-parses each record. This is O(N) over the user table and will be called during passkey login/status, so it can become a serious performance bottleneck. Consider a cached result, incremental tracking (e.g., maintain a counter/flag on update), or iterating rows/limiting to the first legacy match instead of loading all users.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
return false, nil
}
59 changes: 56 additions & 3 deletions internal/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebAuthnCredentialRecords attempts to json.Unmarshal(u.Authn) even when Authn is an empty string; this falls through to the legacy unmarshal and returns nil after printing an error. Treat empty Authn the same as "[]" (return an empty slice) so callers don't have to handle nil and to avoid noisy parse errors during upgrades.

Copilot uses AI. Check for mistakes.

var credentials []webauthn.Credential
err = json.Unmarshal([]byte(u.Authn), &credentials)
if err != nil {
fmt.Println(err)
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid fmt.Println(err) inside WebAuthnCredentialRecords; it writes to stdout and can leak/produce noisy logs in production. Use the project's logger (e.g., utils.Log) at an appropriate level, or simply return an empty slice when parsing fails (possibly after metrics/logging at a higher layer).

Suggested change
fmt.Println(err)

Copilot uses AI. Check for mistakes.
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 {
Expand Down
113 changes: 78 additions & 35 deletions server/handles/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeginAuthnLogin calls db.HasLegacyAuthnCredentials() on the unauthenticated login path when username is empty. That function loads and parses authn data for all users, which is expensive and can become a DoS vector under login traffic. Consider replacing this with a cached flag/TTL cache, a cheaper query, or moving the legacy check to a user-scoped flow rather than a full-table scan per request.

Copilot uses AI. Check for mistakes.
}
}
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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LegacyAuthnStatus is exposed without auth and triggers db.HasLegacyAuthnCredentials(), which currently scans all users and parses authn JSON. This can be very expensive if polled by the UI; consider caching the result, rate-limiting, or restricting the endpoint to authenticated/admin users if possible.

Copilot uses AI. Check for mistakes.
}

func FinishAuthnLogin(c *gin.Context) {
authnInstance, err := authn.NewAuthnInstance(c)
if err != nil {
common.ErrorResp(c, err, 400)
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeginAuthnRegistration calls common.ErrorResp when authn.NewAuthnInstance fails but does not return, so execution continues with a nil/invalid authnInstance and can panic. Add an early return after sending the error response.

Copilot uses AI. Check for mistakes.

options, sessionData, err := authnInstance.BeginRegistration(user)
options, sessionData, err := authnInstance.BeginRegistration(
user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
)

if err != nil {
common.ErrorResp(c, err, 400)
Expand All @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetAuthnCredentials builds passkey metadata by doing an inner loop over records for every credential and compares IDs via string conversions. This is O(n^2) and allocates; consider building a map from credential ID to record (and use bytes.Equal/base64 keying) to simplify and speed up the lookup.

Copilot uses AI. Check for mistakes.
}
credential := PasskeyCredentials{
ID: v.ID,
FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID),
CreatorIP: creatorIP,
CreatorUA: creatorUA,
IsLegacy: isLegacy,
}
res = append(res, credential)
}
Expand Down
17 changes: 9 additions & 8 deletions server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down