diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 3884013d..c9387ee6 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "encoding/base64" "errors" "fmt" "net/http" @@ -94,6 +95,30 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } + if apiKeyHeaders := c.Request.Header["X-Tinyauth-Authorization"]; len(apiKeyHeaders) > 0 { + username, password, ok := parseAPIKeyBasicAuth(apiKeyHeaders[0]) + if !ok { + m.log.App.Debug().Msg("Invalid basic auth in X-Tinyauth-Authorization header") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + userContext, headers, err := m.basicAuth(username, password) + if err != nil { + m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) + c.Next() + return + } + + for k, v := range headers { + c.Header(k, v) + } + + c.Set("context", userContext) + c.Next() + return + } + username, password, ok := c.Request.BasicAuth() if ok { @@ -237,6 +262,8 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri return userContext, cookie, nil } +// basicAuth authenticates a local user and returns the user context with +// any response headers to set. func (m *ContextMiddleware) basicAuth(username string, password string) (*model.UserContext, map[string]string, error) { headers := make(map[string]string) userContext := new(model.UserContext) @@ -359,3 +386,25 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, return &uctx, nil } + +// parseAPIKeyBasicAuth parses an X-Tinyauth-Authorization value in the +// form "Basic base64(username:password)". +func parseAPIKeyBasicAuth(header string) (username string, password string, ok bool) { + const prefix = "Basic " + + if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "", "", false + } + + payload, err := base64.StdEncoding.DecodeString(header[len(prefix):]) + if err != nil { + return "", "", false + } + + username, password, ok = strings.Cut(string(payload), ":") + if !ok { + return "", "", false + } + + return username, password, true +} diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/context_middleware_test.go index 9a2df892..3c8f8557 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -246,6 +246,68 @@ func TestContextMiddleware(t *testing.T) { assert.True(t, userCtx.Authenticated) }, }, + { + description: "Valid X-Tinyauth-Authorization sets authenticated local context", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Tinyauth-Authorization", basicAuthHeader("testuser", "password")) + userCtx, _ := args.do(req) + + require.NotNil(t, userCtx) + assert.Equal(t, model.ProviderLocal, userCtx.Provider) + assert.Equal(t, "testuser", userCtx.GetUsername()) + assert.True(t, userCtx.Authenticated) + }, + }, + { + description: "X-Tinyauth-Authorization takes priority over Authorization", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Tinyauth-Authorization", basicAuthHeader("testuser", "password")) + req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword")) + userCtx, _ := args.do(req) + + require.NotNil(t, userCtx) + assert.Equal(t, "testuser", userCtx.GetUsername()) + assert.True(t, userCtx.Authenticated) + }, + }, + { + description: "Malformed header is rejected without fallback to Authorization", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Tinyauth-Authorization", "Basic !!!not-base64!!!") + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + description: "Non-Basic scheme is rejected without fallback", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Tinyauth-Authorization", "Bearer some-token") + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + description: "Explicitly empty header is rejected without fallback", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header["X-Tinyauth-Authorization"] = []string{""} + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, } ctx := context.TODO()