diff --git a/docs/middleware/keyauth.md b/docs/middleware/keyauth.md index ce39b820f60..a09414e922d 100644 --- a/docs/middleware/keyauth.md +++ b/docs/middleware/keyauth.md @@ -217,10 +217,11 @@ curl --header "Authorization: Bearer my-super-secret-key" http://localhost:3000 |:----------------|:-----------------------------------------|:-------------------------------------------------------------------------------------------------------|:------------------------------| | Next | `func(fiber.Ctx) bool` | Next defines a function to skip this middleware when returned true. | `nil` | | SuccessHandler | `fiber.Handler` | SuccessHandler defines a function which is executed for a valid key. | `nil` | -| ErrorHandler | `fiber.ErrorHandler` | ErrorHandler defines a function which is executed for an invalid key. | `401 Invalid or expired key` | +| ErrorHandler | `fiber.ErrorHandler` | ErrorHandler defines a function which is executed for an invalid key. By default a 401 response with a `WWW-Authenticate` challenge is sent. | `nil` | | KeyLookup | `string` | KeyLookup is a string in the form of "`:`" that is used to extract the key from the request. | "header:Authorization" | | CustomKeyLookup | `KeyLookupFunc` aka `func(c fiber.Ctx) (string, error)` | If more complex logic is required to extract the key from the request, an arbitrary function to extract it can be specified here. Utility helper functions are described below. | `nil` | | AuthScheme | `string` | AuthScheme to be used in the Authorization header. | "Bearer" | +| Realm | `string` | Realm specifies the protected area name used in the `WWW-Authenticate` header. | `"Restricted"` | | Validator | `func(fiber.Ctx, string) (bool, error)` | Validator is a function to validate the key. | A function for key validation | ## Default Config @@ -230,15 +231,11 @@ var ConfigDefault = Config{ SuccessHandler: func(c fiber.Ctx) error { return c.Next() }, - ErrorHandler: func(c fiber.Ctx, err error) error { - if err == ErrMissingOrMalformedAPIKey { - return c.Status(fiber.StatusUnauthorized).SendString(err.Error()) - } - return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired API Key") - }, - KeyLookup: "header:" + fiber.HeaderAuthorization, + ErrorHandler: nil, + KeyLookup: "header:" + fiber.HeaderAuthorization, CustomKeyLookup: nil, - AuthScheme: "Bearer", + AuthScheme: "Bearer", + Realm: "Restricted", } ``` diff --git a/docs/whats_new.md b/docs/whats_new.md index 5ee03bde128..0cb9a7a90c5 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -36,6 +36,7 @@ Here's a quick overview of the changes in Fiber `v3`: - [EncryptCookie](#encryptcookie) - [Filesystem](#filesystem) - [Healthcheck](#healthcheck) + - [KeyAuth](#keyauth) - [Logger](#logger) - [Monitor](#monitor) - [Proxy](#proxy) @@ -1020,6 +1021,10 @@ The Healthcheck middleware has been enhanced to support more than two routes, wi Refer to the [healthcheck middleware migration guide](./middleware/healthcheck.md) or the [general migration guide](#-migration-guide) to review the changes. +### KeyAuth + +The keyauth middleware was updated to introduce a configurable `Realm` field for the `WWW-Authenticate` header. + ### Logger New helper function called `LoggerToWriter` has been added to the logger middleware. This function allows you to use 3rd party loggers such as `logrus` or `zap` with the Fiber logger middleware without any extra afford. For example, you can use `zap` with Fiber logger middleware like this: diff --git a/middleware/keyauth/config.go b/middleware/keyauth/config.go index c7cb8172019..4c89f51a268 100644 --- a/middleware/keyauth/config.go +++ b/middleware/keyauth/config.go @@ -2,6 +2,7 @@ package keyauth import ( "errors" + "fmt" "github.com/gofiber/fiber/v3" ) @@ -42,6 +43,10 @@ type Config struct { // AuthScheme to be used in the Authorization header. // Optional. Default value "Bearer". AuthScheme string + + // Realm defines the protected area for WWW-Authenticate responses. + // Optional. Default value "Restricted". + Realm string } // ConfigDefault is the default config @@ -49,15 +54,11 @@ var ConfigDefault = Config{ SuccessHandler: func(c fiber.Ctx) error { return c.Next() }, - ErrorHandler: func(c fiber.Ctx, err error) error { - if errors.Is(err, ErrMissingOrMalformedAPIKey) { - return c.Status(fiber.StatusUnauthorized).SendString(err.Error()) - } - return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired API Key") - }, + ErrorHandler: nil, KeyLookup: "header:" + fiber.HeaderAuthorization, CustomKeyLookup: nil, AuthScheme: "Bearer", + Realm: "Restricted", } // Helper function to set default values @@ -71,19 +72,30 @@ func configDefault(config ...Config) Config { cfg := config[0] // Set default values - if cfg.SuccessHandler == nil { - cfg.SuccessHandler = ConfigDefault.SuccessHandler - } - if cfg.ErrorHandler == nil { - cfg.ErrorHandler = ConfigDefault.ErrorHandler - } if cfg.KeyLookup == "" { cfg.KeyLookup = ConfigDefault.KeyLookup - // set AuthScheme as "Bearer" only if KeyLookup is set to default. if cfg.AuthScheme == "" { cfg.AuthScheme = ConfigDefault.AuthScheme } } + if cfg.Realm == "" { + cfg.Realm = ConfigDefault.Realm + } + if cfg.SuccessHandler == nil { + cfg.SuccessHandler = ConfigDefault.SuccessHandler + } + if cfg.ErrorHandler == nil { + localCfg := cfg + cfg.ErrorHandler = func(c fiber.Ctx, err error) error { + if localCfg.AuthScheme != "" { + c.Set(fiber.HeaderWWWAuthenticate, fmt.Sprintf("%s realm=%q", localCfg.AuthScheme, localCfg.Realm)) + } + if errors.Is(err, ErrMissingOrMalformedAPIKey) { + return c.Status(fiber.StatusUnauthorized).SendString(err.Error()) + } + return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired API Key") + } + } if cfg.Validator == nil { panic("fiber: keyauth middleware requires a validator function") } diff --git a/middleware/keyauth/keyauth.go b/middleware/keyauth/keyauth.go index e245ba42473..376a93918a9 100644 --- a/middleware/keyauth/keyauth.go +++ b/middleware/keyauth/keyauth.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gofiber/fiber/v3" + "github.com/gofiber/utils/v2" ) // The contextKey type is unexported to prevent collisions with context keys defined in @@ -124,15 +125,31 @@ func DefaultKeyLookup(keyLookup, authScheme string) (KeyLookupFunc, error) { // keyFromHeader returns a function that extracts api key from the request header. func KeyFromHeader(header, authScheme string) KeyLookupFunc { return func(c fiber.Ctx) (string, error) { - auth := c.Get(header) - l := len(authScheme) - if len(auth) > 0 && l == 0 { + auth := utils.Trim(c.Get(header), ' ') + if auth == "" { + return "", ErrMissingOrMalformedAPIKey + } + + if authScheme == "" { return auth, nil } - if len(auth) > l+1 && auth[:l] == authScheme { - return auth[l+1:], nil + + l := len(authScheme) + if len(auth) <= l || !utils.EqualFold(auth[:l], authScheme) { + return "", ErrMissingOrMalformedAPIKey } - return "", ErrMissingOrMalformedAPIKey + + rest := auth[l:] + if len(rest) == 0 || (rest[0] != ' ' && rest[0] != '\t') { + return "", ErrMissingOrMalformedAPIKey + } + + token := strings.TrimLeft(rest, " \t") + if token == "" { + return "", ErrMissingOrMalformedAPIKey + } + + return token, nil } } diff --git a/middleware/keyauth/keyauth_test.go b/middleware/keyauth/keyauth_test.go index 72c9d3c1b4d..40916b0279d 100644 --- a/middleware/keyauth/keyauth_test.go +++ b/middleware/keyauth/keyauth_test.go @@ -2,6 +2,7 @@ package keyauth import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -614,3 +615,155 @@ func Test_AuthSchemeBasic(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, "API key is valid", string(body)) } + +func Test_HeaderSchemeCaseInsensitive(t *testing.T) { + app := fiber.New() + app.Use(New(Config{ + Validator: func(_ fiber.Ctx, key string) (bool, error) { + if key == CorrectKey { + return true, nil + } + return false, ErrMissingOrMalformedAPIKey + }, + })) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Add("Authorization", "bearer "+CorrectKey) + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "OK", string(body)) +} + +func Test_DefaultErrorHandlerChallenge(t *testing.T) { + app := fiber.New() + app.Use(New(Config{ + Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, ErrMissingOrMalformedAPIKey + }, + })) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + res, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, "Bearer realm=\"Restricted\"", res.Header.Get("WWW-Authenticate")) +} + +func Test_DefaultErrorHandlerInvalid(t *testing.T) { + app := fiber.New() + app.Use(New(Config{ + Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, errors.New("invalid") + }, + })) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Add("Authorization", "Bearer "+CorrectKey) + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, "Invalid or expired API Key", string(body)) + require.Equal(t, "Bearer realm=\"Restricted\"", res.Header.Get("WWW-Authenticate")) +} + +func Test_HeaderSchemeMultipleSpaces(t *testing.T) { + app := fiber.New() + app.Use(New(Config{ + Validator: func(_ fiber.Ctx, key string) (bool, error) { + if key == CorrectKey { + return true, nil + } + return false, ErrMissingOrMalformedAPIKey + }, + })) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Add("Authorization", "Bearer "+CorrectKey) + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "OK", string(body)) +} + +func Test_HeaderSchemeMissingSpace(t *testing.T) { + app := fiber.New() + app.Use(New(Config{Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, ErrMissingOrMalformedAPIKey + }})) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Add("Authorization", "Bearer"+CorrectKey) + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, ErrMissingOrMalformedAPIKey.Error(), string(body)) +} + +func Test_HeaderSchemeNoToken(t *testing.T) { + app := fiber.New() + app.Use(New(Config{Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, ErrMissingOrMalformedAPIKey + }})) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Add("Authorization", "Bearer ") + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, ErrMissingOrMalformedAPIKey.Error(), string(body)) +} + +func Test_HeaderSchemeNoSeparator(t *testing.T) { + app := fiber.New() + app.Use(New(Config{Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, ErrMissingOrMalformedAPIKey + }})) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + // No space between "Bearer" and token + req.Header.Add("Authorization", "BearerTokenWithoutSpace") + res, err := app.Test(req) + require.NoError(t, err) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, ErrMissingOrMalformedAPIKey.Error(), string(body)) +} + +func Test_HeaderSchemeEmptyTokenAfterTrim(t *testing.T) { + app := fiber.New() + app.Use(New(Config{ + Validator: func(_ fiber.Ctx, _ string) (bool, error) { + return false, ErrMissingOrMalformedAPIKey + }, + })) + app.Get("/", func(c fiber.Ctx) error { return c.SendString("OK") }) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + // Authorization header with scheme followed by only spaces/tabs (no actual token) + req.Header.Add("Authorization", "Bearer \t \t ") + res, err := app.Test(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, ErrMissingOrMalformedAPIKey.Error(), string(body)) +}