Files
descrybe/apps/api/internal/httpapi/login_lockout.go
T

136 lines
3.2 KiB
Go
Raw Normal View History

package httpapi
import (
"strings"
"sync"
"time"
)
// Email-keyed login lockout (in-process, per API replica).
//
// Complements IP RateLimitAuth: rotating IPs still hit the same email budget.
// ASSUMPTION (Product 10): a single API instance (or acknowledged per-replica
// memory) is acceptable — same posture as HTTP rate limiters in ratelimit.go.
// RATE_LIMIT_REPLICAS does not divide this lockout; multi-replica hard caps need edge/WAF.
// Captcha is deferred; lockout + IP RPM are the primary login abuse controls.
const (
loginLockoutMaxFails = 5
loginLockoutDuration = 15 * time.Minute
)
type loginLockState struct {
fails int
windowStart time.Time
lockedUntil time.Time
}
// loginAttemptLockout tracks failed password attempts by normalized email.
type loginAttemptLockout struct {
mu sync.Mutex
maxFails int
lockFor time.Duration
state map[string]*loginLockState
}
func newLoginAttemptLockout(maxFails int, lockFor time.Duration) *loginAttemptLockout {
if maxFails < 1 {
maxFails = loginLockoutMaxFails
}
if lockFor <= 0 {
lockFor = loginLockoutDuration
}
return &loginAttemptLockout{
maxFails: maxFails,
lockFor: lockFor,
state: make(map[string]*loginLockState),
}
}
func normalizeLoginEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// locked reports whether email is currently locked and Retry-After seconds.
func (l *loginAttemptLockout) locked(email string) (bool, int) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return false, 0
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.state[key]
if st == nil {
return false, 0
}
if st.lockedUntil.After(now) {
sec := int(st.lockedUntil.Sub(now).Seconds()) + 1
if sec < 1 {
sec = 1
}
return true, sec
}
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
// Lock expired — reset failure window.
delete(l.state, key)
}
return false, 0
}
// recordFailure increments the failure count for email; locks after maxFails
// within the lock window. No-ops for empty email.
func (l *loginAttemptLockout) recordFailure(email string) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.state[key]
if st == nil {
st = &loginLockState{windowStart: now}
l.state[key] = st
}
if st.lockedUntil.After(now) {
return
}
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
st.fails = 0
st.windowStart = now
st.lockedUntil = time.Time{}
}
if now.Sub(st.windowStart) > l.lockFor {
st.fails = 0
st.windowStart = now
}
st.fails++
if st.fails >= l.maxFails {
st.lockedUntil = now.Add(l.lockFor)
st.fails = 0
st.windowStart = now
}
}
// clear resets failures and lock for email (successful login).
func (l *loginAttemptLockout) clear(email string) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
delete(l.state, key)
}
func (s *Server) loginAttempts() *loginAttemptLockout {
if s == nil {
return newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
}
s.loginLockoutOnce.Do(func() {
s.loginLockout = newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
})
return s.loginLockout
}