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

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:
2026-10-06 08:28:29 -04:00
co-authored by Claude Opus 5.5
parent 516413f4ca
commit 3bfddd0862
20 changed files with 528 additions and 16 deletions
+205
View File
@@ -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))
}
}
+114
View File
@@ -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")
}
}