diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 8710290..8a6471c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -26,14 +26,34 @@ import ( ) const ( - // Session duration constants - // Follows same pattern as refresh_token.go - SessionDuration = 7 * 24 * time.Hour // 7 days + // DefaultSessionDuration is the fallback session duration used when no + // settings registry is wired (matches the historical 7-day value). + DefaultSessionDuration = 7 * 24 * time.Hour ) -// SessionDurationSec is the session duration in seconds for use in cookies and API responses -// Note: This is computed from SessionDuration to avoid magic numbers -var SessionDurationSec = int(SessionDuration.Seconds()) +// SessionDurationSec is retained for backward compatibility; new code uses the +// registry via AuthHandler.sessionDuration(). +var SessionDurationSec = int(DefaultSessionDuration.Seconds()) + +// SetSettings wires the tunable settings registry (optional). +func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + +// sessionDuration returns the active session duration from the registry. +func (h *AuthHandler) sessionDuration() time.Duration { + if h.settings != nil { + return h.settings.SessionDuration() + } + return DefaultSessionDuration +} + +// refreshTokenTTL returns the active refresh-token lifetime (shared with the +// session duration), with a compiled-default fallback. +func (h *AuthHandler) refreshTokenTTL() time.Duration { + if h.settings != nil { + return h.settings.SessionDuration() + } + return DefaultSessionDuration +} var secure = os.Getenv("COOKIE_SECURE") @@ -41,6 +61,7 @@ type AuthHandler struct { db *database.Queries jwtKey []byte loginAttemptTracker *middleware.LoginAttemptTracker + settings *database.SettingsRegistry } func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler { @@ -265,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error { HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, - MaxAge: SessionDurationSec, + MaxAge: int(h.sessionDuration().Seconds()), } c.SetCookie(cookie) @@ -298,7 +319,7 @@ window.location.href = '/dashboard'; Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.sessionDuration().Seconds()), User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, @@ -411,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error { HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, - MaxAge: SessionDurationSec, + MaxAge: int(h.sessionDuration().Seconds()), } c.SetCookie(cookie) @@ -450,7 +471,7 @@ window.location.href = '%s'; Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.sessionDuration().Seconds()), User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, @@ -1014,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user "user_role": userRole, "user_email": userEmail, "user_username": userUsername, - "exp": time.Now().Add(SessionDuration).Unix(), + "exp": time.Now().Add(h.sessionDuration()).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/internal/handlers/refresh_token.go b/internal/handlers/refresh_token.go index 2fbb358..4863926 100644 --- a/internal/handlers/refresh_token.go +++ b/internal/handlers/refresh_token.go @@ -14,10 +14,6 @@ import ( "github.com/labstack/echo/v5" ) -const ( - refreshTokenExpiration = 7 * 24 * time.Hour // 7 days -) - type RefreshTokenRequest struct { RefreshToken string `json:"refresh_token" validate:"required"` } @@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error { return c.JSON(http.StatusOK, RefreshTokenResponse{ AccessToken: accessToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.refreshTokenTTL().Seconds()), }) } @@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro tokenUUID := uuid.New() refreshToken := tokenUUID.String() - expiresAt := time.Now().Add(refreshTokenExpiration) + expiresAt := time.Now().Add(h.refreshTokenTTL()) _, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Token: pgtype.UUID{Bytes: tokenUUID, Valid: true}, diff --git a/internal/middleware/password_validator.go b/internal/middleware/password_validator.go index 159c3aa..e4e18ae 100644 --- a/internal/middleware/password_validator.go +++ b/internal/middleware/password_validator.go @@ -1,96 +1,146 @@ package middleware import ( + "bookhoard/internal/database" "fmt" "regexp" + "sync" "github.com/go-playground/validator/v10" ) -// PasswordValidator validates password complexity requirements -type PasswordValidator struct{} +// specialCharRegex matches the historical "special character" set used by the +// password complexity rules. +const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]` -// Validate checks if a password meets complexity requirements: -// - Minimum 8 characters -// - At least one uppercase letter -// - At least one lowercase letter -// - At least one number -// - At least one special character +// 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 { - password := fl.Field().String() + return v.CheckPassword(fl.Field().String()) +} - // Check minimum length - if len(password) < 8 { +// 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 } - - // Check for uppercase - hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) - if !hasUpper { + if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) { return false } - - // Check for lowercase - hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) - if !hasLower { + if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return false } - - // Check for number - hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) - if !hasNumber { + if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return false } - - // Check for special character - hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) - if !hasSpecial { + if r.Special && !specialRegex().MatchString(password) { return false } - return true } -// GetPasswordRequirements returns a human-readable list of password requirements +// 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 []string{ - "At least 8 characters long", - "At least one uppercase letter (A-Z)", - "At least one lowercase letter (a-z)", - "At least one number (0-9)", - "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)", - } + return defaultPasswordValidator.Requirements() } -// ValidatePassword checks a password and returns an error if it doesn't meet requirements -func ValidatePassword(password string) error { - if len(password) < 8 { - return fmt.Errorf("password must be at least 8 characters long") +// 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 +} - if !regexp.MustCompile(`[A-Z]`).MatchString(password) { +// 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 !regexp.MustCompile(`[a-z]`).MatchString(password) { + if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return fmt.Errorf("password must contain at least one lowercase letter") } - - if !regexp.MustCompile(`[0-9]`).MatchString(password) { + if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return fmt.Errorf("password must contain at least one number") } - - if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) { + if r.Special && !specialRegex().MatchString(password) { return fmt.Errorf("password must contain at least one special character") } - return nil } -// RegisterPasswordValidation registers the password validator with the validator instance +// 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 { - pv := &PasswordValidator{} - return pv.Validate(fl) + 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) +}