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