From 0bbca1b133dd249b25090b8c3be020e3afbbc92e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 01:18:21 -0400 Subject: [PATCH] feat(api): add API v1 login, setup, grant resolution and token minting --- core/apiauth/context.go | 14 ++ core/apiauth/credentials.go | 68 +++++++++ core/apiauth/credentials_test.go | 63 ++++++++ core/apiauth/db_test.go | 1 - core/apiauth/export_test.go | 11 ++ core/apiauth/service.go | 245 +++++++++++++++++++++++++++++++ core/apiauth/service_test.go | 159 ++++++++++++++++++++ 7 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 core/apiauth/context.go create mode 100644 core/apiauth/credentials.go create mode 100644 core/apiauth/credentials_test.go create mode 100644 core/apiauth/export_test.go create mode 100644 core/apiauth/service.go create mode 100644 core/apiauth/service_test.go diff --git a/core/apiauth/context.go b/core/apiauth/context.go new file mode 100644 index 000000000..082d92d45 --- /dev/null +++ b/core/apiauth/context.go @@ -0,0 +1,14 @@ +package apiauth + +import "context" + +type principalKey struct{} + +func WithPrincipal(ctx context.Context, p *Principal) context.Context { + return context.WithValue(ctx, principalKey{}, p) +} + +func PrincipalFrom(ctx context.Context) (*Principal, bool) { + p, ok := ctx.Value(principalKey{}).(*Principal) + return p, ok +} diff --git a/core/apiauth/credentials.go b/core/apiauth/credentials.go new file mode 100644 index 000000000..43834ab51 --- /dev/null +++ b/core/apiauth/credentials.go @@ -0,0 +1,68 @@ +package apiauth + +import ( + "context" + "crypto/subtle" + "errors" + + "github.com/navidrome/navidrome/model" +) + +type Outcome int + +const ( + NotMine Outcome = iota + Authenticated + Rejected + Unavailable +) + +type CredentialResult struct { + Outcome Outcome + User *model.User + Provider string + PasswordLocal bool +} + +type CredentialChecker interface { + Check(ctx context.Context, username, password string) (CredentialResult, error) +} + +// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final. +func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) { + for _, c := range checkers { + res, err := c.Check(ctx, username, password) + if err != nil { + return CredentialResult{}, err + } + switch res.Outcome { + case NotMine: + continue + case Authenticated: + return res, nil + case Unavailable: + return CredentialResult{}, model.ErrNotAvailable + default: + return CredentialResult{}, model.ErrInvalidAuth + } + } + return CredentialResult{}, model.ErrInvalidAuth +} + +type dbChecker struct { + ds model.DataStore +} + +func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { + u, err := c.ds.User().FindByUsernameWithPassword(ctx, username) + if errors.Is(err, model.ErrNotFound) { + return CredentialResult{Outcome: NotMine}, nil + } + if err != nil { + return CredentialResult{}, err + } + if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 { + return CredentialResult{Outcome: Rejected}, nil + } + return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil +} diff --git a/core/apiauth/credentials_test.go b/core/apiauth/credentials_test.go new file mode 100644 index 000000000..a57d54c29 --- /dev/null +++ b/core/apiauth/credentials_test.go @@ -0,0 +1,63 @@ +package apiauth + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeChecker struct { + res CredentialResult + err error + hit bool +} + +func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) { + f.hit = true + return f.res, f.err +} + +var _ = Describe("credential chain", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + + It("authenticates against the database with the stored password", func() { + u := createUser(ctx, "pw", false) + res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Outcome).To(Equal(Authenticated)) + Expect(res.User.ID).To(Equal(u.ID)) + Expect(res.Provider).To(Equal("password")) + Expect(res.PasswordLocal).To(BeTrue()) + }) + + It("rejects a wrong password and an unknown user the same way", func() { + u := createUser(ctx, "pw", false) + _, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("moves on only from NotMine, and an owner's rejection stops the chain", func() { + owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}} + later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}} + _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + Expect(later.hit).To(BeFalse()) + }) + + It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() { + _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b") + Expect(err).To(MatchError(model.ErrNotAvailable)) + boom := errors.New("boom") + _, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b") + Expect(err).To(MatchError(boom)) + }) +}) diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 0dc791634..69de6cd62 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -24,7 +24,6 @@ var _ = BeforeSuite(func() { realDS = persistence.New(db.Db()) }) -//nolint:unused func createUser(ctx context.Context, password string, admin bool) model.User { name := "user-" + id.NewRandom() u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin} diff --git a/core/apiauth/export_test.go b/core/apiauth/export_test.go new file mode 100644 index 000000000..fdfde677c --- /dev/null +++ b/core/apiauth/export_test.go @@ -0,0 +1,11 @@ +package apiauth + +import ( + "time" + + "github.com/navidrome/navidrome/model" +) + +func (s *Service) SetClock(now func() time.Time) { s.now = now } + +func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f } diff --git a/core/apiauth/service.go b/core/apiauth/service.go new file mode 100644 index 000000000..d6cdf4e20 --- /dev/null +++ b/core/apiauth/service.go @@ -0,0 +1,245 @@ +package apiauth + +import ( + "cmp" + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const ( + TokenTTL = time.Hour + IdleExpiry = 90 * 24 * time.Hour + cacheTTL = 30 * time.Second + touchInterval = 5 * time.Minute +) + +var ( + ErrInsufficientScope = errors.New("insufficient scope") + ErrPasswordManagedExternally = errors.New("password is managed externally") + ErrCurrentPasswordMismatch = errors.New("current password does not match") +) + +type ClientMeta struct { + Name string + Client string + ClientVersion string +} + +type Issued struct { + Secret string + Grant model.Grant + User model.User +} + +type AccessToken struct { + Token string + ExpiresIn time.Duration + Scopes []string +} + +type Principal struct { + User model.User + GrantID string + Scopes []string +} + +type Service struct { + ds model.DataStore + checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction + cache *livenessCache + now func() time.Time + signer func() (*signer, error) +} + +func New(ds model.DataStore) *Service { + s := &Service{ + ds: ds, + checkers: func(ds model.DataStore) []CredentialChecker { + return []CredentialChecker{dbChecker{ds: ds}} + }, + cache: newLivenessCache(cacheTTL), + now: time.Now, + } + // Loaded on first use so building the router never touches the database. + s.signer = sync.OnceValues(func() (*signer, error) { + return loadSigner(context.Background(), ds, func() time.Time { return s.now() }) + }) + return s +} + +func PasswordChangeable(u model.User) bool { + return u.IsAdmin || conf.Server.EnableUserEditing +} + +func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { + res, err := checkCredentials(ctx, s.checkers(s.ds), username, password) + if err != nil { + return nil, err + } + issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes) + if err != nil { + return nil, err + } + if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil { + log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err) + } + return issued, nil +} + +func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { + var issued *Issued + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + u, err := auth.CreateFirstAdmin(ctx, tx, username, password) + if err != nil { + return err + } + issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes) + return err + }) + return issued, err +} + +// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead. +func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) { + secret, hash := newSecret() + g := model.Grant{ + UserID: u.ID, + Name: cmp.Or(meta.Name, meta.Client), + Client: meta.Client, + ClientVersion: meta.ClientVersion, + Scopes: Entitled(scopes, u.IsAdmin), + Provider: provider, + SecretHash: hash, + UserEpoch: u.TokenEpoch, + CreatedAt: s.now(), + } + if err := ds.Grant().Put(ctx, &g); err != nil { + return nil, fmt.Errorf("storing grant: %w", err) + } + return &Issued{Secret: secret, Grant: g, User: u}, nil +} + +func (s *Service) ResolveGrant(ctx context.Context, secret, ip string) (*Principal, error) { + g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret)) + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrInvalidAuth + } + if err != nil { + return nil, err + } + lastActivity := g.CreatedAt + if g.LastUsedAt != nil { + lastActivity = *g.LastUsedAt + } + if !s.now().Before(lastActivity.Add(IdleExpiry)) { + s.dropGrant(ctx, g.ID) + return nil, model.ErrInvalidAuth + } + u, err := s.loadUser(ctx, g.UserID) + if err != nil { + return nil, err + } + if g.UserEpoch != u.TokenEpoch { + if g, u, err = s.settleEpoch(ctx, g.ID); err != nil { + return nil, err + } + } + s.touch(ctx, g.ID, ip, g.LastUsedAt) + return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil +} + +func (s *Service) Mint(ctx context.Context, p *Principal, requested []string) (*AccessToken, error) { + sg, err := s.signer() + if err != nil { + return nil, err + } + now := s.now() + scopes := Attenuate(p.Scopes, requested) + tok, err := sg.sign(claims{UserID: p.User.ID, GrantID: p.GrantID, Scopes: scopes, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)}) + if err != nil { + return nil, fmt.Errorf("signing access token: %w", err) + } + return &AccessToken{Token: tok, ExpiresIn: TokenTTL, Scopes: scopes}, nil +} + +func (s *Service) loadUser(ctx context.Context, userID string) (*model.User, error) { + u, err := s.ds.User().Get(ctx, userID) + if errors.Is(err, model.ErrNotFound) { + return nil, model.ErrInvalidAuth + } + return u, err +} + +func (s *Service) dropGrant(ctx context.Context, id string) { + s.cache.evict(id) + if err := s.ds.Grant().Delete(ctx, id); err != nil { + log.Warn(ctx, "API v1: could not delete dead grant", "grant", id, err) + } +} + +// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password +// change and make a kept grant look dead. The delete only fires while the grant is on the epoch seen here. +func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) { + var g *model.Grant + var u *model.User + err := s.ds.WithTx(func(tx model.DataStore) error { + var err error + if g, err = tx.Grant().Get(ctx, grantID); err != nil { + return err + } + u, err = tx.User().Get(ctx, g.UserID) + return err + }) + if errors.Is(err, model.ErrNotFound) { + s.cache.evict(grantID) + return nil, nil, model.ErrInvalidAuth + } + if err != nil { + return nil, nil, err + } + if g.UserEpoch != u.TokenEpoch { + s.cache.evict(grantID) + if err := s.ds.Grant().DeleteIfEpoch(ctx, grantID, g.UserEpoch); err != nil { + log.Warn(ctx, "API v1: could not delete dead grant", "grant", grantID, err) + } + return nil, nil, model.ErrInvalidAuth + } + return g, u, nil +} + +// touch writes last_used at most every touchInterval; the SQL condition keeps that true across nodes. +func (s *Service) touch(ctx context.Context, id, ip string, lastUsed *time.Time) { + now := s.now() + if lastUsed != nil && now.Before(lastUsed.Add(touchInterval)) { + return + } + if err := s.ds.Grant().Touch(ctx, id, ip, now, now.Add(-touchInterval)); err != nil { + log.Warn(ctx, "API v1: could not record grant use", "grant", id, err) + return + } + s.cache.markUsed(id, now) +} + +func (s *Service) Authenticate(ctx context.Context, token, ip string) (*Principal, error) { + sg, err := s.signer() + if err != nil { + return nil, err + } + c, err := sg.parse(token) + if err != nil { + return nil, err + } + u, err := s.loadUser(ctx, c.UserID) + if err != nil { + return nil, err + } + return &Principal{User: *u, GrantID: c.GrantID, Scopes: c.Scopes}, nil +} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go new file mode 100644 index 000000000..706e09ca3 --- /dev/null +++ b/core/apiauth/service_test.go @@ -0,0 +1,159 @@ +package apiauth + +import ( + "context" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} + +var _ = Describe("Service: grants and tokens", func() { + var ctx context.Context + var svc *Service + var now time.Time + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + now = time.Now().UTC().Truncate(time.Second) + svc = New(realDS) + svc.SetClock(func() time.Time { return now }) + }) + + Describe("Login", func() { + It("creates a grant storing all, the user's epoch and the client metadata", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Secret).To(HavePrefix("ndg_")) + Expect(issued.User.ID).To(Equal(u.ID)) + Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll})) + Expect(issued.Grant.Provider).To(Equal("password")) + Expect(issued.Grant.Name).To(Equal("Living room")) + Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch)) + + stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret)) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.ID).To(Equal(issued.Grant.ID)) + }) + + It("defaults the grant name to the client", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Grant.Name).To(Equal("OnlyClient")) + }) + + It("accepts the username in any case", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.User.ID).To(Equal(u.ID)) + }) + + It("stores only known requested scopes", func() { + u := createUser(ctx, "pw", false) + issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead})) + }) + + It("fails with ErrInvalidAuth for bad credentials", func() { + u := createUser(ctx, "pw", false) + _, err := svc.Login(ctx, u.UserName, "wrong", meta, nil) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("Setup", func() { + It("refuses when users exist", func() { + createUser(ctx, "pw", false) + _, err := svc.Setup(ctx, "newadmin", "pw", meta, nil) + Expect(err).To(MatchError(auth.ErrSetupComplete)) + }) + // The empty-database path is covered end to end in server/apiv1, which owns a fresh DB. + }) + + Describe("ResolveGrant and Mint", func() { + It("mints a token with the grant's expanded scopes and a 1h lifetime", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + + p, err := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") + Expect(err).ToNot(HaveOccurred()) + Expect(p.GrantID).To(Equal(issued.Grant.ID)) + Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) + + tok, err := svc.Mint(ctx, p, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(tok.ExpiresIn).To(Equal(time.Hour)) + Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) + + principal, err := svc.Authenticate(ctx, tok.Token, "10.0.0.9") + Expect(err).ToNot(HaveOccurred()) + Expect(principal.User.ID).To(Equal(u.ID)) + }) + + It("attenuates to the requested subset", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + p, _ := svc.ResolveGrant(ctx, issued.Secret, "") + tok, err := svc.Mint(ctx, p, []string{"read", "sync"}) + Expect(err).ToNot(HaveOccurred()) + Expect(tok.Scopes).To(Equal([]string{ScopeRead})) + }) + + It("counts minting as use", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + p, _ := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9") + _, err := svc.Mint(ctx, p, nil) + Expect(err).ToNot(HaveOccurred()) + g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(g.LastUsedAt).ToNot(BeNil()) + Expect(g.LastUsedIP).To(Equal("10.0.0.9")) + }) + + It("rejects unknown secrets", func() { + _, err := svc.ResolveGrant(ctx, "ndg_unknown", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("deletes and rejects a grant idle for 90 days, including one never used", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + now = now.Add(IdleExpiry + time.Second) + _, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + _, err = realDS.Grant().Get(ctx, issued.Grant.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("rejects a grant whose epoch is behind the user's", func() { + u := createUser(ctx, "pw", false) + issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) + u.NewPassword = "changed-elsewhere" + Expect(realDS.User().Put(ctx, &u)).To(Succeed()) + _, err := svc.ResolveGrant(ctx, issued.Secret, "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Describe("PasswordChangeable", func() { + It("follows EnableUserEditing for non-admins only", func() { + conf.Server.EnableUserEditing = false + Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue()) + Expect(PasswordChangeable(model.User{})).To(BeFalse()) + conf.Server.EnableUserEditing = true + Expect(PasswordChangeable(model.User{})).To(BeTrue()) + }) + }) +})