feat(middleware): add constant-time bearer token auth for machine routes

This commit is contained in:
2026-04-18 07:39:31 +02:00
parent 540298fb88
commit 4a9d1ff908
2 changed files with 95 additions and 0 deletions

View File

@@ -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 <token> 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()
}
}

View File

@@ -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)
}
}