DEV Community

loading...
Cover image for 16 - Create Gin Middleware to Extract Authorized User

16 - Create Gin Middleware to Extract Authorized User

jacobsngoodwin profile image Jacob Goodwin ・8 min read

Today we'll create a middleware to extract a user from an ID token sent to our application on an Authorization header of an HTTP request. This ID token is the one we send to a user in JWT format when they sign up or sign in.

The middleware will do the following:

  1. verify that we've received the appropriate Authorization header.
  2. check that the token is valid and not expired.
  3. "set" the user on the Gin context so that route handlers can use the verified user data to make authorized updates for this user (like updating their profile, for example).

In some role- or permission-based authorization schemes, the token might store a role, or the role might be fetched from a database based on an ID stored in the token. In our application, each user will only be authorized to make changes to their own account info, so we will not be looking up or assigning any permissions.

Auth User Middleware

In order to create this middleware, we'll need to add a new method to our our TokenService called ValidateIDToken. As middleware is a sub-package of handler, we'll have to pass the TokenService as a parameter to the middleware.

After we've completed these tasks, we'll be able to run our application and send a Get Request with a user's valid ID token to the "/me" endpoint and receive the user's details.

As always, check out the Github repo with all of the code for this tutorial including a branch for each lesson and some unit tests not included in the tutorials!

If you prefer video

Review of Me Handler

If we look at the current code of our "me" handler, we see that we extract a user from the gin context with a Get method. This user is then cast to a *model.User. The middleware we're going to write will make sure that a user is available on the "user" key when this handler method is executed.

func (h *Handler) Me(c *gin.Context) {
    // A *model.User will eventually be added to context in middleware
    user, exists := c.Get("user")

  // This shouldn't happen, as our middleware ought to throw an error.
    // We can also use "MustGet" to get the key or panic
    if !exists {
        log.Printf("Unable to extract user from request context for unknown reason: %v\n", c)
        err := apperrors.NewInternal()
        c.JSON(err.Status(), gin.H{
            "error": err,
        })

        return
    }

  uid := user.(*model.User).UID

  // code omitted
}
Enter fullscreen mode Exit fullscreen mode

Fix UserService.Signin

I previously failed to update the *model.User passed as a method parameter to UserService.Signin with the user returned by UserRepository.FindByEmail.

Let's fix this as follows:

  // prev
  u = uFetched

  // May be better to return new user from method
  *u = *uFetched
Enter fullscreen mode Exit fullscreen mode

If I had to do things again, I would pass an email and a password (or else a struct containing these fields) to UserService.Signin, and return (*model.User, error). I believe this approach would be much easier to understand.

To avoid having to modify our tests and handler.Signin, we won't fix this now, but feel free to do this in your app.

Add a validateIDToken Function

We previously created a file with some utility functions for working with tokens in ~/service/tokens.go. Let's now update this file with with a function to validate an ID token.

// IDTokenCustomClaims holds structure of jwt claims of idToken
type IDTokenCustomClaims struct {
    User *model.User `json:"user"`
    jwt.StandardClaims
}

// CODE OMITTED ...

// validateIDToken returns the token's claims if the token is valid
func validateIDToken(tokenString string, key *rsa.PublicKey) (*IDTokenCustomClaims, error) {
    claims := &IDTokenCustomClaims{}

    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
        return key, nil
    })

    // For now we'll just return the error and handle logging in service level
    if err != nil {
        return nil, err
    }

    if !token.Valid {
        return nil, fmt.Errorf("ID token is invalid")
    }

    claims, ok := token.Claims.(*IDTokenCustomClaims)

    if !ok {
        return nil, fmt.Errorf("ID token valid but couldn't parse claims")
    }

    return claims, nil
}
Enter fullscreen mode Exit fullscreen mode

This function takes in the stringified version of our JWT, tokenString, and the rsa.PublicKey which is a field of our tokenService. We first parse the JWT using the public RSA key. This will return a *jwt.Token. This token will contain a valid field indicating whether the token is valid (including if it is expired). It also contains the claims, or fields, of the token. We cast these claims to *IDTokenCustomClaims and then return them.

Use validateIDToken in TokenService.ValidateIDToken

Add Method to Interfaces

We need to update the TokenService interface by creating the method signature for ValidateIDToken. We do this in ~/model/interfaces.go.

// TokenService defines methods the handler layer expects to interact
// with in regards to producing JWTs as string
type TokenService interface {
    NewPairFromUser(ctx context.Context, u *User, prevTokenID string) (*TokenPair, error)
    ValidateIDToken(tokenString string) (*User, error)
}
Enter fullscreen mode Exit fullscreen mode

We need not pass a context as this method will not be making calls to the repository layer and because our token functions do not require a context.

Create Mock ValidateIDToken

To get rid of warnings, I'll add a mock definition in ~/model/mocks/token_service.go.

// ValidateIDToken mocks concrete ValidateIDToken
func (m *MockTokenService) ValidateIDToken(tokenString string) (*model.User, error) {
    ret := m.Called(tokenString)

    // first value passed to "Return"
    var r0 *model.User
    if ret.Get(0) != nil {
        // we can just return this if we know we won't be passing function to "Return"
        r0 = ret.Get(0).(*model.User)
    }

    var r1 error

    if ret.Get(1) != nil {
        r1 = ret.Get(1).(error)
    }

    return r0, r1
}
Enter fullscreen mode Exit fullscreen mode

ValidateIDToken Implementation

The implementation of TokenService.ValidateIDToken will be pretty simple. The main purpose is to extract and return the *model.User from off of the IDTokenCustomClaims. Let's update ~/service/token_service.go.

In the case of any error from validateIDToken, we'll return an authorization error.

// ValidateIDToken validates the id token jwt string
// It returns the user extract from the IDTokenCustomClaims
func (s *tokenService) ValidateIDToken(tokenString string) (*model.User, error) {
    claims, err := validateIDToken(tokenString, s.PubKey) // uses public RSA key

    // We'll just return unauthorized error in all instances of failing to verify user
    if err != nil {
        log.Printf("Unable to validate or parse idToken - Error: %v\n", err)
        return nil, apperrors.NewAuthorization("Unable to verify user from idToken")
    }

    return claims.User, nil
}
Enter fullscreen mode Exit fullscreen mode

Create AuthUser Middleware

We can finally create the middleware to extract an authorized user! To do this, let's add a file, ~/handler/middleware/auth_user.go.

package middleware

// IMPORTS OMITTED - Make sure to import validator/v10
// My auto import always uses V9

type authHeader struct {
    IDToken string `header:"Authorization"`
}

// used to help extract validation errors
type invalidArgument struct {
    Field string `json:"field"`
    Value string `json:"value"`
    Tag   string `json:"tag"`
    Param string `json:"param"`
}

// AuthUser extracts a user from the Authorization header
// which is of the form "Bearer token"
// It sets the user to the context if the user exists
func AuthUser(s model.TokenService) gin.HandlerFunc {
    return func(c *gin.Context) {
        h := authHeader{}

        // bind Authorization Header to h and check for validation errors
        if err := c.ShouldBindHeader(&h); err != nil {
            if errs, ok := err.(validator.ValidationErrors); ok {
                // we used this type in bind_data to extract desired fields from errs
                // you might consider extracting it
                var invalidArgs []invalidArgument

                for _, err := range errs {
                    invalidArgs = append(invalidArgs, invalidArgument{
                        err.Field(),
                        err.Value().(string),
                        err.Tag(),
                        err.Param(),
                    })
                }

                err := apperrors.NewBadRequest("Invalid request parameters. See invalidArgs")

                c.JSON(err.Status(), gin.H{
                    "error":       err,
                    "invalidArgs": invalidArgs,
                })
                c.Abort()
                return
            }

            // otherwise error type is unknown
            err := apperrors.NewInternal()
            c.JSON(err.Status(), gin.H{
                "error": err,
            })
            c.Abort()
            return
        }

        idTokenHeader := strings.Split(h.IDToken, "Bearer ")

        if len(idTokenHeader) < 2 {
            err := apperrors.NewAuthorization("Must provide Authorization header with format `Bearer {token}`")

            c.JSON(err.Status(), gin.H{
                "error": err,
            })
            c.Abort()
            return
        }

        // validate ID token here
        user, err := s.ValidateIDToken(idTokenHeader[1])

        if err != nil {
            err := apperrors.NewAuthorization("Provided token is invalid")
            c.JSON(err.Status(), gin.H{
                "error": err,
            })
            c.Abort()
            return
        }

        c.Set("user", user)

        c.Next()
    }
}
Enter fullscreen mode Exit fullscreen mode

In the middleware we:

  1. Check for validation errors provided by Gin's ShouldBindHeader. We check if the error is a validation error. If it is, we extract a few fields off of each validator.ValidationErrors (individual errors are called FieldError) to send to the client as a BadRequest. We use invalidArgument to define the error fields we want to send to the client.
  2. Check that the Authorization header is provided in the format Bearer {token}. We split the token off of the string, which is a stringified JWT, and check for any errors.
  3. Finally, we reach out to the ValidateIDToken method we just created, which uses the JWT library to make sure the token can be verified with the public RSA key, and that the token is not expired.
  4. If all of these cases pass, we can Set the user on our Gin context, and call the Next() handler.

Apply AuthUser to Me Handler

Recall that our Me handler will expect to extract a model.User from the gin context. To make this user available to our handler, we need to add the AuthUser middleware to this individual handler.

We can update this in ~/handler/handler.go.

  // Wish I had thought this through better!
    if gin.Mode() != gin.TestMode {
        g.Use(middleware.Timeout(c.TimeoutDuration, apperrors.NewServiceUnavailable()))
        g.GET("/me", middleware.AuthUser(h.TokenService), h.Me)
    } else {
        g.GET("/me", h.Me)
    }
Enter fullscreen mode Exit fullscreen mode

Notice that we only add the middleware if we're not in test mode. That's because we previously created a handler test in isolation of the middleware. You can choose to do it this way, or to integrate middleware and handlers for your test.

I'd really love if any of you have any practical suggestions on how to handle this in production-grade applications!

Run and Test Application

From the application root, let's run our application!

docker-compose up
Enter fullscreen mode Exit fullscreen mode

Sign In An Existing User

I'll first sign in an existing user.

If you want to see how to make this request in Postman, checkout the YouTube video.

➜ curl --location --request POST 'http://malcorp.test/api/account/signin' \
--header 'Authorization: Bearer {{idToken}}' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "guy01@guy.com",
    "password": "avalidpassword123"
}'
Enter fullscreen mode Exit fullscreen mode

The response is:

{
  "tokens": {
    "idToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVpZCI6ImVlODZjYzJjLTg2ODEtNDU5YS1hOTc3LTNjYWY5NzUzZTE0YiIsImVtYWlsIjoiZ3V5MDFAZ3V5LmNvbSIsIm5hbWUiOiIiLCJpbWFnZVVybCI6IiIsIndlYnNpdGUiOiIifSwiZXhwIjoxNjA5MjY5NDI2LCJpYXQiOjE2MDkyNjg1MjZ9.RLC-RcH-YnSJfKqgbucUvvo2DV3sLcJwXSMlbvOqnEbPgjeWv_3ae61lZU909xUMq6Qrl-tpGLxgkrkk3FiXUuhu8J8bBdCYgSgBhPTkoVuALSuC9N-0mcVTLiQ2zZVwxpuDWHCxHtcjinCwt-XSq94CuSqfwDxjmc--Y0IiQMa5pRMa5Ol4qhs0ABkCI-cq0op8_HUOR7mctmiyR1xaKC8AmvLXbgYp7-g5DfKquYjdEDM640W4y99eBTvDRJwHqRTE5QBVYwzVylqFcy82yCriKPB0sgv60iACOjngkzTqatPzYI6C_QUtKOoaNY1NiIpRI99jiFrrW7z1IIt9NA","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJlZTg2Y2MyYy04NjgxLTQ1OWEtYTk3Ny0zY2FmOTc1M2UxNGIiLCJleHAiOjE2MDk1Mjc3MjYsImp0aSI6IjE0NDBlNTg4LWI2NjgtNGVjNy05ZmJiLTU5OTM0ODhjMTE4NCIsImlhdCI6MTYwOTI2ODUyNn0.Hd6j4jzD5IKswvWqnJKG7XFLIBw-IRMLeCD4ojAZedA"
  }
} 
Enter fullscreen mode Exit fullscreen mode

We can then add the idToken to our "Authorization" header while making a GET request to "/me".

➜ curl --location --request GET 'http://malcorp.test/api/account/me' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVpZCI6ImVlODZjYzJjLTg2ODEtNDU5YS1hOTc3LTNjYWY5NzUzZTE0YiIsImVtYWlsIjoiZ3V5MDFAZ3V5LmNvbSIsIm5hbWUiOiIiLCJpbWFnZVVybCI6IiIsIndlYnNpdGUiOiIifSwiZXhwIjoxNjA5MjY5NDI2LCJpYXQiOjE2MDkyNjg1MjZ9.RLC-RcH-YnSJfKqgbucUvvo2DV3sLcJwXSMlbvOqnEbPgjeWv_3ae61lZU909xUMq6Qrl-tpGLxgkrkk3FiXUuhu8J8bBdCYgSgBhPTkoVuALSuC9N-0mcVTLiQ2zZVwxpuDWHCxHtcjinCwt-XSq94CuSqfwDxjmc--Y0IiQMa5pRMa5Ol4qhs0ABkCI-cq0op8_HUOR7mctmiyR1xaKC8AmvLXbgYp7-g5DfKquYjdEDM640W4y99eBTvDRJwHqRTE5QBVYwzVylqFcy82yCriKPB0sgv60iACOjngkzTqatPzYI6C_QUtKOoaNY1NiIpRI99jiFrrW7z1IIt9NA'
Enter fullscreen mode Exit fullscreen mode

And we receive the user as a response.

{
  "user": {
    "uid":"ee86cc2c-8681-459a-a977-3caf9753e14b",
    "email":"guy01@guy.com",
    "name":"",
    "imageUrl":"",
    "website":""
  }
}
Enter fullscreen mode Exit fullscreen mode

I recommend you try to send the request with a missing auth header, which should return an HTTP 401 error with a response such as:

{
    "error": {
        "type": "AUTHORIZATION",
        "message": "Must provide Authorization header with format `Bearer {token}`"
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

That's all for today. Once again I'll remind you of the unit tests available in the Github repository!

Next time, we'll get working on a "tokens" handler which will be used by a client to get renewed id and refresh tokens. This helps clients remain logged in to applications in our company or domain!

Hasta pronto!

Discussion (0)

pic
Editor guide