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.
147 lines
5.1 KiB
Go
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)
|
|
}
|