Files
mats 528c59bfd3
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
cart and checkout
2026-06-27 19:49:00 +02:00

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