🏡 index : quine-global/quintodrome.git

package subsonic

import (
	"cmp"
	"context"
	"hash/maphash"
	"sync"
	"time"

	"github.com/navidrome/navidrome/consts"
)

// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures
// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait.
type authLimiter struct {
	limit     int
	window    time.Duration
	seed      maphash.Seed
	mu        sync.Mutex
	keys      map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory
	lastSweep time.Time
}

type authAttempts struct {
	failures int
	start    time.Time
	slots    chan struct{}
	refs     int
}

// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled
// limiter hands back.
type authSlot struct {
	limiter *authLimiter
	entry   *authAttempts
}

// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything.
func newAuthLimiter(limit int, window time.Duration) *authLimiter {
	if limit <= 0 {
		return nil
	}
	return &authLimiter{
		limit:  limit,
		window: cmp.Or(window, consts.DefaultAuthWindowLength),
		seed:   maphash.MakeSeed(),
		keys:   map[uint64]*authAttempts{},
	}
}

// acquire reserves a credential check for key, waiting while other checks for the same key are in
// flight. It only fails when the key already reached `limit` failures in the current window.
func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) {
	if l == nil {
		return nil, true
	}
	a, ok := l.reserve(key)
	if !ok {
		return nil, false
	}

	select {
	case a.slots <- struct{}{}:
	case <-ctx.Done():
		l.unref(a)
		return nil, false
	}

	l.mu.Lock()
	blocked := a.failures >= l.limit
	if blocked {
		a.refs--
	}
	l.mu.Unlock()
	if blocked {
		<-a.slots
		return nil, false
	}
	return &authSlot{limiter: l, entry: a}, true
}

func (l *authLimiter) reserve(key string) (*authAttempts, bool) {
	now := time.Now()
	l.mu.Lock()
	defer l.mu.Unlock()
	l.sweep(now)

	h := maphash.String(l.seed, key)
	a := l.keys[h]
	switch {
	case a == nil:
		a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)}
		l.keys[h] = a
	case now.Sub(a.start) >= l.window:
		a.failures, a.start = 0, now
	}
	if a.failures >= l.limit {
		return nil, false
	}
	a.refs++
	return a, true
}

func (l *authLimiter) unref(a *authAttempts) {
	l.mu.Lock()
	a.refs--
	l.mu.Unlock()
}

func (s *authSlot) release(failed bool) {
	if s == nil {
		return
	}
	s.limiter.mu.Lock()
	if failed {
		s.entry.failures++
	}
	s.entry.refs--
	s.limiter.mu.Unlock()
	<-s.entry.slots
}

// sweep drops idle expired keys once per window, so memory is bounded by recent attempts.
func (l *authLimiter) sweep(now time.Time) {
	if now.Sub(l.lastSweep) < l.window {
		return
	}
	for h, a := range l.keys {
		if a.refs == 0 && now.Sub(a.start) >= l.window {
			delete(l.keys, h)
		}
	}
	l.lastSweep = now
}