mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 11:57:12 +02:00
feat(api): add API v1 login, setup, grant resolution and token minting
This commit is contained in:
parent
8c0ba43e9a
commit
0bbca1b133
7 changed files with 560 additions and 1 deletions
14
core/apiauth/context.go
Normal file
14
core/apiauth/context.go
Normal file
|
|
@ -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
|
||||
}
|
||||
68
core/apiauth/credentials.go
Normal file
68
core/apiauth/credentials.go
Normal file
|
|
@ -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
|
||||
}
|
||||
63
core/apiauth/credentials_test.go
Normal file
63
core/apiauth/credentials_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
11
core/apiauth/export_test.go
Normal file
11
core/apiauth/export_test.go
Normal file
|
|
@ -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 }
|
||||
245
core/apiauth/service.go
Normal file
245
core/apiauth/service.go
Normal file
|
|
@ -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
|
||||
}
|
||||
159
core/apiauth/service_test.go
Normal file
159
core/apiauth/service_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue