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)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -62,11 +63,12 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
|
||||
// Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.)
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000, // 1 year in seconds
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000, // 1 year in seconds
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
|
||||
}))
|
||||
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
|
||||
Limit: "1M", // 1MB default for JSON payloads
|
||||
@@ -93,6 +95,14 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
e.Use(corsMiddleware(cfg))
|
||||
e.Use(i18n.Middleware())
|
||||
|
||||
// Gzip compression (skip media endpoints since they serve binary files)
|
||||
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
||||
Level: 5,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/api/media/")
|
||||
},
|
||||
}))
|
||||
|
||||
// Monitoring metrics middleware (if monitoring is enabled)
|
||||
if deps.MonitoringService != nil {
|
||||
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
|
||||
@@ -114,8 +124,9 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
})
|
||||
}
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
e.GET("/api/health/", healthCheck)
|
||||
// Health check endpoints (no auth required)
|
||||
e.GET("/api/health/", readinessCheck(deps))
|
||||
e.GET("/api/health/live", liveCheck)
|
||||
|
||||
// Initialize onboarding email service for tracking handler
|
||||
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
|
||||
@@ -172,17 +183,21 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
subscriptionWebhookHandler.SetStripeService(stripeService)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := custommiddleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg)
|
||||
|
||||
// Initialize Apple auth service
|
||||
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
|
||||
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
|
||||
|
||||
// Initialize audit service for security event logging
|
||||
auditService := services.NewAuditService(deps.DB)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
|
||||
authHandler.SetAppleAuthService(appleAuthService)
|
||||
authHandler.SetGoogleAuthService(googleAuthService)
|
||||
authHandler.SetStorageService(deps.StorageService)
|
||||
authHandler.SetAuditService(auditService)
|
||||
userHandler := handlers.NewUserHandler(userService)
|
||||
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
|
||||
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
|
||||
@@ -201,6 +216,11 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
||||
}
|
||||
|
||||
// Prometheus metrics endpoint (no auth required, for scraping)
|
||||
if deps.MonitoringService != nil {
|
||||
e.GET("/metrics", prometheusMetrics(deps.MonitoringService))
|
||||
}
|
||||
|
||||
// Set up admin routes with monitoring handler (if available)
|
||||
var monitoringHandler *monitoring.Handler
|
||||
if deps.MonitoringService != nil {
|
||||
@@ -295,16 +315,126 @@ func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// healthCheck returns API health status
|
||||
func healthCheck(c echo.Context) error {
|
||||
// liveCheck returns a simple 200 for Kubernetes liveness probes
|
||||
func liveCheck(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"status": "alive",
|
||||
"version": Version,
|
||||
"framework": "Echo",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// readinessCheck returns 200 if PostgreSQL and Redis are reachable, 503 otherwise.
|
||||
// This is used by Kubernetes readiness probes and load balancers.
|
||||
func readinessCheck(deps *Dependencies) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "healthy"
|
||||
httpStatus := http.StatusOK
|
||||
checks := make(map[string]string)
|
||||
|
||||
// Check PostgreSQL
|
||||
sqlDB, err := deps.DB.DB()
|
||||
if err != nil {
|
||||
checks["postgres"] = fmt.Sprintf("failed to get sql.DB: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else if err := sqlDB.PingContext(ctx); err != nil {
|
||||
checks["postgres"] = fmt.Sprintf("ping failed: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["postgres"] = "ok"
|
||||
}
|
||||
|
||||
// Check Redis (if cache service is available)
|
||||
if deps.Cache != nil {
|
||||
if err := deps.Cache.Client().Ping(ctx).Err(); err != nil {
|
||||
checks["redis"] = fmt.Sprintf("ping failed: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["redis"] = "ok"
|
||||
}
|
||||
} else {
|
||||
checks["redis"] = "not configured"
|
||||
}
|
||||
|
||||
return c.JSON(httpStatus, map[string]interface{}{
|
||||
"status": status,
|
||||
"version": Version,
|
||||
"checks": checks,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prometheusMetrics returns an Echo handler that outputs metrics in Prometheus text format.
|
||||
// It uses the existing monitoring service's HTTP stats collector to avoid adding external dependencies.
|
||||
func prometheusMetrics(monSvc *monitoring.Service) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
httpCollector := monSvc.HTTPCollector()
|
||||
if httpCollector == nil {
|
||||
return c.String(http.StatusOK, "# No HTTP metrics available (collector not initialized)\n")
|
||||
}
|
||||
|
||||
stats := httpCollector.GetStats()
|
||||
var b strings.Builder
|
||||
|
||||
// Request count by method+path+status
|
||||
b.WriteString("# HELP http_requests_total Total number of HTTP requests.\n")
|
||||
b.WriteString("# TYPE http_requests_total counter\n")
|
||||
for statusCode, count := range stats.ByStatusCode {
|
||||
fmt.Fprintf(&b, "http_requests_total{status_code=\"%d\"} %d\n", statusCode, count)
|
||||
}
|
||||
|
||||
// Per-endpoint request count
|
||||
b.WriteString("# HELP http_endpoint_requests_total Total requests per endpoint.\n")
|
||||
b.WriteString("# TYPE http_endpoint_requests_total counter\n")
|
||||
for endpoint, epStats := range stats.ByEndpoint {
|
||||
// endpoint is "METHOD /path"
|
||||
parts := strings.SplitN(endpoint, " ", 2)
|
||||
method := endpoint
|
||||
path := ""
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
path = parts[1]
|
||||
}
|
||||
fmt.Fprintf(&b, "http_endpoint_requests_total{method=\"%s\",path=\"%s\"} %d\n", method, path, epStats.Count)
|
||||
}
|
||||
|
||||
// Request duration (avg latency as a gauge, since we don't have raw histogram buckets)
|
||||
b.WriteString("# HELP http_request_duration_ms Average request duration in milliseconds per endpoint.\n")
|
||||
b.WriteString("# TYPE http_request_duration_ms gauge\n")
|
||||
for endpoint, epStats := range stats.ByEndpoint {
|
||||
parts := strings.SplitN(endpoint, " ", 2)
|
||||
method := endpoint
|
||||
path := ""
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
path = parts[1]
|
||||
}
|
||||
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"avg\"} %.2f\n", method, path, epStats.AvgLatencyMs)
|
||||
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"p95\"} %.2f\n", method, path, epStats.P95LatencyMs)
|
||||
}
|
||||
|
||||
// Error rate
|
||||
b.WriteString("# HELP http_error_rate Overall error rate (4xx+5xx / total).\n")
|
||||
b.WriteString("# TYPE http_error_rate gauge\n")
|
||||
fmt.Fprintf(&b, "http_error_rate %.4f\n", stats.ErrorRate)
|
||||
|
||||
// Requests per minute
|
||||
b.WriteString("# HELP http_requests_per_minute Current request rate.\n")
|
||||
b.WriteString("# TYPE http_requests_per_minute gauge\n")
|
||||
fmt.Fprintf(&b, "http_requests_per_minute %.2f\n", stats.RequestsPerMinute)
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
return c.String(http.StatusOK, b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// setupPublicAuthRoutes configures public authentication routes with
|
||||
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing.
|
||||
// Rate limiters are disabled in debug mode to allow UI test suites to run
|
||||
@@ -342,6 +472,7 @@ func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/logout/", authHandler.Logout)
|
||||
auth.POST("/refresh/", authHandler.RefreshToken)
|
||||
auth.GET("/me/", authHandler.CurrentUser)
|
||||
auth.PUT("/profile/", authHandler.UpdateProfile)
|
||||
auth.PATCH("/profile/", authHandler.UpdateProfile)
|
||||
|
||||
Reference in New Issue
Block a user