Files
bookhoard/internal/middleware/password_validator.go
T
john-okeefe 457a38306d feat(auth): make session duration and password rules configurable
Replace the hardcoded 7-day session lifetime and fixed password
complexity rules with registry-backed accessors so they can be tuned
from the admin UI without a code change.

auth.go:
- Drop the SessionDuration const; keep DefaultSessionDuration (7 days)
  as the fallback used when no registry is wired (e.g. in tests).
- AuthHandler gains an optional *database.SettingsRegistry and a
  sessionDuration() helper that reads the registry, falling back to
  DefaultSessionDuration.
- Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from
  sessionDuration() instead of the package-level SessionDurationSec, so
  a settings change takes effect on the next login.

refresh_token.go:
- Refresh-token lifetime follows sessionDuration() via a new
  refreshTokenTTL() helper (was a separate refreshTokenExpiration const
  that silently had to be kept in sync with the session duration).

password_validator.go:
- PasswordValidator now reads min length and the upper/lower/number/
  special toggles from the registry at validation time, so rule
  changes apply immediately. The special-character regex is compiled
  once and reused (sync.Once).
- GetPasswordRequirements() and ValidatePassword() reflect the active
  configured rules instead of a static list.
- Add SetDefaultPasswordSettings() so the package-level default
  validator (used by echo's struct-tag validator) follows live config.

All paths degrade gracefully to the historical defaults when no
registry is wired.
2026-08-10 08:01:11 -04:00

147 lines
5.1 KiB
Go

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)
}