feat(go-services): tracker-go — auth gate (itsdangerous + internal-trust)
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 <noreply@anthropic.com>
This commit is contained in:
parent
c4e8190656
commit
bf15d4a2f7
3 changed files with 180 additions and 1 deletions
|
|
@ -30,6 +30,9 @@ services:
|
||||||
# Read-only use of the same dereth TimescaleDB the Python tracker writes.
|
# Read-only use of the same dereth TimescaleDB the Python tracker writes.
|
||||||
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
|
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
|
||||||
INVENTORY_SERVICE_URL: "http://inventory-service:8000"
|
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"
|
LOG_LEVEL: "INFO"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
|
||||||
167
go-services/tracker-go/auth.go
Normal file
167
go-services/tracker-go/auth.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ type Server struct {
|
||||||
totals *totalsCache
|
totals *totalsCache
|
||||||
invProxy *httputil.ReverseProxy
|
invProxy *httputil.ReverseProxy
|
||||||
staticDir string
|
staticDir string
|
||||||
|
secretKey string
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,8 +56,14 @@ func main() {
|
||||||
cache: newLiveCache(),
|
cache: newLiveCache(),
|
||||||
totals: newTotalsCache(),
|
totals: newTotalsCache(),
|
||||||
staticDir: cfg.StaticDir,
|
staticDir: cfg.StaticDir,
|
||||||
|
secretKey: cfg.SecretKey,
|
||||||
log: logger,
|
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).
|
// Inventory-service reverse proxy (independent of the DB).
|
||||||
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
|
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
|
||||||
|
|
@ -88,7 +95,7 @@ func main() {
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: withRequestLogging(mux),
|
Handler: withRequestLogging(srv.authMiddleware(mux)),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +124,7 @@ type config struct {
|
||||||
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
|
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
|
||||||
InventoryURL string // inventory-service base URL
|
InventoryURL string // inventory-service base URL
|
||||||
StaticDir string // directory for static assets / openissues.json
|
StaticDir string // directory for static assets / openissues.json
|
||||||
|
SecretKey string // session-cookie signing key (must match the Python service)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
|
|
@ -125,6 +133,7 @@ func loadConfig() config {
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||||
StaticDir: envOr("STATIC_DIR", "static"),
|
StaticDir: envOr("STATIC_DIR", "static"),
|
||||||
|
SecretKey: os.Getenv("SECRET_KEY"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue