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"), } }