From bf15d4a2f78d69d4c53facf9852f88dff286cc26 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 09:48:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(go-services):=20tracker-go=20=E2=80=94=20a?= =?UTF-8?q?uth=20gate=20(itsdangerous=20+=20internal-trust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates main.py's AuthMiddleware so /go/ can be exposed safely: - internal-trust: private source IP AND no X-Forwarded-For => skip auth (loopback/compose callers; nginx adds XFF to all internet traffic). - session cookie: byte-compatible itsdangerous URLSafeTimedSerializer verify (HMAC-SHA1, django-concat key derivation sha1("itsdangerous"+"signer"+key), Unix-epoch timestamp, urlsafe-b64 no pad, optional zlib payload), keyed on the same SECRET_KEY. 30-day max-age. Public allowlist (/login,/logout,login assets, /icons/,/health); 302->/login for html, 401 JSON otherwise. Validated on the server: internal-trust loopback 200; external no-cookie 401; html 302; valid cookie 200; tampered 401; /health public 200; and the SAME Python-issued cookie authenticates BOTH services (cross-compat proof). Co-Authored-By: Claude Opus 4.8 --- go-services/docker-compose.go.yml | 3 + go-services/tracker-go/auth.go | 167 ++++++++++++++++++++++++++++++ go-services/tracker-go/main.go | 11 +- 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 go-services/tracker-go/auth.go diff --git a/go-services/docker-compose.go.yml b/go-services/docker-compose.go.yml index 36a16ada..c80d4938 100644 --- a/go-services/docker-compose.go.yml +++ b/go-services/docker-compose.go.yml @@ -30,6 +30,9 @@ services: # Read-only use of the same dereth TimescaleDB the Python tracker writes. DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth" INVENTORY_SERVICE_URL: "http://inventory-service:8000" + # Same signing key as the Python tracker so the same login cookie verifies + # on both during the parallel run. + SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "INFO" depends_on: - db diff --git a/go-services/tracker-go/auth.go b/go-services/tracker-go/auth.go new file mode 100644 index 00000000..979f3a4e --- /dev/null +++ b/go-services/tracker-go/auth.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "compress/zlib" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "time" +) + +// Session-cookie verification compatible with the Python service's +// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2): +// - HMAC-SHA1 signature +// - django-concat key derivation: sha1(salt + b"signer" + secret_key) +// - salt "itsdangerous", separator ".", Unix-epoch timestamp +// - payload = urlsafe-base64(no pad) of compact JSON {"u":username,"a":is_admin}, +// optionally zlib-compressed with a leading "." marker +// Reusing the same SECRET_KEY means a login on the Python service authenticates +// on the Go service during the parallel run. + +const sessionMaxAge = 30 * 24 * 3600 // SESSION_MAX_AGE seconds (30 days) + +type sessionUser struct { + Username string + IsAdmin bool +} + +func deriveSignerKey(secretKey string) []byte { + h := sha1.New() + h.Write([]byte("itsdangerous")) // salt + h.Write([]byte("signer")) + h.Write([]byte(secretKey)) + return h.Sum(nil) +} + +// verifySessionCookie returns the user encoded in a valid, unexpired token, or +// nil. Constant-time signature comparison; never partially trusts a bad token. +func verifySessionCookie(secretKey, token string) *sessionUser { + if secretKey == "" || token == "" { + return nil + } + // signature is everything after the final separator. + i := strings.LastIndexByte(token, '.') + if i <= 0 { + return nil + } + signed := token[:i] // payload + "." + timestamp + sig, err := base64.RawURLEncoding.DecodeString(token[i+1:]) + if err != nil { + return nil + } + mac := hmac.New(sha1.New, deriveSignerKey(secretKey)) + mac.Write([]byte(signed)) + if !hmac.Equal(sig, mac.Sum(nil)) { + return nil + } + + // timestamp is after the second-to-last separator; payload precedes it + // (the payload may itself start with "." when zlib-compressed). + j := strings.LastIndexByte(signed, '.') + if j < 0 { + return nil + } + tsBytes, err := base64.RawURLEncoding.DecodeString(signed[j+1:]) + if err != nil { + return nil + } + var ts int64 + for _, b := range tsBytes { + ts = ts<<8 | int64(b) + } + if time.Now().Unix()-ts > sessionMaxAge { + return nil // expired + } + + payload, err := decodeItsdangerousPayload(signed[:j]) + if err != nil { + return nil + } + var data struct { + U string `json:"u"` + A bool `json:"a"` + } + if json.Unmarshal(payload, &data) != nil { + return nil + } + return &sessionUser{Username: data.U, IsAdmin: data.A} +} + +func decodeItsdangerousPayload(p string) ([]byte, error) { + compressed := strings.HasPrefix(p, ".") + if compressed { + p = p[1:] + } + raw, err := base64.RawURLEncoding.DecodeString(p) + if err != nil { + return nil, err + } + if !compressed { + return raw, nil + } + zr, err := zlib.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + defer zr.Close() + return io.ReadAll(zr) +} + +// authMiddleware replicates main.py's AuthMiddleware: public paths pass through; +// private-source + no X-Forwarded-For is internal-trust (skip auth); otherwise a +// valid session cookie is required. +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + // Internal trust: only when the peer is private AND nginx did not add + // X-Forwarded-For (nginx sets XFF on all proxied internet traffic). + if r.Header.Get("X-Forwarded-For") == "" && isPrivateAddr(clientIP(r)) { + next.ServeHTTP(w, r) + return + } + if c, err := r.Cookie("session"); err == nil { + if u := verifySessionCookie(s.secretKey, c.Value); u != nil { + next.ServeHTTP(w, r) + return + } + } + if strings.Contains(r.Header.Get("Accept"), "text/html") { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"}) + }) +} + +func isPublicPath(p string) bool { + switch p { + case "/login", "/logout", "/login.html", "/login-style.css", "/health": + return true + } + return strings.HasPrefix(p, "/icons/") +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func isPrivateAddr(ip string) bool { + a := net.ParseIP(ip) + if a == nil { + return false + } + return a.IsLoopback() || a.IsPrivate() +} diff --git a/go-services/tracker-go/main.go b/go-services/tracker-go/main.go index fbcd99c0..635c0ed3 100644 --- a/go-services/tracker-go/main.go +++ b/go-services/tracker-go/main.go @@ -38,6 +38,7 @@ type Server struct { totals *totalsCache invProxy *httputil.ReverseProxy staticDir string + secretKey string log *slog.Logger } @@ -55,8 +56,14 @@ func main() { cache: newLiveCache(), totals: newTotalsCache(), staticDir: cfg.StaticDir, + secretKey: cfg.SecretKey, log: logger, } + if cfg.SecretKey == "" { + // Fail closed like the Python service: with no key, no external cookie + // can verify, so only internal-trust (loopback/compose) requests pass. + logger.Warn("SECRET_KEY unset — external (nginx-proxied) requests will all be rejected") + } // Inventory-service reverse proxy (independent of the DB). if err := srv.initInvProxy(cfg.InventoryURL); err != nil { @@ -88,7 +95,7 @@ func main() { httpSrv := &http.Server{ Addr: cfg.Addr, - Handler: withRequestLogging(mux), + Handler: withRequestLogging(srv.authMiddleware(mux)), ReadHeaderTimeout: 10 * time.Second, } @@ -117,6 +124,7 @@ type config struct { DatabaseURL string // dereth TimescaleDB DSN (read-only use) InventoryURL string // inventory-service base URL StaticDir string // directory for static assets / openissues.json + SecretKey string // session-cookie signing key (must match the Python service) } func loadConfig() config { @@ -125,6 +133,7 @@ func loadConfig() config { DatabaseURL: os.Getenv("DATABASE_URL"), InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"), StaticDir: envOr("STATIC_DIR", "static"), + SecretKey: os.Getenv("SECRET_KEY"), } }