This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
104
backend/middleware/bruteforce.go
Normal file
104
backend/middleware/bruteforce.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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]
|
||||
g.mu.Unlock()
|
||||
|
||||
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)
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "too many login attempts, account temporarily locked",
|
||||
"retry_after": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
// Lockout expired, reset
|
||||
g.mu.Lock()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in a new issue