Files
honeyDueAPI/internal/validator/validator.go
Trey T b679f28e55 Production hardening: security, resilience, observability, and compliance
Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls

Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
       middleware expiry (5), validator (new)
2026-03-26 14:05:28 -05:00

131 lines
3.5 KiB
Go

package validator
import (
"net/http"
"reflect"
"strings"
"unicode"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
// CustomValidator wraps go-playground/validator for Echo
type CustomValidator struct {
validator *validator.Validate
}
// NewCustomValidator creates a new validator instance
func NewCustomValidator() *CustomValidator {
v := validator.New()
// Use JSON tag names for field names in errors
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// Register custom password complexity validator
v.RegisterValidation("password_complexity", validatePasswordComplexity)
return &CustomValidator{validator: v}
}
// validatePasswordComplexity checks that a password contains at least one
// uppercase letter, one lowercase letter, and one digit.
// Minimum length is enforced separately via the "min" tag.
func validatePasswordComplexity(fl validator.FieldLevel) bool {
password := fl.Field().String()
var hasUpper, hasLower, hasDigit bool
for _, ch := range password {
switch {
case unicode.IsUpper(ch):
hasUpper = true
case unicode.IsLower(ch):
hasLower = true
case unicode.IsDigit(ch):
hasDigit = true
}
if hasUpper && hasLower && hasDigit {
return true
}
}
return hasUpper && hasLower && hasDigit
}
// Validate implements echo.Validator interface
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return err
}
return nil
}
// ValidationErrorResponse is the structured error response format
type ValidationErrorResponse struct {
Error string `json:"error"`
Fields map[string]FieldError `json:"fields,omitempty"`
}
// FieldError represents a single field validation error
type FieldError struct {
Message string `json:"message"`
Tag string `json:"tag"`
}
// FormatValidationErrors converts validator errors to structured response
func FormatValidationErrors(err error) *ValidationErrorResponse {
if validationErrors, ok := err.(validator.ValidationErrors); ok {
fields := make(map[string]FieldError)
for _, fe := range validationErrors {
fieldName := fe.Field()
fields[fieldName] = FieldError{
Message: formatMessage(fe),
Tag: fe.Tag(),
}
}
return &ValidationErrorResponse{
Error: "Validation failed",
Fields: fields,
}
}
return &ValidationErrorResponse{Error: err.Error()}
}
// HTTPError returns an echo.HTTPError with validation details
func HTTPError(c echo.Context, err error) error {
return c.JSON(http.StatusBadRequest, FormatValidationErrors(err))
}
func formatMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "This field is required"
case "required_without":
return "This field is required when " + fe.Param() + " is not provided"
case "required_with":
return "This field is required when " + fe.Param() + " is provided"
case "email":
return "Must be a valid email address"
case "min":
return "Must be at least " + fe.Param() + " characters"
case "max":
return "Must be at most " + fe.Param() + " characters"
case "len":
return "Must be exactly " + fe.Param() + " characters"
case "oneof":
return "Must be one of: " + fe.Param()
case "url":
return "Must be a valid URL"
case "uuid":
return "Must be a valid UUID"
case "password_complexity":
return "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit"
default:
return "Invalid value"
}
}