Files
2026-05-10 18:07:51 +02:00

93 lines
1.9 KiB
Go

package auth
import (
"sync"
"time"
)
const (
maxAttempts = 5
windowMinutes = 15
lockMinutes = 15
)
type entry struct {
count int
firstAt time.Time
lockedUntil time.Time
}
// RateLimiter tracks failed login attempts per Discord ID in memory.
// Resets on bot restart (by design — avoids DB writes on every auth failure).
type RateLimiter struct {
mu sync.Mutex
entries map[string]*entry
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{entries: make(map[string]*entry)}
}
// Allow returns true if the given ID is not currently rate-limited.
func (r *RateLimiter) Allow(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok {
return true
}
if !e.lockedUntil.IsZero() {
if time.Now().Before(e.lockedUntil) {
return false
}
// Lock expired — reset
delete(r.entries, id)
}
return true
}
// Record records a failed attempt. Returns true if the account is now locked.
func (r *RateLimiter) Record(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok {
e = &entry{firstAt: time.Now()}
r.entries[id] = e
}
// Reset window if outside window duration
if time.Since(e.firstAt) > windowMinutes*time.Minute {
e.count = 0
e.firstAt = time.Now()
e.lockedUntil = time.Time{}
}
e.count++
if e.count >= maxAttempts {
e.lockedUntil = time.Now().Add(lockMinutes * time.Minute)
return true
}
return false
}
// Reset clears the rate limit entry for a Discord ID (call on successful login).
func (r *RateLimiter) Reset(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.entries, id)
}
// RemainingLock returns the remaining lock duration (zero if not locked).
func (r *RateLimiter) RemainingLock(id string) time.Duration {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[id]
if !ok || e.lockedUntil.IsZero() {
return 0
}
remaining := time.Until(e.lockedUntil)
if remaining < 0 {
return 0
}
return remaining
}