veza/veza-backend-api/internal/validators/password_validator.go
senke 6a675565e1 feat(v0.13.3): complete - Polish Sécurité Avancée
TASK-SECADV-001: WebAuthn/Passkeys (F022)
- WebAuthn credential model, service, handler
- Registration/authentication ceremony endpoints
- CRUD operations (list, rename, delete passkeys)
- Routes: GET/POST/PUT/DELETE /auth/passkeys/*

TASK-SECADV-002: Configurable password policy (F015)
- PasswordPolicyConfig with MinLength, MaxLength, RequireUpper/Lower/Number/Special
- NewPasswordValidatorWithPolicy constructor
- PasswordPolicyFromEnv() reads env vars (PASSWORD_MIN_LENGTH, etc.)
- All character class checks now respect policy configuration

TASK-SECADV-003: Géolocalisation connexions (F025)
- GeoIPResolver interface + GeoIPService implementation
- Country/city columns added to login_history table
- LoginHistoryService.Record() performs GeoIP lookup
- GetUserHistory returns geolocation data
- GET /auth/login-history endpoint

TASK-SECADV-004: Password expiration (F016)
- password_changed_at column on users table
- CheckPasswordExpiration() method on PasswordService
- All password change/reset methods now set password_changed_at
- NewPasswordServiceWithPolicy() supports expiration days config

Migration: 971_security_advanced_v0133.sql

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:09:01 +01:00

339 lines
9 KiB
Go

package validators
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
var (
hasUpper = regexp.MustCompile(`[A-Z]`)
hasLower = regexp.MustCompile(`[a-z]`)
hasNumber = regexp.MustCompile(`[0-9]`)
hasSpecial = regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`)
// Common weak passwords list
commonPasswords = []string{
"password", "12345678", "123456789", "1234567890",
"qwerty", "abc123", "monkey", "1234567", "letmein",
"trustno1", "dragon", "baseball", "iloveyou", "master",
"sunshine", "ashley", "bailey", "passw0rd", "shadow",
"123123", "654321", "superman", "qazwsx", "michael",
"football", "welcome", "jesus", "ninja", "mustang",
"password1", "admin", "1234", "12345", "123456",
}
)
// PasswordPolicyConfig holds configurable password policy settings.
// F015: Configurable password policy.
type PasswordPolicyConfig struct {
MinLength int // Minimum password length (default 12)
MaxLength int // Maximum password length (default 128)
RequireUpper bool // Require uppercase letter (default true)
RequireLower bool // Require lowercase letter (default true)
RequireNumber bool // Require digit (default true)
RequireSpecial bool // Require special character (default true)
}
// DefaultPasswordPolicy returns the default (strict) policy.
func DefaultPasswordPolicy() PasswordPolicyConfig {
return PasswordPolicyConfig{
MinLength: 12,
MaxLength: 128,
RequireUpper: true,
RequireLower: true,
RequireNumber: true,
RequireSpecial: true,
}
}
// PasswordValidator valide la force d'un mot de passe
type PasswordValidator struct {
MinLength int
Policy PasswordPolicyConfig
}
// NewPasswordValidator crée une nouvelle instance de PasswordValidator
func NewPasswordValidator() *PasswordValidator {
p := DefaultPasswordPolicy()
return &PasswordValidator{MinLength: p.MinLength, Policy: p}
}
// NewPasswordValidatorWithPolicy creates a validator with a custom policy.
// F015: Configurable password policy.
func NewPasswordValidatorWithPolicy(policy PasswordPolicyConfig) *PasswordValidator {
if policy.MinLength < 8 {
policy.MinLength = 8 // Absolute minimum
}
if policy.MaxLength <= 0 {
policy.MaxLength = 128
}
return &PasswordValidator{MinLength: policy.MinLength, Policy: policy}
}
// PasswordStrength représente le résultat de la validation d'un mot de passe
type PasswordStrength struct {
Valid bool
Score int
Details []string
}
// Validate valide la force d'un mot de passe selon les règles définies
// BE-SEC-006: Implement comprehensive password strength validation
func (v *PasswordValidator) Validate(password string) (PasswordStrength, error) {
strength := PasswordStrength{
Valid: true,
Details: []string{},
}
// Length check
if len(password) < v.MinLength {
strength.Valid = false
strength.Details = append(strength.Details,
fmt.Sprintf("Password must be at least %d characters long", v.MinLength))
return strength, nil
}
// Maximum length check (prevent DoS)
maxLen := v.Policy.MaxLength
if maxLen <= 0 {
maxLen = 128
}
if len(password) > maxLen {
strength.Valid = false
strength.Details = append(strength.Details,
fmt.Sprintf("Password must be less than %d characters", maxLen))
return strength, nil
}
// Check for common weak passwords
// Only reject if password IS exactly a common password
// This is less aggressive than checking if password contains common words,
// which would reject valid passwords like "VerySecurePassword123!@#"
passwordLower := strings.ToLower(password)
for _, common := range commonPasswords {
// Exact match only
if passwordLower == common {
strength.Valid = false
strength.Details = append(strength.Details,
"Password contains common words or patterns")
return strength, nil
}
}
// Check for repetitive patterns (e.g., "aaaa", "1234", "abcd")
if hasRepetitivePattern(password) {
strength.Valid = false
strength.Details = append(strength.Details,
"Password contains repetitive patterns")
return strength, nil
}
// Check for sequential patterns (e.g., "1234", "abcd", "qwerty")
if hasSequentialPattern(password) {
strength.Valid = false
strength.Details = append(strength.Details,
"Password contains sequential patterns")
return strength, nil
}
// Upper case check (F015: configurable)
if v.Policy.RequireUpper {
if !hasUpper.MatchString(password) {
strength.Valid = false
strength.Details = append(strength.Details, "Must contain uppercase letter")
} else {
strength.Score++
}
}
// Lower case check (F015: configurable)
if v.Policy.RequireLower {
if !hasLower.MatchString(password) {
strength.Valid = false
strength.Details = append(strength.Details, "Must contain lowercase letter")
} else {
strength.Score++
}
}
// Number check (F015: configurable)
if v.Policy.RequireNumber {
if !hasNumber.MatchString(password) {
strength.Valid = false
strength.Details = append(strength.Details, "Must contain number")
} else {
strength.Score++
}
}
// Special character check (F015: configurable)
if v.Policy.RequireSpecial {
if !hasSpecial.MatchString(password) {
strength.Valid = false
strength.Details = append(strength.Details, "Must contain special character")
} else {
strength.Score++
}
}
return strength, nil
}
// hasRepetitivePattern checks if password contains repetitive characters
func hasRepetitivePattern(password string) bool {
if len(password) < 4 {
return false
}
// Check for 4+ consecutive identical characters
count := 1
for i := 1; i < len(password); i++ {
if password[i] == password[i-1] {
count++
if count >= 4 {
return true
}
} else {
count = 1
}
}
return false
}
// hasSequentialPattern checks if password contains sequential characters
func hasSequentialPattern(password string) bool {
if len(password) < 4 {
return false
}
passwordLower := strings.ToLower(password)
// Check for sequential patterns (e.g., "1234", "abcd", "qwerty")
sequences := []string{
"0123456789", "9876543210",
"abcdefghijklmnopqrstuvwxyz", "zyxwvutsrqponmlkjihgfedcba",
"qwertyuiop", "poiuytrewq",
"asdfghjkl", "lkjhgfdsa",
"zxcvbnm", "mnbvcxz",
}
for _, seq := range sequences {
for i := 0; i <= len(seq)-4; i++ {
subseq := seq[i : i+4]
if strings.Contains(passwordLower, subseq) {
return true
}
}
}
return false
}
// ValidatePasswordChange validates a new password against the old password
// BE-SEC-006: Ensure new password is sufficiently different from old password
func (v *PasswordValidator) ValidatePasswordChange(newPassword, oldPassword string) (PasswordStrength, error) {
strength, err := v.Validate(newPassword)
if err != nil || !strength.Valid {
return strength, err
}
// Check if new password is too similar to old password
if oldPassword != "" {
similarity := calculateSimilarity(newPassword, oldPassword)
if similarity > 0.7 {
strength.Valid = false
strength.Details = append(strength.Details,
"New password is too similar to the old password")
return strength, nil
}
// Check if new password contains old password
if strings.Contains(strings.ToLower(newPassword), strings.ToLower(oldPassword)) {
strength.Valid = false
strength.Details = append(strength.Details,
"New password cannot contain the old password")
return strength, nil
}
}
return strength, nil
}
// calculateSimilarity calculates the similarity ratio between two strings
func calculateSimilarity(s1, s2 string) float64 {
if len(s1) == 0 && len(s2) == 0 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
// Simple Levenshtein-like similarity
maxLen := len(s1)
if len(s2) > maxLen {
maxLen = len(s2)
}
matches := 0
minLen := len(s1)
if len(s2) < minLen {
minLen = len(s2)
}
for i := 0; i < minLen; i++ {
if s1[i] == s2[i] {
matches++
}
}
return float64(matches) / float64(maxLen)
}
// PasswordPolicyFromEnv reads password policy config from environment variables.
// F015: Configurable password policy.
// Env vars:
// - PASSWORD_MIN_LENGTH (default 12, minimum 8)
// - PASSWORD_MAX_LENGTH (default 128)
// - PASSWORD_REQUIRE_UPPER (default true)
// - PASSWORD_REQUIRE_LOWER (default true)
// - PASSWORD_REQUIRE_NUMBER (default true)
// - PASSWORD_REQUIRE_SPECIAL (default true)
func PasswordPolicyFromEnv() PasswordPolicyConfig {
p := DefaultPasswordPolicy()
if v := os.Getenv("PASSWORD_MIN_LENGTH"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 8 {
p.MinLength = n
}
}
if v := os.Getenv("PASSWORD_MAX_LENGTH"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
p.MaxLength = n
}
}
if v := os.Getenv("PASSWORD_REQUIRE_UPPER"); v != "" {
p.RequireUpper = parseBool(v, true)
}
if v := os.Getenv("PASSWORD_REQUIRE_LOWER"); v != "" {
p.RequireLower = parseBool(v, true)
}
if v := os.Getenv("PASSWORD_REQUIRE_NUMBER"); v != "" {
p.RequireNumber = parseBool(v, true)
}
if v := os.Getenv("PASSWORD_REQUIRE_SPECIAL"); v != "" {
p.RequireSpecial = parseBool(v, true)
}
return p
}
func parseBool(s string, defaultVal bool) bool {
b, err := strconv.ParseBool(s)
if err != nil {
return defaultVal
}
return b
}