mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-25 02:37:22 +02:00
134 lines
2.8 KiB
Go
134 lines
2.8 KiB
Go
|
|
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
|
||
|
|
}
|