feat(middleware): add constant-time bearer token auth for machine routes
This commit is contained in:
38
backend/internal/middleware/bearer_token.go
Normal file
38
backend/internal/middleware/bearer_token.go
Normal 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()
|
||||
}
|
||||
}
|
||||
57
backend/internal/middleware/bearer_token_test.go
Normal file
57
backend/internal/middleware/bearer_token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user