Documentation
¶
Overview ¶
Package lockout provides the login account-lockout limiter and the admin-API rate limiter. It is a self-contained leaf (stdlib only, no Culvert coupling) extracted from the flat package main per ADR-0002.
Index ¶
- Constants
- func Msg(seconds int) string
- type APIRateLimiter
- type LockedEntry
- type LoginLimiter
- func (l *LoginLimiter) AttemptsLeft(ip, username string) int
- func (l *LoginLimiter) Check(ip, username string) (locked bool, secondsRemaining int)
- func (l *LoginLimiter) CheckPair(ip, username string) (locked bool, secondsRemaining int)
- func (l *LoginLimiter) Cleanup()
- func (l *LoginLimiter) RecordFailure(ip, username string) bool
- func (l *LoginLimiter) RecordPairFailure(ip, username string) bool
- func (l *LoginLimiter) RecordSuccess(ip, username string)
- func (l *LoginLimiter) ResetUser(username string)
- func (l *LoginLimiter) Snapshot() []LockedEntry
- func (l *LoginLimiter) SnapshotAndClear() func()
Constants ¶
const ( // MaxAttempts is the number of failures from one IP against one username // that triggers a pair lock (tier 1). MaxAttempts = 5 // AccountMaxAttempts is the number of failures against one username // across ALL IPs that triggers the account-wide lock (tier 2). AccountMaxAttempts = 20 // Window is the span within which failures accumulate toward a lock. Window = 10 * time.Minute // Duration is how long a pair/account stays locked once tripped. Duration = 15 * time.Minute // TrustTTL is how long a successful login keeps its client IP exempt // from the tier-2 account lock for that username. TrustTTL = 30 * 24 * time.Hour )
const ( // Burst is the max API mutations allowed per window. Burst = 60 // RateWindow is the sliding window width for the API rate limiter. RateWindow = 1 * time.Minute )
Variables ¶
This section is empty.
Functions ¶
Types ¶
type APIRateLimiter ¶
type APIRateLimiter struct {
// contains filtered or unexported fields
}
APIRateLimiter limits mutating admin API calls per client IP.
func NewAPIRateLimiter ¶
func NewAPIRateLimiter() *APIRateLimiter
NewAPIRateLimiter returns a ready-to-use APIRateLimiter.
func (*APIRateLimiter) Allow ¶
func (a *APIRateLimiter) Allow(ip string) bool
Allow returns true if the IP is within the rate limit for API mutations.
func (*APIRateLimiter) Cleanup ¶
func (a *APIRateLimiter) Cleanup()
Cleanup removes expired entries.
type LockedEntry ¶ added in v1.0.63
type LockedEntry struct {
Tier string `json:"tier"`
Username string `json:"username"`
IP string `json:"ip,omitempty"`
SecondsRemaining int `json:"seconds_remaining"`
}
LockedEntry describes one currently-active lockout, for admin visibility. Tier is "account" (tier-2, blocks every IP for Username) or "pair" (tier-1, blocks only IP against Username). IP is empty for account-tier entries.
type LoginLimiter ¶
type LoginLimiter struct {
// contains filtered or unexported fields
}
LoginLimiter tracks failed login attempts per (client IP, username) pair (tier 1) and per username across IPs (tier 2), with a trusted-IP bypass on tier 2 for IPs that previously logged in successfully.
func NewLoginLimiter ¶
func NewLoginLimiter() *LoginLimiter
NewLoginLimiter returns a ready-to-use LoginLimiter.
func (*LoginLimiter) AttemptsLeft ¶
func (l *LoginLimiter) AttemptsLeft(ip, username string) int
AttemptsLeft returns how many more failures the (ip, username) pair is allowed before the tier-1 lock trips.
func (*LoginLimiter) Check ¶
func (l *LoginLimiter) Check(ip, username string) (locked bool, secondsRemaining int)
Check returns (locked bool, secondsRemaining int) for a login attempt by username from client ip. A locked attempt must not be verified further. The pair lock always applies; the account lock is bypassed for IPs with a live trust grant. Expired entries are lazily deleted.
func (*LoginLimiter) CheckPair ¶ added in v1.0.32
func (l *LoginLimiter) CheckPair(ip, username string) (locked bool, secondsRemaining int)
CheckPair reports the tier-1 (IP, username) pair lock ONLY, ignoring the account tier entirely. It is for callers that want pure per-IP rate limiting with no cross-IP aggregation — notably the pre-provisioning setup endpoint, where there is no account to protect yet and an account-wide counter would let a few IPs globally block bootstrap (lockout-as-DoS).
func (*LoginLimiter) Cleanup ¶ added in v1.0.22
func (l *LoginLimiter) Cleanup()
Cleanup removes entries that can no longer affect a decision, bounding the maps against an unbounded-memory DoS: the pair and account keys derive from the attacker-controlled username (and client IP) on the unauthenticated login POST, so without a sweep each failed attempt could leak a permanent entry. An entry is removable when its lock has expired (a future Check would delete it anyway) or when it is unlocked and its failure window has elapsed (a future RecordFailure would reset it to a fresh window). Trusted grants past TrustTTL are dropped too (they no longer bypass anything). Called periodically by the shared cleanup janitor. Removing a stale entry is behaviorally identical to the lazy reset the hot paths already perform, so it changes no decision.
func (*LoginLimiter) RecordFailure ¶
func (l *LoginLimiter) RecordFailure(ip, username string) bool
RecordFailure registers one failed attempt from ip against username in both tiers. Returns true when this attempt JUST tripped either lock (so the caller can log the lockout event).
func (*LoginLimiter) RecordPairFailure ¶ added in v1.0.32
func (l *LoginLimiter) RecordPairFailure(ip, username string) bool
RecordPairFailure registers one failed attempt against the tier-1 (IP, username) pair ONLY, leaving the account tier untouched. Pair-only companion to CheckPair. Returns true when this attempt JUST tripped the pair lock.
func (*LoginLimiter) RecordSuccess ¶
func (l *LoginLimiter) RecordSuccess(ip, username string)
RecordSuccess clears the pair's failure history and marks ip as trusted for username (TrustTTL, bounded at trustMaxIPs with oldest-first eviction). The tier-2 account counter is deliberately NOT cleared: a victim's successful login must not erase the evidence of a concurrent distributed attack, and the victim doesn't need it cleared — their IP is now trusted.
func (*LoginLimiter) ResetUser ¶ added in v1.0.32
func (l *LoginLimiter) ResetUser(username string)
ResetUser removes ALL failure/lock state for username — the tier-2 account entry and every (ip, username) pair — without touching the trusted-IP set (trust is an allowance earned by a successful login, not lock state). This is the explicit unlock primitive: test isolation helpers use it where the old single-tier code abused RecordSuccess as a full reset, and it is the natural hook for a future admin "unlock account" API.
func (*LoginLimiter) Snapshot ¶ added in v1.0.63
func (l *LoginLimiter) Snapshot() []LockedEntry
Snapshot returns every currently-active lockout across both tiers, sorted by username then IP for stable display. It is the read side of the explicit unlock primitive (ResetUser) — without it, an operator has no way to see who is locked out short of reading logs or waiting/restarting. It does not mutate limiter state; expired entries are simply omitted.
func (*LoginLimiter) SnapshotAndClear ¶
func (l *LoginLimiter) SnapshotAndClear() func()
SnapshotAndClear captures the current limiter state, replaces it with an empty state, and returns a closure that restores the captured state. It is the exported equivalent of the whitebox snapshot/restore idiom used for test isolation of the package-global limiter: the maps are deep-copied under the mutex and the restore runs under the mutex too, so neither tears against a concurrent reader/writer. Production code never calls this; it exists so package main's test isolation helper does not need access to the unexported maps across the package boundary (ADR-0002 extraction).