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)) } }