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>
339 lines
9 KiB
Go
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
|
|
}
|