diff --git a/AGENTS.md b/AGENTS.md index 061b19ccec..e593b36278 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,3 +58,11 @@ Examples: If you are not one of the listed assistants, do not add a `Co-authored-by` trailer. Instead, ask the human collaborator to provide the exact `Co-authored-by` trailer to use. Do not invent, infer, or generate one yourself. + +## Passkey Authentication + +- The existing `User` record is the sole account owner for passwords, sessions, recovery methods, and passkeys. Do not create a parallel passkey account system. +- WebAuthn challenges are server-side, one-time, ceremony/user-bound, and expire after five minutes. Never return serialized `webauthn.SessionData` to clients. +- Passkey ceremonies require user verification and validate the exact configured RP ID and origin. Production deployments require an absolute HTTPS `site_url`. +- Persist the credential returned by a successful assertion before issuing a login token so signature counters, backup flags, and `last_used_at` cannot be lost. +- The in-memory challenge store supports the documented single-process deployment. Multi-replica deployments must provide a shared atomic challenge store before enabling passkeys. diff --git a/internal/authn/authn.go b/internal/authn/authn.go index a57823d156..5aa191a6d7 100644 --- a/internal/authn/authn.go +++ b/internal/authn/authn.go @@ -1,26 +1,73 @@ package authn import ( - "fmt" + "errors" + "net" "net/url" + "strings" "github.com/OpenListTeam/OpenList/v4/internal/conf" "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 NewAuthnInstance(c *gin.Context) (*webauthn.WebAuthn, error) { - siteUrl, err := url.Parse(common.GetApiUrl(c.Request.Context())) + rawURL := common.GetApiUrl(c.Request.Context()) + siteURL, err := url.Parse(rawURL) if err != nil { return nil, err } + configuredURL, configuredErr := url.Parse(conf.Conf.SiteURL) + configuredAbsolute := configuredErr == nil && + (configuredURL.Scheme == "http" || configuredURL.Scheme == "https") && + configuredURL.Hostname() != "" + if !configuredAbsolute && !isLocalHostname(siteURL.Hostname()) { + return nil, errors.New("passkeys require an absolute site_url in production") + } + return NewAuthnInstanceForURL(rawURL, setting.GetStr(conf.SiteTitle)) +} + +func NewAuthnInstanceForURL(rawURL, displayName string) (*webauthn.WebAuthn, error) { + siteURL, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + if siteURL.Hostname() == "" || (siteURL.Scheme != "http" && siteURL.Scheme != "https") { + return nil, errors.New("passkeys require an absolute http(s) site_url") + } + if siteURL.Scheme != "https" && !isLocalHostname(siteURL.Hostname()) { + return nil, errors.New("passkeys require https outside localhost") + } + origin := siteURL.Scheme + "://" + siteURL.Host return webauthn.New(&webauthn.Config{ - RPDisplayName: setting.GetStr(conf.SiteTitle), - RPID: siteUrl.Hostname(), - //RPOrigin: siteUrl.String(), - RPOrigins: []string{fmt.Sprintf("%s://%s", siteUrl.Scheme, siteUrl.Host)}, - // RPOrigin: "http://localhost:5173" + RPDisplayName: displayName, + RPID: siteURL.Hostname(), + RPOrigins: []string{origin}, + RPTopOrigins: []string{origin}, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + UserVerification: protocol.VerificationRequired, + }, + RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: ChallengeTTL, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: ChallengeTTL, + }, + }, }) } + +func isLocalHostname(hostname string) bool { + if strings.EqualFold(hostname, "localhost") { + return true + } + ip := net.ParseIP(hostname) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/authn/authn_test.go b/internal/authn/authn_test.go new file mode 100644 index 0000000000..6d5b632ca3 --- /dev/null +++ b/internal/authn/authn_test.go @@ -0,0 +1,67 @@ +package authn + +import ( + "crypto/sha256" + "testing" + + "github.com/go-webauthn/webauthn/protocol" +) + +func TestAuthnConfigRequiresUserVerificationAndPinsOrigin(t *testing.T) { + instance, err := NewAuthnInstanceForURL("https://files.example.com/openlist", "OpenList") + if err != nil { + t.Fatal(err) + } + if got := instance.Config.RPID; got != "files.example.com" { + t.Fatalf("RPID = %q, want files.example.com", got) + } + if got := instance.Config.RPOrigins; len(got) != 1 || got[0] != "https://files.example.com" { + t.Fatalf("RPOrigins = %#v, want exact deployment origin", got) + } + if got := instance.Config.AuthenticatorSelection.UserVerification; got != protocol.VerificationRequired { + t.Fatalf("UserVerification = %q, want required", got) + } + if !instance.Config.Timeouts.Login.Enforce || !instance.Config.Timeouts.Registration.Enforce { + t.Fatal("server-side WebAuthn timeouts are not enforced") + } +} + +func TestAuthnConfigRequiresHTTPSOutsideLocalhost(t *testing.T) { + if _, err := NewAuthnInstanceForURL("http://files.example.com", "OpenList"); err == nil { + t.Fatal("non-local HTTP origin was accepted") + } + if _, err := NewAuthnInstanceForURL("http://localhost:5244", "OpenList"); err != nil { + t.Fatalf("localhost development origin rejected: %v", err) + } +} + +func TestAuthnRejectsWrongOriginAndRPID(t *testing.T) { + instance, err := NewAuthnInstanceForURL("https://files.example.com", "OpenList") + if err != nil { + t.Fatal(err) + } + clientData := protocol.CollectedClientData{ + Type: protocol.AssertCeremony, + Challenge: "challenge", + Origin: "https://attacker.example", + } + if err = clientData.Verify( + "challenge", + protocol.AssertCeremony, + instance.Config.RPOrigins, + nil, + instance.Config.RPTopOriginVerificationMode, + ); err == nil { + t.Fatal("wrong origin was accepted") + } + + wrongRP := sha256.Sum256([]byte("attacker.example")) + expectedRP := sha256.Sum256([]byte(instance.Config.RPID)) + authenticatorData := protocol.AuthenticatorData{ + RPIDHash: wrongRP[:], + Flags: protocol.FlagUserPresent | protocol.FlagUserVerified, + } + if err = authenticatorData.Verify(expectedRP[:], nil, true, true); err == nil { + t.Fatal("wrong RP ID hash was accepted") + } +} diff --git a/internal/authn/challenge.go b/internal/authn/challenge.go new file mode 100644 index 0000000000..7430f2b3fa --- /dev/null +++ b/internal/authn/challenge.go @@ -0,0 +1,99 @@ +package authn + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "sync" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + ChallengeTTL = 5 * time.Minute + maxChallengeCount = 10000 +) + +var ( + ErrChallengeInvalid = errors.New("passkey challenge is invalid, expired, or already used") + challenges = newChallengeStore() +) + +type Ceremony string + +const ( + CeremonyLogin Ceremony = "login" + CeremonyRegistration Ceremony = "registration" +) + +type Challenge struct { + Session webauthn.SessionData + Ceremony Ceremony + UserID uint + Name string + Expires time.Time +} + +type challengeStore struct { + mu sync.Mutex + items map[string]Challenge + now func() time.Time +} + +func newChallengeStore() *challengeStore { + return &challengeStore{items: make(map[string]Challenge), now: time.Now} +} + +func (s *challengeStore) Put(challenge Challenge) (string, error) { + id := make([]byte, 32) + if _, err := rand.Read(id); err != nil { + return "", err + } + key := base64.RawURLEncoding.EncodeToString(id) + now := s.now() + challenge.Expires = now.Add(ChallengeTTL) + + s.mu.Lock() + if len(s.items) >= maxChallengeCount { + oldestKey := "" + var oldestExpiry time.Time + for existingKey, existing := range s.items { + if !now.Before(existing.Expires) { + delete(s.items, existingKey) + continue + } + if oldestKey == "" || existing.Expires.Before(oldestExpiry) { + oldestKey = existingKey + oldestExpiry = existing.Expires + } + } + // ponytail: O(n) only at the 10k-entry ceiling; use an expiry heap if this becomes hot. + if len(s.items) >= maxChallengeCount { + delete(s.items, oldestKey) + } + } + s.items[key] = challenge + s.mu.Unlock() + return key, nil +} + +func (s *challengeStore) Consume(key string, ceremony Ceremony, userID uint) (Challenge, error) { + s.mu.Lock() + challenge, ok := s.items[key] + delete(s.items, key) + s.mu.Unlock() + + if !ok || challenge.Ceremony != ceremony || challenge.UserID != userID || !s.now().Before(challenge.Expires) { + return Challenge{}, ErrChallengeInvalid + } + return challenge, nil +} + +func StoreChallenge(challenge Challenge) (string, error) { + return challenges.Put(challenge) +} + +func ConsumeChallenge(key string, ceremony Ceremony, userID uint) (Challenge, error) { + return challenges.Consume(key, ceremony, userID) +} diff --git a/internal/authn/challenge_test.go b/internal/authn/challenge_test.go new file mode 100644 index 0000000000..fe91419fbc --- /dev/null +++ b/internal/authn/challenge_test.go @@ -0,0 +1,87 @@ +package authn + +import ( + "errors" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestChallengeRegistrationAuthenticationAndReplay(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + store := newChallengeStore() + store.now = func() time.Time { return now } + + for _, ceremony := range []Ceremony{CeremonyRegistration, CeremonyLogin} { + key, err := store.Put(Challenge{ + Session: webauthn.SessionData{Challenge: "challenge"}, + Ceremony: ceremony, + UserID: 42, + }) + if err != nil { + t.Fatalf("Put(%s) error = %v", ceremony, err) + } + if _, err = store.Consume(key, ceremony, 42); err != nil { + t.Fatalf("Consume(%s) error = %v", ceremony, err) + } + if _, err = store.Consume(key, ceremony, 42); !errors.Is(err, ErrChallengeInvalid) { + t.Fatalf("replayed Consume(%s) error = %v, want ErrChallengeInvalid", ceremony, err) + } + } +} + +func TestChallengeRejectsExpiredOrWrongBinding(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + store := newChallengeStore() + store.now = func() time.Time { return now } + + expired, err := store.Put(Challenge{Ceremony: CeremonyLogin, UserID: 42}) + if err != nil { + t.Fatal(err) + } + now = now.Add(ChallengeTTL) + if _, err = store.Consume(expired, CeremonyLogin, 42); !errors.Is(err, ErrChallengeInvalid) { + t.Fatalf("expired challenge error = %v, want ErrChallengeInvalid", err) + } + + wrongUser, err := store.Put(Challenge{Ceremony: CeremonyRegistration, UserID: 42}) + if err != nil { + t.Fatal(err) + } + if _, err = store.Consume(wrongUser, CeremonyRegistration, 7); !errors.Is(err, ErrChallengeInvalid) { + t.Fatalf("wrong-user challenge error = %v, want ErrChallengeInvalid", err) + } +} + +func TestChallengeCapacityEvictsOldestInsteadOfRejectingNewCeremonies(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + store := newChallengeStore() + store.now = func() time.Time { return now } + + var oldest string + for i := 0; i < maxChallengeCount; i++ { + key, err := store.Put(Challenge{Ceremony: CeremonyLogin}) + if err != nil { + t.Fatal(err) + } + if i == 0 { + oldest = key + } + now = now.Add(time.Nanosecond) + } + + newest, err := store.Put(Challenge{Ceremony: CeremonyLogin}) + if err != nil { + t.Fatalf("Put at capacity rejected a new ceremony: %v", err) + } + if len(store.items) != maxChallengeCount { + t.Fatalf("challenge count = %d, want %d", len(store.items), maxChallengeCount) + } + if _, err = store.Consume(oldest, CeremonyLogin, 0); !errors.Is(err, ErrChallengeInvalid) { + t.Fatalf("oldest challenge error = %v, want ErrChallengeInvalid", err) + } + if _, err = store.Consume(newest, CeremonyLogin, 0); err != nil { + t.Fatalf("new challenge was not retained: %v", err) + } +} diff --git a/internal/db/user.go b/internal/db/user.go index 4b9c67eceb..1dace8db94 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -1,7 +1,11 @@ package db import ( + "bytes" "encoding/base64" + "fmt" + "sync" + "time" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -9,6 +13,10 @@ import ( "github.com/pkg/errors" ) +// ponytail: one process-wide lock matches the supported single-process auth topology. +// Multi-replica deployments need database row locks together with a shared challenge store. +var passkeyMu sync.Mutex + func GetUserByRole(role int) (*model.User, error) { user := model.User{Role: role} if err := db.Where(user).Take(&user).Error; err != nil { @@ -68,14 +76,38 @@ 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, name string, now time.Time) error { if u == nil { return errors.New("user is nil") } - exists := u.WebAuthnCredentials() - if credential != nil { - exists = append(exists, *credential) + if credential == nil { + return errors.New("credential is nil") } + passkeyMu.Lock() + defer passkeyMu.Unlock() + + current, err := GetUserById(u.ID) + if err != nil { + return err + } + exists, err := current.PasskeyCredentials() + if err != nil { + return err + } + for i := range exists { + if bytes.Equal(exists[i].ID, credential.ID) { + return errors.New("credential is already registered") + } + } + if name == "" { + name = fmt.Sprintf("Passkey %d", len(exists)+1) + } + createdAt := now.UTC() + exists = append(exists, model.PasskeyCredential{ + Credential: *credential, + Name: name, + CreatedAt: &createdAt, + }) res, err := utils.Json.Marshal(exists) if err != nil { return err @@ -84,19 +116,93 @@ func RegisterAuthn(u *model.User, credential *webauthn.Credential) error { } func RemoveAuthn(u *model.User, id string) error { - exists := u.WebAuthnCredentials() + if u == nil { + return errors.New("user is nil") + } + passkeyMu.Lock() + defer passkeyMu.Unlock() + + current, err := GetUserById(u.ID) + if err != nil { + return err + } + exists, err := current.PasskeyCredentials() + if err != nil { + return err + } + found := false for i := 0; i < len(exists); i++ { idEncoded := base64.StdEncoding.EncodeToString(exists[i].ID) if idEncoded == id { - exists[len(exists)-1], exists[i] = exists[i], exists[len(exists)-1] - exists = exists[:len(exists)-1] + exists = append(exists[:i], exists[i+1:]...) + found = true break } } - + if !found { + return errors.New("credential not found or already revoked") + } res, err := utils.Json.Marshal(exists) if err != nil { return err } return UpdateAuthn(u.ID, string(res)) } + +func RenameAuthn(u *model.User, id, name string) error { + if u == nil { + return errors.New("user is nil") + } + passkeyMu.Lock() + defer passkeyMu.Unlock() + + current, err := GetUserById(u.ID) + if err != nil { + return err + } + exists, err := current.PasskeyCredentials() + if err != nil { + return err + } + for i := range exists { + if base64.StdEncoding.EncodeToString(exists[i].ID) == id { + exists[i].Name = name + res, err := utils.Json.Marshal(exists) + if err != nil { + return err + } + return UpdateAuthn(u.ID, string(res)) + } + } + return errors.New("credential not found or already revoked") +} + +func UpdateAuthnUsage(userID uint, credential *webauthn.Credential, now time.Time) error { + if credential == nil { + return errors.New("credential is nil") + } + passkeyMu.Lock() + defer passkeyMu.Unlock() + + current, err := GetUserById(userID) + if err != nil { + return err + } + exists, err := current.PasskeyCredentials() + if err != nil { + return err + } + for i := range exists { + if bytes.Equal(exists[i].ID, credential.ID) { + exists[i].Credential = *credential + usedAt := now.UTC() + exists[i].LastUsedAt = &usedAt + res, err := utils.Json.Marshal(exists) + if err != nil { + return err + } + return UpdateAuthn(userID, string(res)) + } + } + return errors.New("credential not found or already revoked") +} diff --git a/internal/db/user_passkey_test.go b/internal/db/user_passkey_test.go new file mode 100644 index 0000000000..aba14c56e1 --- /dev/null +++ b/internal/db/user_passkey_test.go @@ -0,0 +1,90 @@ +package db + +import ( + "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/glebarez/sqlite" + "github.com/go-webauthn/webauthn/webauthn" + "gorm.io/gorm" +) + +func TestPasskeyLifecyclePreservesPasswordLogin(t *testing.T) { + previousDB := db + testDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + db = testDB + t.Cleanup(func() { db = previousDB }) + if err = testDB.AutoMigrate(new(model.User)); err != nil { + t.Fatal(err) + } + + user := &model.User{Username: "passkey-user", Authn: "[]"} + user.SetPassword("still-works") + if err = testDB.Create(user).Error; err != nil { + t.Fatal(err) + } + + registeredAt := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + credential := &webauthn.Credential{ + ID: []byte("credential-id"), + PublicKey: []byte("public-key"), + Authenticator: webauthn.Authenticator{ + SignCount: 1, + }, + } + if err = RegisterAuthn(user, credential, "MacBook Touch ID", registeredAt); err != nil { + t.Fatalf("RegisterAuthn() error = %v", err) + } + secondCredential := &webauthn.Credential{ + ID: []byte("credential-id-2"), + PublicKey: []byte("public-key-2"), + } + if err = RegisterAuthn(user, secondCredential, "", registeredAt); err != nil { + t.Fatalf("second RegisterAuthn() error = %v", err) + } + if err = RenameAuthn(user, "Y3JlZGVudGlhbC1pZC0y", "YubiKey"); err != nil { + t.Fatalf("RenameAuthn() error = %v", err) + } + + credential.Authenticator.SignCount = 2 + usedAt := registeredAt.Add(time.Hour) + if err = UpdateAuthnUsage(user.ID, credential, usedAt); err != nil { + t.Fatalf("UpdateAuthnUsage() error = %v", err) + } + + current, err := GetUserById(user.ID) + if err != nil { + t.Fatal(err) + } + passkeys, err := current.PasskeyCredentials() + if err != nil { + t.Fatal(err) + } + if len(passkeys) != 2 || passkeys[0].Name != "MacBook Touch ID" || passkeys[1].Name != "YubiKey" { + t.Fatalf("stored passkeys = %#v", passkeys) + } + if passkeys[0].Authenticator.SignCount != 2 || passkeys[0].LastUsedAt == nil || !passkeys[0].LastUsedAt.Equal(usedAt) { + t.Fatalf("authentication metadata not persisted: %#v", passkeys[0]) + } + if err = current.ValidateRawPassword("still-works"); err != nil { + t.Fatalf("existing password login regressed: %v", err) + } + + if err = RemoveAuthn(current, "Y3JlZGVudGlhbC1pZA=="); err != nil { + t.Fatalf("RemoveAuthn() error = %v", err) + } + current, err = GetUserById(user.ID) + if err != nil { + t.Fatal(err) + } + if got := current.WebAuthnCredentials(); len(got) != 1 || string(got[0].ID) != "credential-id-2" { + t.Fatalf("revoked credential remains usable or sibling was removed: %#v", got) + } + if err = RemoveAuthn(current, "Y3JlZGVudGlhbC1pZA=="); err == nil { + t.Fatal("revoking an already-revoked credential succeeded") + } +} diff --git a/internal/model/user.go b/internal/model/user.go index 2dacd752eb..2d12a33b3f 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -71,6 +71,13 @@ type User struct { AllowLdap bool `json:"allow_ldap" gorm:"default:true"` } +type PasskeyCredential struct { + webauthn.Credential + Name string `json:"name,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + func (u *User) IsGuest() bool { return u.Role == GUEST } @@ -259,14 +266,29 @@ func (u *User) WebAuthnDisplayName() string { } func (u *User) WebAuthnCredentials() []webauthn.Credential { - var res []webauthn.Credential - err := json.Unmarshal([]byte(u.Authn), &res) + passkeys, err := u.PasskeyCredentials() if err != nil { - fmt.Println(err) + utils.Log.Errorf("failed to decode passkeys for user %d: %v", u.ID, err) + return nil + } + res := make([]webauthn.Credential, len(passkeys)) + for i := range passkeys { + res[i] = passkeys[i].Credential } return res } +func (u *User) PasskeyCredentials() ([]PasskeyCredential, error) { + var res []PasskeyCredential + if u.Authn == "" { + return res, nil + } + if err := json.Unmarshal([]byte(u.Authn), &res); err != nil { + return nil, err + } + return res, nil +} + func (u *User) WebAuthnIcon() string { return "https://res.oplist.org/logo/logo.svg" } diff --git a/server/handles/webauthn.go b/server/handles/webauthn.go index 6848020fd0..ddad921028 100644 --- a/server/handles/webauthn.go +++ b/server/handles/webauthn.go @@ -1,10 +1,12 @@ package handles import ( - "encoding/base64" "encoding/binary" - "encoding/json" + "errors" "fmt" + "strings" + "time" + "unicode/utf8" "github.com/OpenListTeam/OpenList/v4/internal/authn" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -16,8 +18,11 @@ import ( "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + log "github.com/sirupsen/logrus" ) +const maxPasskeyNameLength = 100 + func BeginAuthnLogin(c *gin.Context) { enabled := setting.GetBool(conf.WebauthnLoginEnabled) if !enabled { @@ -33,29 +38,42 @@ func BeginAuthnLogin(c *gin.Context) { var ( options *protocol.CredentialAssertion sessionData *webauthn.SessionData + userID uint ) if username := c.Query("username"); username != "" { var user *model.User user, err = db.GetUserByName(username) if err == nil { - options, sessionData, err = authnInstance.BeginLogin(user) + userID = user.ID + options, sessionData, err = authnInstance.BeginLogin( + user, + webauthn.WithUserVerification(protocol.VerificationRequired), + ) } } else { // client-side discoverable login - options, sessionData, err = authnInstance.BeginDiscoverableLogin() + options, sessionData, err = authnInstance.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationRequired), + ) } if err != nil { + log.WithError(err).Warn("passkey login challenge creation failed") common.ErrorResp(c, err, 400) return } - val, err := json.Marshal(sessionData) + sessionID, err := authn.StoreChallenge(authn.Challenge{ + Session: *sessionData, + Ceremony: authn.CeremonyLogin, + UserID: userID, + }) if err != nil { - common.ErrorResp(c, err, 400) + log.WithError(err).Error("passkey login challenge storage failed") + common.ErrorResp(c, err, 500) return } common.SuccessResp(c, gin.H{ "options": options, - "session": val, + "session": sessionID, }) } @@ -65,38 +83,37 @@ func FinishAuthnLogin(c *gin.Context) { common.ErrorStrResp(c, "WebAuthn is not enabled", 403) return } - authnInstance, err := authn.NewAuthnInstance(c) - if err != nil { - common.ErrorResp(c, err, 400) - return + var user *model.User + var userID uint + if username := c.Query("username"); username != "" { + var err error + user, err = db.GetUserByName(username) + if err != nil { + common.ErrorResp(c, err, 400) + return + } + userID = user.ID } - - sessionDataString := c.GetHeader("session") - sessionDataBytes, err := base64.StdEncoding.DecodeString(sessionDataString) + challenge, err := authn.ConsumeChallenge(c.GetHeader("session"), authn.CeremonyLogin, userID) if err != nil { + log.WithError(err).Warn("passkey login challenge rejected") common.ErrorResp(c, err, 400) return } - - var sessionData webauthn.SessionData - if err := json.Unmarshal(sessionDataBytes, &sessionData); err != nil { + authnInstance, err := authn.NewAuthnInstance(c) + if err != nil { common.ErrorResp(c, err, 400) return } - var user *model.User - if username := c.Query("username"); username != "" { - user, err = db.GetUserByName(username) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - _, err = authnInstance.FinishLogin(user, sessionData, c.Request) + var credential *webauthn.Credential + if user != nil { + credential, err = authnInstance.FinishLogin(user, challenge.Session, c.Request) } else { // client-side discoverable login - _, err = authnInstance.FinishDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) { - // first param `rawID` in this callback function is equal to ID in webauthn.Credential, - // but it's unnnecessary to check it. - // userHandle param is equal to (User).WebAuthnID(). + credential, err = authnInstance.FinishDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) { + if len(userHandle) != 8 { + return nil, errors.New("invalid passkey user handle") + } userID := uint(binary.LittleEndian.Uint64(userHandle)) user, err = db.GetUserById(userID) if err != nil { @@ -104,18 +121,32 @@ func FinishAuthnLogin(c *gin.Context) { } return user, nil - }, sessionData, c.Request) + }, challenge.Session, c.Request) } if err != nil { + log.WithError(err).Warn("passkey login verification failed") common.ErrorResp(c, err, 400) return } - + if user == nil || user.Disabled { + common.ErrorStrResp(c, "passkey account is unavailable", 401) + return + } + if err = db.UpdateAuthnUsage(user.ID, credential, time.Now()); err != nil { + log.WithError(err).Error("passkey counter persistence failed") + common.ErrorResp(c, err, 500) + return + } + if err = op.DelUserCache(user.Username); err != nil { + common.ErrorResp(c, err, 500) + return + } token, err := common.GenerateToken(user) if err != nil { - common.ErrorResp(c, err, 400, true) + common.ErrorResp(c, err, 500, true) return } + log.WithField("user_id", user.ID).Info("passkey login succeeded") common.SuccessResp(c, gin.H{"token": token}) } @@ -126,6 +157,11 @@ func BeginAuthnRegistration(c *gin.Context) { return } user := c.Request.Context().Value(conf.UserKey).(*model.User) + name, err := validatePasskeyName(c.Query("name"), true) + if err != nil { + common.ErrorResp(c, err, 400) + return + } authnInstance, err := authn.NewAuthnInstance(c) if err != nil { @@ -143,15 +179,21 @@ func BeginAuthnRegistration(c *gin.Context) { return } - val, err := json.Marshal(sessionData) + sessionID, err := authn.StoreChallenge(authn.Challenge{ + Session: *sessionData, + Ceremony: authn.CeremonyRegistration, + UserID: user.ID, + Name: name, + }) if err != nil { - common.ErrorResp(c, err, 400) + log.WithError(err).Error("passkey registration challenge storage failed") + common.ErrorResp(c, err, 500) return } common.SuccessResp(c, gin.H{ "options": options, - "session": val, + "session": sessionID, }) } @@ -162,33 +204,30 @@ func FinishAuthnRegistration(c *gin.Context) { return } user := c.Request.Context().Value(conf.UserKey).(*model.User) - sessionDataString := c.GetHeader("Session") - - authnInstance, err := authn.NewAuthnInstance(c) + challenge, err := authn.ConsumeChallenge( + c.GetHeader("session"), + authn.CeremonyRegistration, + user.ID, + ) if err != nil { + log.WithError(err).Warn("passkey registration challenge rejected") common.ErrorResp(c, err, 400) return } - - sessionDataBytes, err := base64.StdEncoding.DecodeString(sessionDataString) + authnInstance, err := authn.NewAuthnInstance(c) if err != nil { common.ErrorResp(c, err, 400) return } - var sessionData webauthn.SessionData - if err := json.Unmarshal(sessionDataBytes, &sessionData); err != nil { - common.ErrorResp(c, err, 400) - return - } - - credential, err := authnInstance.FinishRegistration(user, sessionData, c.Request) + credential, err := authnInstance.FinishRegistration(user, challenge.Session, c.Request) if err != nil { + log.WithError(err).Warn("passkey registration verification failed") common.ErrorResp(c, err, 400) return } - err = db.RegisterAuthn(user, credential) + err = db.RegisterAuthn(user, credential, challenge.Name, time.Now()) if err != nil { common.ErrorResp(c, err, 400) return @@ -198,6 +237,7 @@ func FinishAuthnRegistration(c *gin.Context) { common.ErrorResp(c, err, 400) return } + log.WithField("user_id", user.ID).Info("passkey registered") common.SuccessResp(c, "Registered Successfully") } @@ -225,20 +265,75 @@ func DeleteAuthnLogin(c *gin.Context) { common.SuccessResp(c, "Deleted Successfully") } +func RenameAuthnLogin(c *gin.Context) { + user := c.Request.Context().Value(conf.UserKey).(*model.User) + var req struct { + ID string `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + name, err := validatePasskeyName(req.Name, false) + if err != nil { + common.ErrorResp(c, err, 400) + return + } + if err = db.RenameAuthn(user, req.ID, name); err != nil { + common.ErrorResp(c, err, 400) + return + } + if err = op.DelUserCache(user.Username); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, "Renamed Successfully") +} + func GetAuthnCredentials(c *gin.Context) { type WebAuthnCredentials struct { - ID []byte `json:"id"` - FingerPrint string `json:"fingerprint"` + ID []byte `json:"id"` + Name string `json:"name"` + FingerPrint string `json:"fingerprint"` + CreatedAt *time.Time `json:"created_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` } user := c.Request.Context().Value(conf.UserKey).(*model.User) - credentials := user.WebAuthnCredentials() + credentials, err := user.PasskeyCredentials() + if err != nil { + log.WithError(err).Error("passkey credential list decode failed") + common.ErrorResp(c, err, 500) + return + } res := make([]WebAuthnCredentials, 0, len(credentials)) - for _, v := range credentials { + for i, v := range credentials { + name := v.Name + if name == "" { + name = fmt.Sprintf("Passkey %d", i+1) + } credential := WebAuthnCredentials{ ID: v.ID, + Name: name, FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID), + CreatedAt: v.CreatedAt, + LastUsedAt: v.LastUsedAt, } res = append(res, credential) } common.SuccessResp(c, res) } + +func validatePasskeyName(name string, optional bool) (string, error) { + name = strings.TrimSpace(name) + if name == "" && optional { + return "", nil + } + if name == "" { + return "", errors.New("passkey name is required") + } + if utf8.RuneCountInString(name) > maxPasskeyNameLength { + return "", fmt.Errorf("passkey name must be at most %d characters", maxPasskeyNameLength) + } + return name, nil +} diff --git a/server/middlewares/auth.go b/server/middlewares/auth.go index ca67e4a6dd..efd85e85d6 100644 --- a/server/middlewares/auth.go +++ b/server/middlewares/auth.go @@ -75,60 +75,6 @@ func Auth(allowDisabledGuest bool) func(c *gin.Context) { } } -func Authn(c *gin.Context) { - token := c.GetHeader("Authorization") - if subtle.ConstantTimeCompare([]byte(token), []byte(setting.GetStr(conf.Token))) == 1 { - admin, err := op.GetAdmin() - if err != nil { - common.ErrorResp(c, err, 500) - c.Abort() - return - } - common.GinAppendValues(c, conf.UserKey, admin) - log.Debugf("use admin token: %+v", admin) - c.Next() - return - } - if token == "" { - guest, err := op.GetGuest() - if err != nil { - common.ErrorResp(c, err, 500) - c.Abort() - return - } - common.GinAppendValues(c, conf.UserKey, guest) - log.Debugf("use empty token: %+v", guest) - c.Next() - return - } - userClaims, err := common.ParseToken(token) - if err != nil { - common.ErrorResp(c, err, 401) - c.Abort() - return - } - user, err := op.GetUserByName(userClaims.Username) - if err != nil { - common.ErrorResp(c, err, 401) - c.Abort() - return - } - // validate password timestamp - if userClaims.PwdTS != user.PwdTS { - common.ErrorStrResp(c, "Password has been changed, login please", 401) - c.Abort() - return - } - if user.Disabled { - common.ErrorStrResp(c, "Current user is disabled, replace please", 401) - c.Abort() - return - } - common.GinAppendValues(c, conf.UserKey, user) - log.Debugf("use login token: %+v", user) - c.Next() -} - func AuthNotGuest(c *gin.Context) { user := c.Request.Context().Value(conf.UserKey).(*model.User) if user.IsGuest() { diff --git a/server/router.go b/server/router.go index 7330bd2c33..1b61d11360 100644 --- a/server/router.go +++ b/server/router.go @@ -68,7 +68,7 @@ func Init(e *gin.Engine) { api := g.Group("/api") auth := api.Group("", middlewares.Auth(false)) - webauthn := api.Group("/authn", middlewares.Authn) + passkeys := api.Group("/authn", middlewares.Auth(false), middlewares.AuthNotGuest) api.POST("/auth/login", handles.Login) api.POST("/auth/login/hash", handles.LoginHash) @@ -91,10 +91,11 @@ func Init(e *gin.Engine) { // 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) + passkeys.GET("/webauthn_begin_registration", handles.BeginAuthnRegistration) + passkeys.POST("/webauthn_finish_registration", handles.FinishAuthnRegistration) + passkeys.POST("/delete_authn", handles.DeleteAuthnLogin) + passkeys.POST("/rename_authn", handles.RenameAuthnLogin) + passkeys.GET("/getcredentials", handles.GetAuthnCredentials) // no need auth public := api.Group("/public")