From aaacfd3a96052f897fee481124ea7e0398ca7003 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 12 Sep 2026 23:53:19 -0400 Subject: [PATCH] feat(auth): protect the admin subtree with a bearer key --- README.md | 16 ++++++ cmd/beacon/main.go | 2 +- config.yaml.example | 5 ++ env.example | 3 ++ internal/api/middleware/auth.go | 39 +++++++++++---- internal/api/middleware/auth_test.go | 75 ++++++++++++++++++++++++++++ internal/api/router/auth_test.go | 56 +++++++++++++++++++++ internal/api/router/router.go | 20 +++----- internal/api/router/router_test.go | 2 +- internal/config/auth_test.go | 54 ++++++++++++++++++++ internal/config/config.go | 11 ++++ 11 files changed, 258 insertions(+), 25 deletions(-) create mode 100644 internal/api/middleware/auth_test.go create mode 100644 internal/api/router/auth_test.go create mode 100644 internal/config/auth_test.go diff --git a/README.md b/README.md index ef3026d..e62b506 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,22 @@ matching a now-known channel and decrypts them. Watch the startup log for ## Configuration +### Admin authentication + +The `/api/v1/admin` subtree requires `Authorization: Bearer `. Set the +operator key with `BEACON_API_KEY` or `auth.api_key` in YAML. A set environment +variable overrides YAML; an explicitly empty value disables admin access. +With no key, admin requests return JSON 503 while public reads and WebSockets +continue normally. With a key, missing, incorrect or duplicate Authorization +headers return JSON 401 with `WWW-Authenticate: Bearer`. + +Admin operations are not implemented yet: a valid key currently reaches a 404. +Global CORS preflights remain public. Use a long, randomly generated key, keep +it out of source control and logs, and send it only in the Authorization header, +never the URL or request body. Require HTTPS at the reverse proxy and restrict +direct access to Beacon's HTTP listener to that proxy or a private connection. +Changing the key requires a restart. No API key is issued automatically. + ### Environment variables (`.env`) | Variable | Default | Description | diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index bb31b2a..43e6168 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -275,7 +275,7 @@ func main() { go scheduler.Start(ctx) // ── HTTP server ────────────────────────────────────────────────────────── - r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, cfg.CORS, cfg.Server) + r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, cfg.CORS, cfg.Server, cfg.Auth) srv := &http.Server{ Addr: addr, diff --git a/config.yaml.example b/config.yaml.example index 439c51a..c07852f 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,6 +1,11 @@ # Beacon configuration file # Copy to config.yaml and adjust as needed. +# Public reads stay available. No key means admin routes return 503. +# Prefer BEACON_API_KEY in the service environment; never commit a real key. +auth: + api_key: "" + server: # Only these direct proxy peers may set the client IP through X-Real-IP. # The proxy must overwrite that header, not pass through client input. diff --git a/env.example b/env.example index 96bf825..3594554 100644 --- a/env.example +++ b/env.example @@ -1,5 +1,8 @@ LISTEN_ADDR=:8080 +# Optional admin bearer key. If set, overrides auth.api_key (even when empty). +# BEACON_API_KEY= + POSTGRES_DSN=postgres://beacon:beacon@localhost:5432/beacon?sslmode=disable # Redis (optional — leave REDIS_ADDR unset to disable caching) diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index efa49c6..3e775f3 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -3,17 +3,36 @@ package middleware -import "net/http" +import ( + "crypto/subtle" + "io" + "net/http" + "strings" +) -// NoopAuth is a placeholder for the authentication middleware that will be -// wired onto the private route group when auth is implemented (see Future -// Features → Admin authentication in the design doc). -// -// Replace this with a real JWT/session validation middleware before shipping -// any write endpoints or admin functionality. -func NoopAuth(next http.Handler) http.Handler { +// BearerAuth protects admin routes with one operator key. An empty key disables +// access; public routes must be mounted outside this middleware. +func BearerAuth(apiKey string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: validate bearer token, set user in context, return 401 on failure. - next.ServeHTTP(w, r) + w.Header().Set("Cache-Control", "no-store") + status := http.StatusServiceUnavailable + body := `{"error":{"code":"service_unavailable","message":"admin authentication is not configured"}}` + if apiKey != "" { + if values := r.Header.Values("Authorization"); len(values) == 1 { + scheme, token, found := strings.Cut(values[0], " ") + token = strings.TrimLeft(token, " ") // Bearer permits one or more spaces. + if found && strings.EqualFold(scheme, "Bearer") && token != "" && !strings.ContainsAny(token, " \t\r\n,") && + subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 { + next.ServeHTTP(w, r) + return + } + } + status = http.StatusUnauthorized + body = `{"error":{"code":"unauthorized","message":"valid bearer token required"}}` + w.Header().Set("WWW-Authenticate", "Bearer") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body+"\n") }) } diff --git a/internal/api/middleware/auth_test.go b/internal/api/middleware/auth_test.go new file mode 100644 index 0000000..e394d92 --- /dev/null +++ b/internal/api/middleware/auth_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBearerAuth(t *testing.T) { + const key = "synthetic-test-key" + for _, tc := range []struct { + name, key string + headers []string + status int + }{ + {"disabled", "", nil, 503}, {"disabled with token", "", []string{"Bearer " + key}, 503}, + {"missing", key, nil, 401}, {"empty", key, []string{""}, 401}, + {"wrong scheme", key, []string{"Basic " + key}, 401}, {"wrong token", key, []string{"Bearer wrong"}, 401}, + {"empty token", key, []string{"Bearer "}, 401}, {"no separator", key, []string{"Bearer" + key}, 401}, + {"tab separator", key, []string{"Bearer\t" + key}, 401}, {"extra token", key, []string{"Bearer " + key + " extra"}, 401}, + {"line break", key, []string{"Bearer " + key + "\r\n"}, 401}, + {"duplicate", key, []string{"Bearer " + key, "Bearer " + key}, 401}, + {"combined", key, []string{"Bearer " + key + ", Bearer " + key}, 401}, + {"case sensitive token", key, []string{"Bearer SYNTHETIC-TEST-KEY"}, 401}, + {"valid", key, []string{"Bearer " + key}, 200}, + {"scheme case", key, []string{"bEaReR " + key}, 200}, + {"multiple spaces", key, []string{"Bearer " + key}, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + handler := BearerAuth(tc.key, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(200) + })) + // Query and form tokens alone must never grant access. + req := httptest.NewRequest("POST", "/admin?access_token="+key, strings.NewReader("access_token="+key)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, value := range tc.headers { + req.Header.Add("Authorization", value) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != tc.status || (calls == 1) != (tc.status == 200) { + t.Fatalf("status=%d downstream calls=%d", w.Code, calls) + } + if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), key) { + t.Fatal("cache policy missing or credential reflected") + } + if tc.status == 200 { + return + } + var body struct { + Error struct{ Code, Message string } + } + if w.Header().Get("Content-Type") != "application/json" || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Error.Message == "" { + t.Fatal("invalid JSON error contract") + } + wantCode := "service_unavailable" + if tc.status == 401 { + wantCode = "unauthorized" + if w.Header().Get("WWW-Authenticate") != "Bearer" { + t.Fatal("missing bearer challenge") + } + } + if body.Error.Code != wantCode { + t.Fatalf("error code=%s", body.Error.Code) + } + }) + } +} diff --git a/internal/api/router/auth_test.go b/internal/api/router/auth_test.go new file mode 100644 index 0000000..9c66f6d --- /dev/null +++ b/internal/api/router/auth_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/config" +) + +func TestAdminAuthBoundary(t *testing.T) { + for _, key := range []string{"", "synthetic-test-key"} { + handler := New(nil, nil, nil, 5, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{APIKey: key}) + for _, path := range []string{"/api/v1/admin", "/api/v1/admin/", "/api/v1/admin/config", "/api/v1/admin//config"} { + for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} { + for _, token := range []string{"", "wrong", "synthetic-test-key"} { + req := httptest.NewRequest(method, path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + want := http.StatusServiceUnavailable + if key != "" { + want = 401 + if token == key { + want = 404 + } + } + if w.Code != want { + t.Fatalf("%s %s status=%d want=%d", method, path, w.Code, want) + } + } + } + } + for path, want := range map[string]int{"/api/v1/brokers": 200, "/api/v1/administrator": 404, "/swagger": 301} { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != want { + t.Fatalf("public %s status=%d want=%d", path, w.Code, want) + } + } + req := httptest.NewRequest("OPTIONS", "/api/v1/admin/config", nil) + req.Header.Set("Origin", "https://example.test") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "Authorization") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatal("CORS preflight blocked") + } + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 5f4852a..a2f5d77 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -3,8 +3,7 @@ // Package router wires all HTTP routes onto the Chi router and injects // dependencies (hub, reader, ingest workers) into the handler closures. -// All routes are mounted under /api/v1 with public and private groups -// stubbed for future auth middleware. +// REST routes are mounted under /api/v1; its admin subtree requires a bearer key. package router import ( @@ -39,9 +38,8 @@ import ( // /regions → regions subrouter // /stats → stats subrouter // -// The private group is stubbed and ready for the auth middleware drop-in -// described in Future Features → Admin authentication. -func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int, corsCfg config.CORSConfig, serverCfg config.ServerConfig) http.Handler { +// Admin handlers are added separately; the reserved subtree is protected now. +func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int, corsCfg config.CORSConfig, serverCfg config.ServerConfig, authCfg config.AuthConfig) http.Handler { r := chi.NewRouter() // ── CORS ───────────────────────────────────────────────────────────────── @@ -91,7 +89,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP // ── Public REST API (v1) ───────────────────────────────────────────────── r.Route("/api/v1", func(r chi.Router) { - // Public group — no authentication required (all of v1 is public). + // Public group — no authentication required. r.Group(func(r chi.Router) { r.Mount("/packets", handlers.PacketsRouter(reader)) r.Mount("/nodes", handlers.NodesRouter(reader)) @@ -107,13 +105,9 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP r.Mount("/traces", handlers.TracesRouter(reader)) }) - // Private group — auth middleware applied. - // Stubbed for the admin endpoints described in Future Features. - // Swap mw.NoopAuth for a real JWT/session middleware when ready. - r.Group(func(r chi.Router) { - r.Use(mw.NoopAuth) - // r.Mount("/admin", handlers.AdminRouter()) - }) + // Protect the entire subtree, including its root and unknown paths. + // Replace the empty router with the admin handlers when they are added. + r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, chi.NewRouter())) }) return r diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go index 9a42eb3..6b8c5a2 100644 --- a/internal/api/router/router_test.go +++ b/internal/api/router/router_test.go @@ -36,7 +36,7 @@ func TestWebSocketLimitUsesTrustedClientIP(t *testing.T) { func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string, wantStatus int) { t.Helper() - server := httptest.NewServer(New(hub.New(), nil, nil, 1, config.CORSConfig{}, cfg)) + server := httptest.NewServer(New(hub.New(), nil, nil, 1, config.CORSConfig{}, cfg, config.AuthConfig{})) defer server.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go new file mode 100644 index 0000000..e3fccf5 --- /dev/null +++ b/internal/config/auth_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAuth(t *testing.T) { + for _, tc := range []struct { + name, env, want string + file, set bool + }{ + {"no config", "", "", false, false}, + {"YAML", "", "yaml-test-key", true, false}, + {"environment overrides", "env-test-key", "env-test-key", true, true}, + {"empty environment disables", "", "", true, true}, + {"environment without file", "env-test-key", "env-test-key", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("BEACON_API_KEY", tc.env) + if !tc.set { + if err := os.Unsetenv("BEACON_API_KEY"); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(t.TempDir(), "config.yaml") + if tc.file { + if err := os.WriteFile(path, []byte("auth:\n api_key: yaml-test-key\n"), 0600); err != nil { + t.Fatal(err) + } + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Auth.APIKey != tc.want { + t.Fatal("wrong auth configuration precedence") + } + encoded, err := json.Marshal(cfg) + if err != nil || strings.Contains(string(encoded), "test-key") { + t.Fatal("key exposed in JSON") + } + if strings.Contains(Resolve(cfg).String(), "test-key") { + t.Fatal("key exposed in startup summary") + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4a9f417..d4ada2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ import ( // Config is the top-level structure of the Beacon config file. type Config struct { + Auth AuthConfig `yaml:"auth"` Server ServerConfig `yaml:"server"` IATAs map[string]IATAConfig `yaml:"iatas"` Regions []RegionConfig `yaml:"regions"` @@ -35,6 +36,12 @@ type Config struct { Observers ObserversConfig `yaml:"observers"` } +// AuthConfig holds the operator key for the protected admin subtree. +// The key is excluded from JSON; it must not be exposed by configuration APIs. +type AuthConfig struct { + APIKey string `yaml:"api_key" json:"-"` +} + // ServerConfig controls which direct peers may supply the client address. type ServerConfig struct { // TrustedProxies accepts IPv4/IPv6 CIDRs; an empty list trusts no proxy. @@ -333,6 +340,7 @@ func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { + cfg.Auth.APIKey = os.Getenv("BEACON_API_KEY") return cfg, nil } return nil, err @@ -340,6 +348,9 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, err } + if value, set := os.LookupEnv("BEACON_API_KEY"); set { + cfg.Auth.APIKey = value + } for i, prefix := range cfg.Server.TrustedProxies { if !prefix.IsValid() { return nil, fmt.Errorf("server.trusted_proxies[%d] must be a valid CIDR", i)