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:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+223
View File
@@ -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
}
@@ -0,0 +1,342 @@
package middleware_test
import (
"encoding/base64"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func basic(email, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+password))
}
// guarded wraps a handler that records the identity the middleware resolved.
func guarded(f *testsupport.Fixture, seen *auth.Identity) http.Handler {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id, err := auth.FromContext(r.Context()); err == nil && seen != nil {
*seen = id
}
w.WriteHeader(http.StatusOK)
})
return middleware.BasicAuth(f.Repo, quietLogger())(inner)
}
func TestBasicAuthAcceptsValidCredentials(t *testing.T) {
f := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body)
}
if got.User.ID != f.Agent.ID {
t.Errorf("resolved user %s, want %s", got.User.ID, f.Agent.ID)
}
if got.Tenant.ID != f.Tenant.ID {
t.Errorf("resolved tenant %s, want %s", got.Tenant.ID, f.Tenant.ID)
}
}
func TestBasicAuthIsCaseInsensitiveOnEmail(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(strings.ToUpper(f.Agent.Email), testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestBasicAuthRejections(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
cases := []struct {
name string
authHeader string
tenant string
wantStatus int
}{
{
name: "no credentials at all",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong password",
authHeader: basic(f.Agent.Email, "not-the-password"),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown user",
authHeader: basic("nobody@nowhere.test", testsupport.Password),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "no-such-agency",
wantStatus: http.StatusUnauthorized,
},
{
// The central rule: right password, wrong agency.
name: "valid credentials against another tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: other.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "missing tenant header",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "",
wantStatus: http.StatusBadRequest,
},
{
name: "malformed authorization header",
authHeader: "Bearer some.jwt.token",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
if tc.tenant != "" {
req.Header.Set(middleware.TenantHeader, tc.tenant)
}
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body)
}
})
}
}
func TestBasicAuthRefusesSuspendedTenant(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 for a suspended tenant", rec.Code)
}
}
// Sending WWW-Authenticate would make the browser pop its own login dialog over
// the Angular sign-in screen, so its absence is a deliberate contract.
func TestUnauthorizedDoesNotTriggerTheBrowserDialog(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, "wrong"))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if got := rec.Header().Get("WWW-Authenticate"); got != "" {
t.Errorf("WWW-Authenticate = %q, want it absent", got)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
// Deployments that give each agency a subdomain need no client changes.
func TestTenantFallsBackToSubdomain(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Host = f.Tenant.Slug + ".travelhub.example.com:8080"
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 resolving the tenant from the subdomain", rec.Code)
}
}
func TestTenantHeaderBeatsSubdomain(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
req.Host = other.Tenant.Slug + ".travelhub.example.com"
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got.Tenant.ID != f.Tenant.ID {
t.Error("the subdomain overrode the explicit tenant header")
}
}
func TestRequireAdmin(t *testing.T) {
f := testsupport.Setup(t)
adminOnly := middleware.BasicAuth(f.Repo, quietLogger())(
middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})),
)
cases := []struct {
name string
email string
want int
}{
{"admin passes", f.Admin.Email, http.StatusOK},
{"agent is refused", f.Agent.Email, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
req.Header.Set("Authorization", basic(tc.email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
adminOnly.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
func TestCORS(t *testing.T) {
allowed := []string{"http://localhost:4200", "https://app.example.com/"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware.CORS(allowed)(ok)
t.Run("echoes an allowed origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:4200" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") {
t.Errorf("Vary = %q, want it to include Origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, middleware.TenantHeader) {
t.Errorf("Allow-Headers = %q, want it to include %s", got, middleware.TenantHeader)
}
})
t.Run("tolerates a trailing slash in configuration", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Allow-Origin = %q, want the origin echoed", got)
}
})
t.Run("ignores an origin that is not on the list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want it absent", got)
}
})
t.Run("answers preflight without reaching the handler", func(t *testing.T) {
reached := false
h := middleware.CORS(allowed)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
if reached {
t.Error("preflight should short-circuit before the handler")
}
})
}
func TestRecovererTurnsPanicIntoFiveHundred(t *testing.T) {
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("something went very wrong")
})
handler := middleware.Recoverer(quietLogger())(boom)
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
rec := httptest.NewRecorder()
// The point is that this call returns rather than taking the process down.
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if !strings.Contains(rec.Body.String(), "internal server error") {
t.Errorf("body = %s, want a JSON error", rec.Body)
}
}