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.
This commit is contained in:
+32
-11
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user