package middleware import ( "bookhoard/internal/database" "fmt" "regexp" "sync" "github.com/go-playground/validator/v10" ) // specialCharRegex matches the historical "special character" set used by the // password complexity rules. const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]` // PasswordValidator validates password complexity against the configured rules. // When a database.SettingsRegistry is wired via SetSettings, rules are read live and the // regex set is recompiled under a mutex on each validation. Without a registry // the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply. type PasswordValidator struct { settings *database.SettingsRegistry } // SetSettings wires the tunable settings registry. func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s } // compileSpecialRegex isolates the regexp compile (which is safe to call // concurrently, but we keep it behind a cached var for the no-registry path). var ( specialOnce sync.Once specialRe *regexp.Regexp ) func specialRegex() *regexp.Regexp { specialOnce.Do(func() { specialRe = regexp.MustCompile(specialCharRegex) }) return specialRe } func (v *PasswordValidator) rules() database.PasswordRules { if v.settings != nil { return v.settings.PasswordRules() } return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true} } // Validate checks if a password meets the configured complexity requirements. func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool { return v.CheckPassword(fl.Field().String()) } // CheckPassword applies the active rules to a single password. func (v *PasswordValidator) CheckPassword(password string) bool { r := v.rules() if len(password) < r.MinLength { return false } if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) { return false } if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return false } if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return false } if r.Special && !specialRegex().MatchString(password) { return false } return true } // GetPasswordRequirements returns a human-readable list of the active password // requirements, driven by the configured rules when a registry is wired. func GetPasswordRequirements() []string { return defaultPasswordValidator.Requirements() } // Requirements returns the human-readable list for the receiver's active rules. func (v *PasswordValidator) Requirements() []string { r := v.rules() var out []string out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength)) if r.Upper { out = append(out, "At least one uppercase letter (A-Z)") } if r.Lower { out = append(out, "At least one lowercase letter (a-z)") } if r.Number { out = append(out, "At least one number (0-9)") } if r.Special { out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)") } return out } // ValidatePassword checks a password against the default (hardcoded) rules and // returns an error describing the first unmet requirement. Retained for callers // that don't have access to a configured PasswordValidator instance. func ValidatePassword(password string) error { v := defaultPasswordValidator r := v.rules() if len(password) < r.MinLength { return fmt.Errorf("password must be at least %d characters long", r.MinLength) } if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) { return fmt.Errorf("password must contain at least one uppercase letter") } if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return fmt.Errorf("password must contain at least one lowercase letter") } if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return fmt.Errorf("password must contain at least one number") } if r.Special && !specialRegex().MatchString(password) { return fmt.Errorf("password must contain at least one special character") } return nil } // defaultPasswordValidator is used by the package-level helpers // (GetPasswordRequirements, ValidatePassword) and as the fallback inside // RegisterPasswordValidation when no registry has been wired. Callers that want // live rule updates should construct their own PasswordValidator and call // SetSettings. var defaultPasswordValidator = &PasswordValidator{} // RegisterPasswordValidation registers the password validator with the // validator instance. The registered func re-evaluates rules on every call, so // changes to the wired registry take effect immediately. func RegisterPasswordValidation(v *validator.Validate) error { return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool { return defaultPasswordValidator.CheckPassword(fl.Field().String()) }) } // SetDefaultPasswordSettings wires the settings registry into the package-level // default validator so that the struct-tag validator (used by echo's // CustomValidator) and ValidatePassword follow live configuration. Intended to // be called once at startup. func SetDefaultPasswordSettings(s *database.SettingsRegistry) { defaultPasswordValidator.SetSettings(s) }