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.com → name@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.
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user