From 050e189f932630801b4933af6537868638afb665 Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 31 May 2024 10:26:35 +0200 Subject: [PATCH] IAM: Set Cache-Control headers (#3147) --- auth/api/iam/api.go | 19 ++++++++ auth/api/iam/api_test.go | 49 +++++++++++++++----- http/cache/middleware.go | 76 +++++++++++++++++++++++++++++++ http/cache/middleware_test.go | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 http/cache/middleware.go create mode 100644 http/cache/middleware_test.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 4961bfa4a5..43cac885b0 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -26,6 +26,7 @@ import ( "encoding/base64" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/http/cache" "github.com/nuts-foundation/nuts-node/http/user" "html/template" "net/http" @@ -71,6 +72,22 @@ const accessTokenValidity = 15 * time.Minute const oid4vciSessionValidity = 15 * time.Minute +// cacheControlMaxAgeURLs holds API endpoints that should have a max-age cache control header set. +var cacheControlMaxAgeURLs = []string{ + "/.well-known/did.json", + "/iam/:id/did.json", + "/oauth2/:did/presentation_definition", + "/.well-known/oauth-authorization-server/iam/:id", + "/.well-known/oauth-authorization-server", + "/oauth2/:did/oauth-client", + "/statuslist/:did/:page", +} + +// cacheControlNoCacheURLs holds API endpoints that should have a no-cache cache control header set. +var cacheControlNoCacheURLs = []string{ + "/oauth2/:did/token", +} + //go:embed assets var assetsFS embed.FS @@ -130,6 +147,8 @@ func (r Wrapper) Routes(router core.EchoRouter) { return next(c) } }, audit.Middleware(apiModuleName)) + router.Use(cache.MaxAge(5*time.Minute, cacheControlMaxAgeURLs...).Handle) + router.Use(cache.NoCache(cacheControlNoCacheURLs...).Handle) router.Use(user.SessionMiddleware{ Skipper: func(c echo.Context) bool { // The following URLs require a user session: diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 688a3a5aa6..acf7c1c167 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -698,16 +698,45 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { } func TestWrapper_Routes(t *testing.T) { - ctrl := gomock.NewController(t) - router := core.NewMockEchoRouter(ctrl) - - router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - router.EXPECT().Use(gomock.AssignableToTypeOf(user.SessionMiddleware{}.Handle)) - - (&Wrapper{ - storageEngine: storage.NewTestStorageEngine(t), - }).Routes(router) + t.Run("it registers handlers", func(t *testing.T) { + ctrl := gomock.NewController(t) + router := core.NewMockEchoRouter(ctrl) + + router.EXPECT().Use(gomock.Any()).AnyTimes() + router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + (&Wrapper{ + storageEngine: storage.NewTestStorageEngine(t), + }).Routes(router) + }) + t.Run("cache middleware URLs match registered paths", func(t *testing.T) { + ctrl := gomock.NewController(t) + router := core.NewMockEchoRouter(ctrl) + + var registeredPaths []string + router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(path string, _ echo.HandlerFunc, _ ...echo.MiddlewareFunc) *echo.Route { + registeredPaths = append(registeredPaths, path) + return nil + }).AnyTimes() + router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(path string, _ echo.HandlerFunc, _ ...echo.MiddlewareFunc) *echo.Route { + registeredPaths = append(registeredPaths, path) + return nil + }).AnyTimes() + router.EXPECT().Use(gomock.Any()).AnyTimes() + (&Wrapper{ + storageEngine: storage.NewTestStorageEngine(t), + }).Routes(router) + + // Check that all cache-control max-age paths are actual paths + for _, path := range cacheControlMaxAgeURLs { + assert.Contains(t, registeredPaths, path) + } + // Check that all cache-control no-cache paths are actual paths + for _, path := range cacheControlNoCacheURLs { + assert.Contains(t, registeredPaths, path) + } + }) } func TestWrapper_middleware(t *testing.T) { diff --git a/http/cache/middleware.go b/http/cache/middleware.go new file mode 100644 index 0000000000..e3801a60cb --- /dev/null +++ b/http/cache/middleware.go @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package cache + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "time" +) + +// Middleware is a middleware that sets the Cache-Control header (no-cache or max-age) for the given request URLs. +// Use MaxAge or NoCache to create a new instance. +type Middleware struct { + Skipper middleware.Skipper + maxAge time.Duration +} + +func (m Middleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if !m.Skipper(c) { + if m.maxAge == -1 { + c.Response().Header().Set("Cache-Control", "no-cache") + // Pragma is deprecated (HTTP/1.0) but it's specified by OAuth2 RFC6749, + // so specify it for compliance. + c.Response().Header().Set("Pragma", "no-store") + } else if m.maxAge > 0 { + c.Response().Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(m.maxAge.Seconds()))) + } + } + return next(c) + } +} + +// MaxAge creates a new middleware that sets the Cache-Control header to the given max-age for the given request URLs. +func MaxAge(maxAge time.Duration, requestURLs ...string) Middleware { + return Middleware{ + Skipper: matchRequestPathSkipper(requestURLs), + maxAge: maxAge, + } +} + +// NoCache creates a new middleware that sets the Cache-Control header to no-cache for the given request URLs. +func NoCache(requestURLs ...string) Middleware { + return Middleware{ + Skipper: matchRequestPathSkipper(requestURLs), + maxAge: -1, + } +} + +func matchRequestPathSkipper(requestURLs []string) func(c echo.Context) bool { + return func(c echo.Context) bool { + for _, curr := range requestURLs { + if c.Request().URL.Path == curr { + return false + } + } + return true + } +} diff --git a/http/cache/middleware_test.go b/http/cache/middleware_test.go new file mode 100644 index 0000000000..47426b7e20 --- /dev/null +++ b/http/cache/middleware_test.go @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package cache + +import ( + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "net/http/httptest" + "testing" + "time" +) + +func TestMaxAge(t *testing.T) { + t.Run("match", func(t *testing.T) { + e := echo.New() + httpResponse := httptest.NewRecorder() + echoContext := e.NewContext(httptest.NewRequest("GET", "/a", nil), httpResponse) + + err := MaxAge(time.Minute, "/a", "/b").Handle(func(c echo.Context) error { + return c.String(200, "OK") + })(echoContext) + + require.NoError(t, err) + require.Equal(t, "max-age=60", httpResponse.Header().Get("Cache-Control")) + }) + t.Run("no match", func(t *testing.T) { + e := echo.New() + httpResponse := httptest.NewRecorder() + echoContext := e.NewContext(httptest.NewRequest("GET", "/c", nil), httpResponse) + + err := MaxAge(time.Minute, "/a", "/b").Handle(func(c echo.Context) error { + return c.String(200, "OK") + })(echoContext) + + require.NoError(t, err) + require.Empty(t, httpResponse.Header().Get("Cache-Control")) + }) + +} + +func TestNoCache(t *testing.T) { + t.Run("match", func(t *testing.T) { + e := echo.New() + httpResponse := httptest.NewRecorder() + echoContext := e.NewContext(httptest.NewRequest("GET", "/a", nil), httpResponse) + + err := NoCache("/a", "/b").Handle(func(c echo.Context) error { + return c.String(200, "OK") + })(echoContext) + + require.NoError(t, err) + require.Equal(t, "no-cache", httpResponse.Header().Get("Cache-Control")) + require.Equal(t, "no-store", httpResponse.Header().Get("Pragma")) + }) + t.Run("no match", func(t *testing.T) { + e := echo.New() + httpResponse := httptest.NewRecorder() + echoContext := e.NewContext(httptest.NewRequest("GET", "/c", nil), httpResponse) + + err := NoCache("/a", "/b").Handle(func(c echo.Context) error { + return c.String(200, "OK") + })(echoContext) + + require.NoError(t, err) + require.Empty(t, httpResponse.Header().Get("Cache-Control")) + require.Empty(t, httpResponse.Header().Get("Pragma")) + }) +}