Files
bookhoard/internal/middleware/login_attempts.go
T
john-okeefe 311361a2ed feat(security): add password complexity validator
- Implement strict password requirements:
  - Minimum 8 characters
  - At least one uppercase letter
  - At least one lowercase letter
  - At least one number
  - At least one special character
- Add custom validator for Echo integration
- Add GetPasswordRequirements helper function
- Add ValidatePassword function for manual validation
2026-01-29 09:23:34 -05:00

122 lines
2.8 KiB
Go

package middleware
import (
"sync"
"time"
)
// LoginAttemptTracker tracks failed login attempts per IP and username
type LoginAttemptTracker struct {
mu sync.RWMutex
attempts map[string]*AttemptInfo
maxAttempts int
lockoutDuration time.Duration
cleanupInterval time.Duration
}
// AttemptInfo stores information about login attempts
type AttemptInfo struct {
AttemptCount int
LockedUntil time.Time
LastAttempt time.Time
}
// NewLoginAttemptTracker creates a new login attempt tracker
func NewLoginAttemptTracker(maxAttempts int, lockoutDuration, cleanupInterval time.Duration) *LoginAttemptTracker {
tracker := &LoginAttemptTracker{
attempts: make(map[string]*AttemptInfo),
maxAttempts: maxAttempts,
lockoutDuration: lockoutDuration,
cleanupInterval: cleanupInterval,
}
// Start cleanup goroutine
go tracker.cleanup()
return tracker
}
// RecordFailedAttempt records a failed login attempt
func (t *LoginAttemptTracker) RecordFailedAttempt(identifier string) (locked bool, remainingTime time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
info, exists := t.attempts[identifier]
if !exists {
info = &AttemptInfo{
AttemptCount: 1,
LastAttempt: now,
}
t.attempts[identifier] = info
return false, 0
}
// Check if currently locked
if now.Before(info.LockedUntil) {
return true, time.Until(info.LockedUntil)
}
// Reset attempts if last attempt was more than lockoutDuration ago
if now.Sub(info.LastAttempt) > t.lockoutDuration {
info.AttemptCount = 1
} else {
info.AttemptCount++
}
info.LastAttempt = now
// Check if max attempts reached
if info.AttemptCount >= t.maxAttempts {
info.LockedUntil = now.Add(t.lockoutDuration)
return true, t.lockoutDuration
}
return false, 0
}
// IsLocked checks if an identifier is currently locked out
func (t *LoginAttemptTracker) IsLocked(identifier string) (locked bool, remainingTime time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
info, exists := t.attempts[identifier]
if !exists {
return false, 0
}
now := time.Now()
if now.Before(info.LockedUntil) {
return true, time.Until(info.LockedUntil)
}
return false, 0
}
// ClearAttempts clears failed login attempts for an identifier
func (t *LoginAttemptTracker) ClearAttempts(identifier string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.attempts, identifier)
}
// cleanup removes old attempt records periodically
func (t *LoginAttemptTracker) cleanup() {
ticker := time.NewTicker(t.cleanupInterval)
defer ticker.Stop()
for range ticker.C {
t.mu.Lock()
now := time.Now()
for identifier, info := range t.attempts {
// Remove records that are not locked and haven't had recent attempts
if now.After(info.LockedUntil) && now.Sub(info.LastAttempt) > t.lockoutDuration*2 {
delete(t.attempts, identifier)
}
}
t.mu.Unlock()
}
}