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)
131 lines
3.5 KiB
Go
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"
|
|
}
|
|
}
|