105 lines
3.1 KiB
Go
105 lines
3.1 KiB
Go
package customerauth
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
|
|
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
|
|
// and by RedisLoginLimiter (shared, horizontally scalable).
|
|
type Limiter interface {
|
|
// Allowed reports whether an attempt for key may proceed, and when locked the
|
|
// remaining lockout duration (for a Retry-After header).
|
|
Allowed(ctx context.Context, key string) (bool, time.Duration)
|
|
// RecordFailure registers a failed attempt for key.
|
|
RecordFailure(ctx context.Context, key string)
|
|
// Reset clears the failure history for key (call on a successful auth).
|
|
Reset(ctx context.Context, key string)
|
|
}
|
|
|
|
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
|
|
// too many failures inside a rolling window. It is per-process — matching the
|
|
// credential store's single-instance scope; a horizontally-scaled deployment
|
|
// would need a shared backing store. Keys are typically the normalized email
|
|
// (login) or a "reset:"-prefixed email (reset requests).
|
|
type LoginLimiter struct {
|
|
mu sync.Mutex
|
|
attempts map[string]*attemptState
|
|
max int // failures allowed in the window before lockout
|
|
window time.Duration // rolling window and lockout duration
|
|
}
|
|
|
|
type attemptState struct {
|
|
count int
|
|
first time.Time
|
|
lockedUntil time.Time
|
|
}
|
|
|
|
// NewLoginLimiter builds a limiter allowing max failures per window before a
|
|
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
|
|
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
|
|
if max <= 0 {
|
|
max = 5
|
|
}
|
|
if window <= 0 {
|
|
window = 15 * time.Minute
|
|
}
|
|
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
|
|
}
|
|
|
|
// Allowed reports whether an attempt for key may proceed. When locked it also
|
|
// returns the remaining lockout duration (suitable for a Retry-After header). A
|
|
// nil limiter always allows (disabled).
|
|
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
|
|
if l == nil {
|
|
return true, 0
|
|
}
|
|
now := time.Now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
st := l.attempts[key]
|
|
if st == nil {
|
|
return true, 0
|
|
}
|
|
if now.Before(st.lockedUntil) {
|
|
return false, st.lockedUntil.Sub(now)
|
|
}
|
|
// Window elapsed since the first failure: forget the history.
|
|
if now.Sub(st.first) > l.window {
|
|
delete(l.attempts, key)
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
// RecordFailure registers a failed attempt for key, locking it for window once
|
|
// max failures accumulate inside the window.
|
|
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
st := l.attempts[key]
|
|
if st == nil || now.Sub(st.first) > l.window {
|
|
l.attempts[key] = &attemptState{count: 1, first: now}
|
|
return
|
|
}
|
|
st.count++
|
|
if st.count >= l.max {
|
|
st.lockedUntil = now.Add(l.window)
|
|
}
|
|
}
|
|
|
|
// Reset clears the failure history for key. Call it on a successful auth.
|
|
func (l *LoginLimiter) Reset(_ context.Context, key string) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
l.mu.Lock()
|
|
delete(l.attempts, key)
|
|
l.mu.Unlock()
|
|
}
|