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
This commit is contained in:
2026-01-29 09:23:34 -05:00
parent db18aa5e5e
commit 311361a2ed
4 changed files with 251 additions and 1 deletions
+121
View File
@@ -0,0 +1,121 @@
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()
}
}
+96
View File
@@ -0,0 +1,96 @@
package middleware
import (
"fmt"
"regexp"
"github.com/go-playground/validator/v10"
)
// PasswordValidator validates password complexity requirements
type PasswordValidator struct{}
// Validate checks if a password meets complexity requirements:
// - Minimum 8 characters
// - At least one uppercase letter
// - At least one lowercase letter
// - At least one number
// - At least one special character
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
password := fl.Field().String()
// Check minimum length
if len(password) < 8 {
return false
}
// Check for uppercase
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
if !hasUpper {
return false
}
// Check for lowercase
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
if !hasLower {
return false
}
// Check for number
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
if !hasNumber {
return false
}
// Check for special character
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
if !hasSpecial {
return false
}
return true
}
// GetPasswordRequirements returns a human-readable list of password requirements
func GetPasswordRequirements() []string {
return []string{
"At least 8 characters long",
"At least one uppercase letter (A-Z)",
"At least one lowercase letter (a-z)",
"At least one number (0-9)",
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
}
}
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
func ValidatePassword(password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one uppercase letter")
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one lowercase letter")
}
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("password must contain at least one number")
}
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
return fmt.Errorf("password must contain at least one special character")
}
return nil
}
// RegisterPasswordValidation registers the password validator with the validator instance
func RegisterPasswordValidation(v *validator.Validate) error {
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
pv := &PasswordValidator{}
return pv.Validate(fl)
})
}