Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user