From 4a9d1ff908a26f096f2f06fee963e6e808f91ca1 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Apr 2026 07:39:31 +0200 Subject: [PATCH] feat(middleware): add constant-time bearer token auth for machine routes --- backend/internal/middleware/bearer_token.go | 38 +++++++++++++ .../internal/middleware/bearer_token_test.go | 57 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 backend/internal/middleware/bearer_token.go create mode 100644 backend/internal/middleware/bearer_token_test.go diff --git a/backend/internal/middleware/bearer_token.go b/backend/internal/middleware/bearer_token.go new file mode 100644 index 0000000..b524590 --- /dev/null +++ b/backend/internal/middleware/bearer_token.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "crypto/subtle" + "strings" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +// RequireBearerToken validates Authorization: Bearer against the given +// secret using constant-time compare. An empty secret disables the route (all +// requests are rejected) — a safety default for dev environments that haven't +// set the token. +func RequireBearerToken(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + if secret == "" { + apiErr := apierror.Unauthorized("endpoint disabled") + c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + auth := c.GetHeader("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + apiErr := apierror.Unauthorized("missing bearer token") + c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + provided := strings.TrimPrefix(auth, prefix) + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { + apiErr := apierror.Unauthorized("invalid bearer token") + c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/bearer_token_test.go b/backend/internal/middleware/bearer_token_test.go new file mode 100644 index 0000000..46b0526 --- /dev/null +++ b/backend/internal/middleware/bearer_token_test.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRequireBearerToken(t *testing.T) { + gin.SetMode(gin.TestMode) + secret := "s3cret" + mw := RequireBearerToken(secret) + + tests := []struct { + name string + header string + wantStatus int + }{ + {"missing", "", http.StatusUnauthorized}, + {"wrong scheme", "Basic abcd", http.StatusUnauthorized}, + {"wrong token", "Bearer wrong", http.StatusUnauthorized}, + {"correct", "Bearer s3cret", http.StatusOK}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(mw) + r.GET("/t", func(c *gin.Context) { c.Status(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/t", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + r.ServeHTTP(w, req) + if w.Code != tc.wantStatus { + t.Errorf("status=%d want %d", w.Code, tc.wantStatus) + } + }) + } +} + +func TestRequireBearerToken_EmptySecretRejectsAll(t *testing.T) { + gin.SetMode(gin.TestMode) + mw := RequireBearerToken("") // feature disabled → all requests 401 + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(mw) + r.GET("/t", func(c *gin.Context) { c.Status(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("Authorization", "Bearer anything") + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 when secret is empty, got %d", w.Code) + } +}