diff --git a/cmd/server/main.go b/cmd/server/main.go index ad01f47..cd3375d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,7 +26,12 @@ func main() { log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set") } - client := auth.NewClient(supabaseURL, supabaseAnonKey) + 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 == "" { diff --git a/docker-compose.yml b/docker-compose.yml index b6ce885..f62bdd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,10 @@ services: - PORT=8080 - SUPABASE_URL=${SUPABASE_URL} - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET} - GITEA_TOKEN=${GITEA_TOKEN} - DATABASE_URL=${DATABASE_URL} + - CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY} + - ALLOWED_EMAIL_DOMAINS=${ALLOWED_EMAIL_DOMAINS} + # - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred restart: unless-stopped diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index b57b560..e48a11a 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -70,7 +70,7 @@ const translations: Record> = { "login.confirm.placeholder": "Passwort wiederholen", "login.minchars": "Mind. 8 Zeichen", "login.register.submit": "Registrieren", - "login.hint": "Nur f\u00fcr @hoganlovells.com Adressen.", + "login.hint": "Nur f\u00fcr autorisierte HLC-E-Mail-Adressen.", "login.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.", "login.error.mismatch": "Passw\u00f6rter stimmen nicht \u00fcberein.", "login.error.minlength": "Passwort muss mindestens 8 Zeichen lang sein.", @@ -843,7 +843,7 @@ const translations: Record> = { "login.confirm.placeholder": "Repeat password", "login.minchars": "Min. 8 characters", "login.register.submit": "Register", - "login.hint": "Only for @hoganlovells.com addresses.", + "login.hint": "Only for authorised HLC email addresses.", "login.error.connection": "Connection error. Please try again.", "login.error.mismatch": "Passwords do not match.", "login.error.minlength": "Password must be at least 8 characters.", diff --git a/frontend/src/login.tsx b/frontend/src/login.tsx index d977729..8ccb736 100644 --- a/frontend/src/login.tsx +++ b/frontend/src/login.tsx @@ -23,7 +23,7 @@ export function renderLogin(loginJs: string): string {
- + @@ -31,7 +31,7 @@ export function renderLogin(loginJs: string): string { - + @@ -39,7 +39,7 @@ export function renderLogin(loginJs: string): string {
-

{"Nur f\u00FCr @hoganlovells.com Adressen."}

+

{"Nur f\u00FCr autorisierte HLC-E-Mail-Adressen."}

diff --git a/go.mod b/go.mod index 944d74c..f30dc32 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module mgit.msbls.de/m/patholo go 1.24.0 require ( - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/lib/pq v1.12.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.12.3 ) diff --git a/go.sum b/go.sum index f4b6c0a..79c3627 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,75 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dffcaba..272abc6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,7 +2,6 @@ package auth import ( "bytes" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -11,6 +10,8 @@ import ( "net/http" "strings" "time" + + "github.com/golang-jwt/jwt/v5" ) const ( @@ -20,16 +21,21 @@ const ( ) type Client struct { - URL string - AnonKey string - HTTP *http.Client + URL string + AnonKey string + JWTSecret []byte + HTTP *http.Client } -func NewClient(supabaseURL, anonKey string) *Client { +func NewClient(supabaseURL, anonKey string, jwtSecret []byte) *Client { + if len(jwtSecret) == 0 { + log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification") + } return &Client{ - URL: strings.TrimRight(supabaseURL, "/"), - AnonKey: anonKey, - HTTP: &http.Client{Timeout: 10 * time.Second}, + URL: strings.TrimRight(supabaseURL, "/"), + AnonKey: anonKey, + JWTSecret: jwtSecret, + HTTP: &http.Client{Timeout: 10 * time.Second}, } } @@ -150,70 +156,111 @@ func (c *Client) SignOut(accessToken string) { resp.Body.Close() } -// DecodeJWTExpiry reads the exp claim from a JWT without signature verification. -func DecodeJWTExpiry(token string) (time.Time, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return time.Time{}, errors.New("invalid token format") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return time.Time{}, fmt.Errorf("decode payload: %w", err) - } - - var claims struct { - Exp float64 `json:"exp"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return time.Time{}, fmt.Errorf("parse claims: %w", err) - } - if claims.Exp == 0 { - return time.Time{}, errors.New("no exp claim") - } - return time.Unix(int64(claims.Exp), 0), nil +// VerifiedClaims is the subset of Supabase JWT claims the app cares about. +// Populated only after signature + expiry verification succeed. +type VerifiedClaims struct { + Sub string + Exp time.Time } -// Middleware requires a valid session for protected routes. +// VerifyToken parses and fully validates a Supabase access token. It verifies +// the HS256 signature against the shared secret, enforces the standard +// exp/nbf/iat checks, and extracts the `sub` claim as the authenticated +// user's UUID string. Returns an error if the signature is wrong, the token +// is expired, or any required claim is missing. +func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) { + parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return c.JWTSecret, nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil { + return nil, err + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok || !parsed.Valid { + return nil, errors.New("invalid claims") + } + sub, _ := claims["sub"].(string) + if sub == "" { + return nil, errors.New("no sub claim") + } + var exp time.Time + if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil { + exp = expClaim.Time + } + return &VerifiedClaims{Sub: sub, Exp: exp}, nil +} + +// Middleware requires a valid, signature-verified session for protected routes. +// Browser requests get a 302 to /login; API requests get a 401 JSON response. func (c *Client) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionCookie, err := r.Cookie(SessionCookieName) if err != nil || sessionCookie.Value == "" { - http.Redirect(w, r, "/login", http.StatusFound) + rejectUnauthenticated(w, r) return } - exp, err := DecodeJWTExpiry(sessionCookie.Value) - if err != nil { + claims, err := c.VerifyToken(sessionCookie.Value) + if err == nil { + ctx := withVerifiedClaims(r.Context(), claims) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Only attempt refresh if the failure was expiry. Any other signature + // error means the token is forged or tampered with — reject. + if !errors.Is(err, jwt.ErrTokenExpired) { ClearAuthCookies(w) - http.Redirect(w, r, "/login", http.StatusFound) + rejectUnauthenticated(w, r) return } - if time.Now().After(exp) { - // Access token expired — try refresh - refreshCookie, err := r.Cookie(RefreshCookieName) - if err != nil || refreshCookie.Value == "" { - ClearAuthCookies(w) - http.Redirect(w, r, "/login", http.StatusFound) - return - } - - tokens, err := c.RefreshSession(refreshCookie.Value) - if err != nil { - log.Printf("token refresh failed: %v", err) - ClearAuthCookies(w) - http.Redirect(w, r, "/login", http.StatusFound) - return - } - - SetAuthCookies(w, r, tokens) + refreshCookie, err := r.Cookie(RefreshCookieName) + if err != nil || refreshCookie.Value == "" { + ClearAuthCookies(w) + rejectUnauthenticated(w, r) + return } - next.ServeHTTP(w, r) + tokens, err := c.RefreshSession(refreshCookie.Value) + if err != nil { + log.Printf("token refresh failed: %v", err) + ClearAuthCookies(w) + rejectUnauthenticated(w, r) + return + } + + newClaims, err := c.VerifyToken(tokens.AccessToken) + if err != nil { + log.Printf("refreshed token failed verification: %v", err) + ClearAuthCookies(w) + rejectUnauthenticated(w, r) + return + } + + SetAuthCookies(w, r, tokens) + ctx := withVerifiedClaims(r.Context(), newClaims) + next.ServeHTTP(w, r.WithContext(ctx)) }) } +func rejectUnauthenticated(w http.ResponseWriter, r *http.Request) { + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthenticated"}`)) + return + } + http.Redirect(w, r, "/login", http.StatusFound) +} + +func isAPIRequest(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/api/") +} + // SetAuthCookies writes session and refresh token cookies. func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) { secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..4240195 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,96 @@ +package auth + +import ( + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// testSecret mirrors the format of a Supabase JWT signing key. +var testSecret = []byte("test-secret-for-hs256-verification-123") + +func sign(t *testing.T, secret []byte, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, err := tok.SignedString(secret) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +func TestVerifyToken_Valid(t *testing.T) { + c := &Client{JWTSecret: testSecret} + token := sign(t, testSecret, jwt.MapClaims{ + "sub": "11111111-1111-1111-1111-111111111111", + "exp": time.Now().Add(time.Hour).Unix(), + }) + got, err := c.VerifyToken(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Sub != "11111111-1111-1111-1111-111111111111" { + t.Errorf("sub: got %q", got.Sub) + } +} + +func TestVerifyToken_WrongSecret(t *testing.T) { + c := &Client{JWTSecret: testSecret} + token := sign(t, []byte("attacker-guessed-wrong"), jwt.MapClaims{ + "sub": "11111111-1111-1111-1111-111111111111", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, err := c.VerifyToken(token); err == nil { + t.Fatal("expected error for wrong-signature token, got nil (auth bypass)") + } +} + +func TestVerifyToken_Expired(t *testing.T) { + c := &Client{JWTSecret: testSecret} + token := sign(t, testSecret, jwt.MapClaims{ + "sub": "11111111-1111-1111-1111-111111111111", + "exp": time.Now().Add(-time.Hour).Unix(), + }) + _, err := c.VerifyToken(token) + if err == nil { + t.Fatal("expected error for expired token") + } + if !errors.Is(err, jwt.ErrTokenExpired) { + t.Errorf("expected ErrTokenExpired, got %v", err) + } +} + +func TestVerifyToken_AlgNone(t *testing.T) { + c := &Client{JWTSecret: testSecret} + // An attacker might try alg=none to bypass signature checks. + tok := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{ + "sub": "22222222-2222-2222-2222-222222222222", + "exp": time.Now().Add(time.Hour).Unix(), + }) + token, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("sign none: %v", err) + } + if _, err := c.VerifyToken(token); err == nil { + t.Fatal("expected error for alg=none, got nil (critical bypass)") + } +} + +func TestVerifyToken_MissingSub(t *testing.T) { + c := &Client{JWTSecret: testSecret} + token := sign(t, testSecret, jwt.MapClaims{ + "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, err := c.VerifyToken(token); err == nil { + t.Fatal("expected error for missing sub claim") + } +} + +func TestVerifyToken_Garbage(t *testing.T) { + c := &Client{JWTSecret: testSecret} + if _, err := c.VerifyToken("not.a.jwt"); err == nil { + t.Fatal("expected error for garbage token") + } +} diff --git a/internal/auth/user.go b/internal/auth/user.go index 72ce465..51256c7 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -2,18 +2,17 @@ package auth import ( "context" - "encoding/base64" - "encoding/json" - "errors" "net/http" - "strings" "github.com/google/uuid" ) type contextKey string -const userIDContextKey contextKey = "paliad.userID" +const ( + userIDContextKey contextKey = "paliad.userID" + claimsContextKey contextKey = "paliad.claims" +) // UserIDFromContext returns the authenticated user's UUID, populated by // WithUserID middleware (which runs after the session middleware). @@ -26,25 +25,31 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) { return v, true } -// WithUserID extracts the `sub` claim from the Supabase JWT in the session -// cookie and injects it into the request context. Runs after Client.Middleware -// so the cookie is guaranteed valid (otherwise we'd already be at /login). -// -// If the claim is missing or malformed, the request continues without a user -// in context — handlers needing a user must check. +// withVerifiedClaims stores signature-verified JWT claims in the request +// context. Called only from Client.Middleware after VerifyToken succeeds. +func withVerifiedClaims(ctx context.Context, claims *VerifiedClaims) context.Context { + return context.WithValue(ctx, claimsContextKey, claims) +} + +// verifiedClaimsFromContext returns the signature-verified JWT claims +// attached by Client.Middleware. +func verifiedClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) { + v, ok := ctx.Value(claimsContextKey).(*VerifiedClaims) + return v, ok +} + +// WithUserID reads the `sub` claim from verified JWT claims attached by +// Client.Middleware and injects the user's UUID into the request context. +// Must run after Client.Middleware — the claims are only set there, after +// signature verification succeeds. func (c *Client) WithUserID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(SessionCookieName) - if err != nil || cookie.Value == "" { + claims, ok := verifiedClaimsFromContext(r.Context()) + if !ok { next.ServeHTTP(w, r) return } - sub, err := decodeJWTSub(cookie.Value) - if err != nil { - next.ServeHTTP(w, r) - return - } - uid, err := uuid.Parse(sub) + uid, err := uuid.Parse(claims.Sub) if err != nil { next.ServeHTTP(w, r) return @@ -53,27 +58,3 @@ func (c *Client) WithUserID(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } - -// decodeJWTSub reads the `sub` claim from a JWT without signature verification. -// Verification was already done by the session middleware (which trusts -// Supabase to issue the cookie); we only need the user-id claim here. -func decodeJWTSub(token string) (string, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return "", errors.New("invalid token format") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return "", err - } - var claims struct { - Sub string `json:"sub"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return "", err - } - if claims.Sub == "" { - return "", errors.New("no sub claim") - } - return claims.Sub, nil -} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index dfa1017..e008343 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -4,15 +4,15 @@ import ( "encoding/json" "log" "net/http" + "os" "strings" - "time" "mgit.msbls.de/m/patholo/internal/auth" ) func handleLoginPage(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" { - if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) { + if _, err := authClient.VerifyToken(cookie.Value); err == nil { http.Redirect(w, r, "/", http.StatusFound) return } @@ -36,8 +36,8 @@ func handleAPILogin(w http.ResponseWriter, r *http.Request) { return } - if !isHoganLovellsEmail(req.Email) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für @hoganlovells.com E-Mail-Adressen."}) + if !isAllowedEmailDomain(req.Email) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für autorisierte HLC-E-Mail-Adressen."}) return } @@ -77,8 +77,8 @@ func handleAPIRegister(w http.ResponseWriter, r *http.Request) { return } - if !isHoganLovellsEmail(req.Email) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für @hoganlovells.com E-Mail-Adressen."}) + if !isAllowedEmailDomain(req.Email) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für autorisierte HLC-E-Mail-Adressen."}) return } @@ -110,7 +110,35 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusFound) } -func isHoganLovellsEmail(email string) bool { +// isAllowedEmailDomain gates sign-in/register to the HLC email domains. +// Whitelist is configurable via ALLOWED_EMAIL_DOMAINS (comma-separated), +// defaulting to hoganlovells.com,hlc.com,hlc.de so existing Hogan Lovells +// addresses keep working during the post-merger transition. +func isAllowedEmailDomain(email string) bool { parts := strings.SplitN(email, "@", 2) - return len(parts) == 2 && strings.EqualFold(parts[1], "hoganlovells.com") + if len(parts) != 2 { + return false + } + domain := strings.ToLower(parts[1]) + for _, allowed := range allowedEmailDomains() { + if domain == allowed { + return true + } + } + return false +} + +func allowedEmailDomains() []string { + raw := os.Getenv("ALLOWED_EMAIL_DOMAINS") + if strings.TrimSpace(raw) == "" { + return []string{"hoganlovells.com", "hlc.com", "hlc.de"} + } + var out []string + for _, d := range strings.Split(raw, ",") { + d = strings.TrimSpace(strings.ToLower(d)) + if d != "" { + out = append(out, d) + } + } + return out } diff --git a/internal/handlers/auth_test.go b/internal/handlers/auth_test.go new file mode 100644 index 0000000..9b325cd --- /dev/null +++ b/internal/handlers/auth_test.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "testing" +) + +func TestIsAllowedEmailDomain_Default(t *testing.T) { + t.Setenv("ALLOWED_EMAIL_DOMAINS", "") + cases := []struct { + email string + want bool + }{ + {"alice@hoganlovells.com", true}, + {"alice@HOGANLOVELLS.COM", true}, + {"alice@hlc.com", true}, + {"alice@hlc.de", true}, + {"alice@gmail.com", false}, + {"alice@evil.hoganlovells.com.attacker.net", false}, + {"", false}, + {"no-at-sign", false}, + } + for _, tc := range cases { + if got := isAllowedEmailDomain(tc.email); got != tc.want { + t.Errorf("isAllowedEmailDomain(%q) = %v; want %v", tc.email, got, tc.want) + } + } +} + +func TestIsAllowedEmailDomain_EnvOverride(t *testing.T) { + t.Setenv("ALLOWED_EMAIL_DOMAINS", "example.com, Other.Org ") + if !isAllowedEmailDomain("u@example.com") { + t.Error("example.com should be allowed by env override") + } + if !isAllowedEmailDomain("u@other.org") { + t.Error("other.org should be allowed (case-insensitive, trimmed)") + } + if isAllowedEmailDomain("u@hoganlovells.com") { + t.Error("env override should replace defaults — hoganlovells.com no longer allowed") + } +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 4044dd3..f645b00 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "time" "mgit.msbls.de/m/patholo/internal/auth" ) @@ -53,18 +52,14 @@ func handleRootPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "dist/index.html") } -// hasValidSession returns true when a session cookie is present, parses, and -// hasn't expired. Kept intentionally loose: we don't reach out to Supabase -// here. A near-expiry token will still be accepted and the downstream -// Middleware handles refresh on the next protected request. +// hasValidSession returns true when the session cookie carries a signed, +// unexpired Supabase JWT. Verification is the same one Middleware uses — +// forged or tampered cookies never reach the authenticated branch. func hasValidSession(r *http.Request) bool { cookie, err := r.Cookie(auth.SessionCookieName) if err != nil || cookie.Value == "" { return false } - exp, err := auth.DecodeJWTExpiry(cookie.Value) - if err != nil { - return false - } - return time.Now().Before(exp) + _, err = authClient.VerifyToken(cookie.Value) + return err == nil } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index b10b4ed..bdfa963 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -227,8 +227,9 @@ SELECT f.id, } func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error { - // Termine may be ad-hoc (no parent Akte). Apply visibility to Termine that - // reference an Akte; ad-hoc Termine are visible to any authenticated user. + // Personal Termine (akte_id IS NULL) are creator-only — must mirror + // TerminService.canSee. Akte-attached rows follow the office-scoped + // visibility predicate. query := ` SELECT t.id, t.title, @@ -242,11 +243,12 @@ SELECT t.id, LEFT JOIN paliad.akten a ON a.id = t.akte_id WHERE t.start_at >= $4 AND t.start_at < ($4 + interval '7 days') - AND (t.akte_id IS NULL - OR a.firm_wide_visible = true - OR a.owning_office = $1 - OR $2::uuid = ANY (a.collaborators) - OR $3 = 'admin') + AND ((t.akte_id IS NULL AND t.created_by = $2::uuid) + OR (t.akte_id IS NOT NULL AND ( + a.firm_wide_visible = true + OR a.owning_office = $1 + OR $2::uuid = ANY (a.collaborators) + OR $3 = 'admin'))) ORDER BY t.start_at ASC LIMIT 10` if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query, diff --git a/internal/services/parteien_service.go b/internal/services/parteien_service.go index 7b60237..3337eb6 100644 --- a/internal/services/parteien_service.go +++ b/internal/services/parteien_service.go @@ -89,11 +89,24 @@ func (s *ParteienService) Create(ctx context.Context, userID, akteID uuid.UUID, return &p, nil } -// Delete removes a Partei; visibility is checked on the parent Akte. +// Delete removes a Partei. Partner/admin only — mirrors the FristService +// delete policy so associates can't erase a Klägerin record on a firm-wide +// Akte they merely have visibility for. func (s *ParteienService) Delete(ctx context.Context, userID, parteiID uuid.UUID) error { + user, err := s.akten.users.GetByID(ctx, userID) + if err != nil { + return err + } + if user == nil { + return ErrNotVisible + } + if user.Role != "partner" && user.Role != "admin" { + return fmt.Errorf("%w: only partners/admins can delete Parteien", ErrForbidden) + } + // Resolve the parent Akte to enforce visibility before DELETE executes. var akteID uuid.UUID - err := s.db.GetContext(ctx, &akteID, + err = s.db.GetContext(ctx, &akteID, `SELECT akte_id FROM paliad.parteien WHERE id = $1`, parteiID) if errors.Is(err, sql.ErrNoRows) { return ErrNotVisible diff --git a/internal/services/termin_service.go b/internal/services/termin_service.go index 0ca32ae..812b4a2 100644 --- a/internal/services/termin_service.go +++ b/internal/services/termin_service.go @@ -194,6 +194,26 @@ func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID) return &t, nil } +// requireMutationRole enforces the partner/admin gate on Akte-linked +// Termin mutations (Update, Delete). The Termin's own creator is also +// allowed — they're the one who added it. +func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Termin) error { + if t.CreatedBy != nil && *t.CreatedBy == userID { + return nil + } + user, err := s.akten.users.GetByID(ctx, userID) + if err != nil { + return err + } + if user == nil { + return ErrNotVisible + } + if user.Role != "partner" && user.Role != "admin" { + return fmt.Errorf("%w: only partners/admins can modify Termine on an Akte", ErrForbidden) + } + return nil +} + // canSee mirrors the SELECT visibility predicate for one in-memory Termin. func (s *TerminService) canSee(ctx context.Context, userID uuid.UUID, t *models.Termin) bool { if t.AkteID == nil { @@ -268,14 +288,23 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea } // Update applies a partial update. +// +// Policy: +// - Personal Termin (akte_id IS NULL) → only the creator may edit. +// - Akte-linked Termin → partner/admin only (or the Termin's creator). +// Mirrors the FristService delete policy so an associate in another +// office can't mutate a hearing on a firm-wide Akte. func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Termin, error) { current, err := s.GetByID(ctx, userID, terminID) if err != nil { return nil, err } - // Personal Termine: only the creator may edit. - if current.AkteID == nil && (current.CreatedBy == nil || *current.CreatedBy != userID) { - return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden) + if current.AkteID == nil { + if current.CreatedBy == nil || *current.CreatedBy != userID { + return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden) + } + } else if err := s.requireMutationRole(ctx, userID, current); err != nil { + return nil, err } sets := []string{} @@ -351,16 +380,24 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, return t, nil } -// Delete removes a Termin. Personal Termine: creator only. Akte-attached: -// any user with visibility may delete (matches the FristService policy -// upgrade: termin removal is recoverable from external CalDAV if synced). +// Delete removes a Termin. +// +// Policy: +// - Personal Termin (akte_id IS NULL) → only the creator may delete. +// - Akte-linked Termin → partner/admin only (or the Termin's creator). +// Matches the FristService delete gate so an associate viewing a +// firm-wide Akte can't erase hearings from the calendar. func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) error { current, err := s.GetByID(ctx, userID, terminID) if err != nil { return err } - if current.AkteID == nil && (current.CreatedBy == nil || *current.CreatedBy != userID) { - return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden) + if current.AkteID == nil { + if current.CreatedBy == nil || *current.CreatedBy != userID { + return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden) + } + } else if err := s.requireMutationRole(ctx, userID, current); err != nil { + return err } tx, err := s.db.BeginTxx(ctx, nil)