Files
paliad/cmd/server/main.go
m 3e20806aee fix(security): verify JWT signatures + plug 4 other critical gaps (t-paliad-016)
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
  HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
  reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
  outright and clear cookies.

C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
  TerminService.canSee: personal Termine (akte_id IS NULL) are
  creator-only; admins do NOT see other users' personal Termine.

C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
  partner/admin (or the original creator). Personal Termine stay
  creator-only.

C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
  (default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
  whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.comname@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.

C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
  and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
  ANTHROPIC_API_KEY line for Phase H readiness.

Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
  token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
  whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
2026-04-18 02:23:50 +02:00

124 lines
4.0 KiB
Go

package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/db"
"mgit.msbls.de/m/patholo/internal/handlers"
"mgit.msbls.de/m/patholo/internal/services"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
if supabaseURL == "" || supabaseAnonKey == "" {
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
}
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
if jwtSecret == "" {
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
}
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
giteaToken := os.Getenv("GITEA_TOKEN")
if giteaToken == "" {
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
}
// DATABASE_URL is optional during the Phase A → Phase D transition. The
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
dbURL := os.Getenv("DATABASE_URL")
var svcBundle *handlers.Services
var caldavSvc *services.CalDAVService
if dbURL != "" {
log.Println("applying database migrations…")
if err := db.ApplyMigrations(dbURL); err != nil {
log.Fatalf("migration failed: %v", err)
}
log.Println("database migrations applied")
pool, err := db.OpenPool(dbURL)
if err != nil {
log.Fatalf("open db pool: %v", err)
}
holidays := services.NewHolidayService(pool)
users := services.NewUserService(pool)
akteSvc := services.NewAkteService(pool, users)
rules := services.NewDeadlineRuleService(pool)
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
// the service exists but Enabled() reports false; handlers return 501.
// If the env var is malformed, fail fast — silently skipping would
// leave plaintext-credential bugs hidden.
cipher, err := services.LoadCalDAVCipher()
if err != nil {
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
}
if cipher == nil {
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
} else {
log.Println("CalDAV encryption configured (AES-256-GCM)")
}
terminSvc := services.NewTerminService(pool, akteSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, terminSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
terminSvc.SetCalDAVPusher(caldavSvc)
svcBundle = &handlers.Services{
Akte: akteSvc,
Parteien: services.NewParteienService(pool, akteSvc),
Frist: services.NewFristService(pool, akteSvc),
Termin: terminSvc,
CalDAV: caldavSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
Dashboard: services.NewDashboardService(pool, users),
Notiz: services.NewNotizService(pool, akteSvc, terminSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, akteSvc),
}
log.Println("Phase B services initialised")
// Spawn one CalDAV sync goroutine per enabled user. No-op if cipher
// is nil. Lives for the process lifetime; signal handler cleans up.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := caldavSvc.Start(ctx); err != nil {
log.Printf("CalDAV start: %v", err)
}
go func() {
<-ctx.Done()
log.Println("CalDAV: shutdown signal received")
caldavSvc.Stop()
}()
} else {
log.Println("DATABASE_URL not set — Akten/Frist endpoints will return 503")
}
mux := http.NewServeMux()
handlers.Register(mux, client, giteaToken, svcBundle)
log.Printf("paliad server starting on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}