feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
Every password-shaped check was mounted bare, so guessing was limited only by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them: - login: 10 failures per account and 50 per address per 15 min, checked before the user lookup and bcrypt; 429 with Retry-After. A success clears the account's count but not the address's. - unknown usernames run a dummy bcrypt compare, so timing no longer says which accounts exist. - register: 10 per address per hour; forgot-password: 5 per address and 3 per email per hour (applied whether or not the email matches); reset: 20 failed tokens per address per 15 min. - Subsonic /rest: same limits as login, counting only wrong credentials, since clients authenticate on every request. Web login, register, reset and forgot-password screens say how long to wait; web and Android carry copy for the rate_limited code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AttemptLimiter caps how many attempts a key may make inside a fixed
|
||||
// window. It is the throttle in front of every password-shaped check:
|
||||
// native login, register, forgot/reset password and Subsonic /rest auth.
|
||||
//
|
||||
// In memory on purpose. Minstrel is a single process, the counts only need
|
||||
// to outlive a guessing run rather than a restart, and a table would put a
|
||||
// write on every failed login. Each key costs one small struct, and expired
|
||||
// keys are swept as the map grows, so a spray across many addresses cannot
|
||||
// hold memory past one window.
|
||||
type AttemptLimiter struct {
|
||||
max int
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*attemptBucket
|
||||
nextSweep int
|
||||
}
|
||||
|
||||
type attemptBucket struct {
|
||||
count int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// sweepFloor is the map size below which expired keys are left in place;
|
||||
// past it, a sweep runs whenever the map doubles from its last swept size.
|
||||
const sweepFloor = 1024
|
||||
|
||||
// NewAttemptLimiter returns a limiter allowing max attempts per key per
|
||||
// window.
|
||||
func NewAttemptLimiter(max int, window time.Duration) *AttemptLimiter {
|
||||
return &AttemptLimiter{
|
||||
max: max,
|
||||
window: window,
|
||||
now: time.Now,
|
||||
buckets: map[string]*attemptBucket{},
|
||||
nextSweep: sweepFloor,
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked reports whether key has used its attempts for the current window,
|
||||
// and if so how long until the window resets. It records nothing, so a check
|
||||
// can run before the expensive work and the outcome be recorded after.
|
||||
func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) {
|
||||
if l == nil || key == "" {
|
||||
return false, 0
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
return false, 0
|
||||
}
|
||||
now := l.now()
|
||||
if now.Sub(b.start) >= l.window {
|
||||
delete(l.buckets, key)
|
||||
return false, 0
|
||||
}
|
||||
if b.count < l.max {
|
||||
return false, 0
|
||||
}
|
||||
return true, b.start.Add(l.window).Sub(now)
|
||||
}
|
||||
|
||||
// Record counts one attempt against key.
|
||||
func (l *AttemptLimiter) Record(key string) {
|
||||
if l == nil || key == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
b, ok := l.buckets[key]
|
||||
if !ok || now.Sub(b.start) >= l.window {
|
||||
l.buckets[key] = &attemptBucket{count: 1, start: now}
|
||||
l.maybeSweep(now)
|
||||
return
|
||||
}
|
||||
b.count++
|
||||
}
|
||||
|
||||
// Reset forgets key, as after a successful login: the user who finally got
|
||||
// their password right should not carry their typos into the next window.
|
||||
func (l *AttemptLimiter) Reset(key string) {
|
||||
if l == nil || key == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.buckets, key)
|
||||
}
|
||||
|
||||
// maybeSweep drops expired buckets once the map has doubled since the last
|
||||
// sweep. Callers hold l.mu.
|
||||
func (l *AttemptLimiter) maybeSweep(now time.Time) {
|
||||
if len(l.buckets) < l.nextSweep {
|
||||
return
|
||||
}
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.start) >= l.window {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
l.nextSweep = max(sweepFloor, 2*len(l.buckets))
|
||||
}
|
||||
|
||||
// LoginGuard pairs a per-account and a per-address limiter, the shape every
|
||||
// password check uses. The account limit stops a slow guess at one user from
|
||||
// many addresses; the address limit stops one address spraying many users.
|
||||
// Only failures are recorded, so a user who signs in correctly is never
|
||||
// counted at all.
|
||||
type LoginGuard struct {
|
||||
account *AttemptLimiter
|
||||
address *AttemptLimiter
|
||||
}
|
||||
|
||||
// Login limits: 10 failures per account and 50 per address per 15 minutes,
|
||||
// the same numbers ThoughtSync settled on. Generous enough that a user
|
||||
// fumbling a password manager never meets them, tight enough that an online
|
||||
// guess against bcrypt gets ~1,000 tries a day per account.
|
||||
const (
|
||||
loginWindow = 15 * time.Minute
|
||||
loginAccountMax = 10
|
||||
loginAddressMax = 50
|
||||
)
|
||||
|
||||
// NewLoginGuard returns a guard with the default login limits.
|
||||
func NewLoginGuard() *LoginGuard {
|
||||
return &LoginGuard{
|
||||
account: NewAttemptLimiter(loginAccountMax, loginWindow),
|
||||
address: NewAttemptLimiter(loginAddressMax, loginWindow),
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked reports whether either the account or the address is over its
|
||||
// limit, with the longer of the two waits. Check it BEFORE verifying the
|
||||
// password, so a blocked guess costs no bcrypt.
|
||||
func (g *LoginGuard) Blocked(account, address string) (bool, time.Duration) {
|
||||
if g == nil {
|
||||
return false, 0
|
||||
}
|
||||
aBlocked, aWait := g.account.Blocked(accountKey(account))
|
||||
ipBlocked, ipWait := g.address.Blocked(address)
|
||||
return aBlocked || ipBlocked, max(aWait, ipWait)
|
||||
}
|
||||
|
||||
// Fail records a failed attempt against both the account and the address.
|
||||
// An unknown username counts against its name all the same, so the limit
|
||||
// gives away nothing about which accounts exist.
|
||||
func (g *LoginGuard) Fail(account, address string) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.account.Record(accountKey(account))
|
||||
g.address.Record(address)
|
||||
}
|
||||
|
||||
// Succeed clears the account's failures. The address keeps its count: one
|
||||
// address that guessed fifty accounts and got one right is still spraying.
|
||||
func (g *LoginGuard) Succeed(account string) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.account.Reset(accountKey(account))
|
||||
}
|
||||
|
||||
// accountKey folds case so "Admin" and "admin" share one budget. Usernames
|
||||
// are compared exactly by the lookup, but the guesser shouldn't get a fresh
|
||||
// allowance per capitalisation.
|
||||
func accountKey(account string) string {
|
||||
return strings.ToLower(strings.TrimSpace(account))
|
||||
}
|
||||
|
||||
var (
|
||||
dummyHashOnce sync.Once
|
||||
dummyHash []byte
|
||||
)
|
||||
|
||||
// DummyVerify spends the same bcrypt time a real password check would, for
|
||||
// the path where the username doesn't exist. Without it, an unknown user
|
||||
// answers in microseconds and a known one in ~50ms, and the uniform error
|
||||
// message hides nothing.
|
||||
func DummyVerify(plaintext string) {
|
||||
dummyHashOnce.Do(func() {
|
||||
// What the hash is of doesn't matter — no account carries it. Its
|
||||
// cost does: DefaultCost, the same as every stored password.
|
||||
h, err := bcrypt.GenerateFromPassword([]byte("minstrel-dummy-password"), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
dummyHash = h
|
||||
}
|
||||
})
|
||||
if dummyHash != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(plaintext))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock lets a test step through a window without sleeping.
|
||||
type fakeClock struct{ t time.Time }
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.t }
|
||||
|
||||
func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) {
|
||||
c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)}
|
||||
l := NewAttemptLimiter(max, window)
|
||||
l.now = c.now
|
||||
return l, c
|
||||
}
|
||||
|
||||
func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) {
|
||||
l, c := newTestLimiter(3, 15*time.Minute)
|
||||
for i := 0; i < 3; i++ {
|
||||
if blocked, _ := l.Blocked("k"); blocked {
|
||||
t.Fatalf("blocked after %d attempts, want allowed below max", i)
|
||||
}
|
||||
l.Record("k")
|
||||
}
|
||||
c.t = c.t.Add(5 * time.Minute)
|
||||
blocked, wait := l.Blocked("k")
|
||||
if !blocked {
|
||||
t.Fatal("not blocked after max attempts")
|
||||
}
|
||||
if wait != 10*time.Minute {
|
||||
t.Errorf("wait = %v, want the 10m left in the window", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter_WindowExpiryClears(t *testing.T) {
|
||||
l, c := newTestLimiter(1, time.Minute)
|
||||
l.Record("k")
|
||||
if blocked, _ := l.Blocked("k"); !blocked {
|
||||
t.Fatal("want blocked inside the window")
|
||||
}
|
||||
c.t = c.t.Add(time.Minute)
|
||||
if blocked, _ := l.Blocked("k"); blocked {
|
||||
t.Fatal("still blocked once the window has passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) {
|
||||
l, _ := newTestLimiter(1, time.Minute)
|
||||
l.Record("a")
|
||||
if blocked, _ := l.Blocked("b"); blocked {
|
||||
t.Fatal("one key's attempts blocked another")
|
||||
}
|
||||
l.Reset("a")
|
||||
if blocked, _ := l.Blocked("a"); blocked {
|
||||
t.Fatal("Reset did not clear the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) {
|
||||
l, c := newTestLimiter(5, time.Minute)
|
||||
// One short of the floor: no sweep yet, however stale these become.
|
||||
for i := 0; i < sweepFloor-1; i++ {
|
||||
l.Record("k" + time.Duration(i).String())
|
||||
}
|
||||
c.t = c.t.Add(2 * time.Minute)
|
||||
l.Record("fresh") // reaches the floor and triggers a sweep
|
||||
if n := len(l.buckets); n != 1 {
|
||||
t.Errorf("buckets after sweep = %d, want only the fresh key", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) {
|
||||
var l *AttemptLimiter
|
||||
l.Record("k")
|
||||
if blocked, _ := l.Blocked("k"); blocked {
|
||||
t.Error("nil limiter blocked")
|
||||
}
|
||||
real, _ := newTestLimiter(1, time.Minute)
|
||||
real.Record("")
|
||||
if blocked, _ := real.Blocked(""); blocked {
|
||||
t.Error("empty key was counted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) {
|
||||
g := NewLoginGuard()
|
||||
for i := 0; i < loginAccountMax; i++ {
|
||||
g.Fail("Alice", "10.0.0."+string(rune('0'+i%10)))
|
||||
}
|
||||
if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked {
|
||||
t.Fatal("account limit should hold from a fresh address and any capitalisation")
|
||||
}
|
||||
g.Succeed("ALICE")
|
||||
if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked {
|
||||
t.Fatal("Succeed should clear the account's failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) {
|
||||
g := NewLoginGuard()
|
||||
for i := 0; i < loginAddressMax; i++ {
|
||||
g.Fail("user"+time.Duration(i).String(), "203.0.113.9")
|
||||
}
|
||||
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
|
||||
t.Fatal("address that sprayed many accounts should be blocked")
|
||||
}
|
||||
g.Succeed("someone-new")
|
||||
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
|
||||
t.Fatal("a success must not clear the address count")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user