// Package middleware contains the HTTP layer that runs before handlers: // panic recovery, logging, CORS, and Basic authentication with tenant // resolution. package middleware import ( "crypto/subtle" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "golang.org/x/crypto/bcrypt" "github.com/travelswitch/travelhub/backend/internal/auth" "github.com/travelswitch/travelhub/backend/internal/repository" ) // TenantHeader names the header an SPA uses to say which agency it is signing // in to. A subdomain is used as a fallback so the same build works when // deployed as skyline.example.com. const TenantHeader = "X-Tenant" // Recoverer turns a panic into a 500 instead of killing the connection. func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { logger.Error("panic serving request", "method", r.Method, "path", r.URL.Path, "panic", rec) writeError(w, http.StatusInternalServerError, "internal server error") } }() next.ServeHTTP(w, r) }) } } // statusRecorder remembers the status code so the logger can report it. type statusRecorder struct { http.ResponseWriter status int } func (s *statusRecorder) WriteHeader(code int) { s.status = code s.ResponseWriter.WriteHeader(code) } // Logger writes one line per request. func Logger(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "tenant", r.Header.Get(TenantHeader), "duration", time.Since(start).String(), ) }) } } // CORS answers preflights and echoes back only origins on the allow list. func CORS(allowed []string) func(http.Handler) http.Handler { allowSet := make(map[string]bool, len(allowed)) for _, o := range allowed { allowSet[strings.TrimSuffix(o, "/")] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := strings.TrimSuffix(r.Header.Get("Origin"), "/") if origin != "" && allowSet[origin] { h := w.Header() h.Set("Access-Control-Allow-Origin", origin) h.Set("Access-Control-Allow-Credentials", "true") h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+TenantHeader) h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") h.Set("Access-Control-Max-Age", "600") // Responses differ per origin, so caches must key on it. h.Add("Vary", "Origin") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } } // BasicAuth authenticates the caller and pins them to one tenant. // // Both halves matter: the password is checked against the user row *inside* // the named tenant, so identical credentials at another agency will not // authenticate here. The resolved tenant goes into the request context and is // the only tenant id the handlers below ever use. func BasicAuth(repo *repository.Repository, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slug := tenantSlug(r) if slug == "" { writeError(w, http.StatusBadRequest, "missing tenant: send the "+TenantHeader+" header") return } email, password, ok := r.BasicAuth() if !ok { unauthorized(w) return } ctx := r.Context() tenant, err := repo.TenantBySlug(ctx, slug) if err != nil { if errors.Is(err, repository.ErrNotFound) { // Same response as a bad password: do not let an // unauthenticated caller enumerate which tenants exist. unauthorized(w) return } logger.Error("tenant lookup failed", "error", err) writeError(w, http.StatusInternalServerError, "internal server error") return } user, err := repo.UserByEmail(ctx, tenant.ID, email) if err != nil { if errors.Is(err, repository.ErrNotFound) { // Hash a throwaway value so a missing user costs the same // time as a wrong password, which stops an attacker // learning valid addresses from response timing. bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) unauthorized(w) return } logger.Error("user lookup failed", "error", err) writeError(w, http.StatusInternalServerError, "internal server error") return } if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { unauthorized(w) return } // Defence in depth: the user row must belong to the tenant we // resolved. The query already guarantees it; this catches a future // refactor that breaks the invariant. if subtle.ConstantTimeCompare(user.TenantID[:], tenant.ID[:]) != 1 { unauthorized(w) return } ctx = auth.WithIdentity(ctx, auth.Identity{User: user, Tenant: tenant}) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // RequireAdmin gates routes that only a tenant administrator may call. func RequireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id, err := auth.FromContext(r.Context()) if err != nil || !id.IsAdmin() { writeError(w, http.StatusForbidden, "administrator role required") return } next.ServeHTTP(w, r) }) } // dummyHash is a valid bcrypt hash of a value nobody uses, for timing parity. var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") // tenantSlug reads the tenant from the header, falling back to the first label // of the Host name for subdomain-per-tenant deployments. func tenantSlug(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get(TenantHeader)); v != "" { return strings.ToLower(v) } host := r.Host if h, _, found := strings.Cut(host, ":"); found { host = h } labels := strings.Split(host, ".") if len(labels) >= 3 && labels[0] != "www" { return strings.ToLower(labels[0]) } return "" } // unauthorized replies 401 without a WWW-Authenticate header on purpose: that // header makes browsers open their own credential dialog, which would fight // with the Angular sign-in screen. func unauthorized(w http.ResponseWriter) { writeError(w, http.StatusUnauthorized, "invalid credentials for this tenant") } func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": message}) } // Chain applies middleware so the first argument is the outermost layer. func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { for i := len(mw) - 1; i >= 0; i-- { h = mw[i](h) } return h }