All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
103 lines
2.3 KiB
Go
103 lines
2.3 KiB
Go
package middleware
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type loginAttempt struct {
|
|
Count int
|
|
LockedAt time.Time
|
|
}
|
|
|
|
// BruteForceGuard tracks login attempts per IP and enforces exponential backoff.
|
|
type BruteForceGuard struct {
|
|
mu sync.Mutex
|
|
attempts map[string]*loginAttempt
|
|
maxAttempts int
|
|
lockoutSecs int
|
|
}
|
|
|
|
func NewBruteForceGuard(maxAttempts, lockoutSecs int) *BruteForceGuard {
|
|
guard := &BruteForceGuard{
|
|
attempts: make(map[string]*loginAttempt),
|
|
maxAttempts: maxAttempts,
|
|
lockoutSecs: lockoutSecs,
|
|
}
|
|
|
|
// Clean up stale entries every 10 minutes
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
guard.cleanup()
|
|
}
|
|
}()
|
|
|
|
return guard
|
|
}
|
|
|
|
// Check returns a Fiber handler that blocks requests from locked-out IPs.
|
|
func (g *BruteForceGuard) Check() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
ip := c.IP()
|
|
|
|
g.mu.Lock()
|
|
attempt, exists := g.attempts[ip]
|
|
|
|
if exists && attempt.Count >= g.maxAttempts {
|
|
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
|
|
if time.Since(attempt.LockedAt) < lockoutDuration {
|
|
remaining := lockoutDuration - time.Since(attempt.LockedAt)
|
|
g.mu.Unlock()
|
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
|
"error": "too many login attempts, account temporarily locked",
|
|
"retry_after": int(remaining.Seconds()),
|
|
})
|
|
}
|
|
// Lockout expired, reset
|
|
delete(g.attempts, ip)
|
|
}
|
|
g.mu.Unlock()
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// RecordFailure increments the failure count for an IP.
|
|
func (g *BruteForceGuard) RecordFailure(ip string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
attempt, exists := g.attempts[ip]
|
|
if !exists {
|
|
g.attempts[ip] = &loginAttempt{Count: 1, LockedAt: time.Now()}
|
|
return
|
|
}
|
|
|
|
attempt.Count++
|
|
if attempt.Count >= g.maxAttempts {
|
|
attempt.LockedAt = time.Now()
|
|
}
|
|
}
|
|
|
|
// RecordSuccess resets the failure count for an IP after a successful login.
|
|
func (g *BruteForceGuard) RecordSuccess(ip string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
delete(g.attempts, ip)
|
|
}
|
|
|
|
func (g *BruteForceGuard) cleanup() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
cutoff := time.Duration(g.lockoutSecs*2) * time.Second
|
|
for ip, attempt := range g.attempts {
|
|
if time.Since(attempt.LockedAt) > cutoff {
|
|
delete(g.attempts, ip)
|
|
}
|
|
}
|
|
}
|