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:
- verify that we've received the appropriate
Authorization
header. - check that the token is valid and not expired.
- "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.
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
}
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
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
}
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)
}
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
}
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
}
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()
}
}
In the middleware we:
- 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 eachvalidator.ValidationErrors
(individual errors are calledFieldError
) to send to the client as aBadRequest
. We useinvalidArgument
to define the error fields we want to send to the client. - 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. - 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. - If all of these cases pass, we can
Set
the user on our Gin context, and call theNext()
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)
}
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
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"
}'
The response is:
{
"tokens": {
"idToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVpZCI6ImVlODZjYzJjLTg2ODEtNDU5YS1hOTc3LTNjYWY5NzUzZTE0YiIsImVtYWlsIjoiZ3V5MDFAZ3V5LmNvbSIsIm5hbWUiOiIiLCJpbWFnZVVybCI6IiIsIndlYnNpdGUiOiIifSwiZXhwIjoxNjA5MjY5NDI2LCJpYXQiOjE2MDkyNjg1MjZ9.RLC-RcH-YnSJfKqgbucUvvo2DV3sLcJwXSMlbvOqnEbPgjeWv_3ae61lZU909xUMq6Qrl-tpGLxgkrkk3FiXUuhu8J8bBdCYgSgBhPTkoVuALSuC9N-0mcVTLiQ2zZVwxpuDWHCxHtcjinCwt-XSq94CuSqfwDxjmc--Y0IiQMa5pRMa5Ol4qhs0ABkCI-cq0op8_HUOR7mctmiyR1xaKC8AmvLXbgYp7-g5DfKquYjdEDM640W4y99eBTvDRJwHqRTE5QBVYwzVylqFcy82yCriKPB0sgv60iACOjngkzTqatPzYI6C_QUtKOoaNY1NiIpRI99jiFrrW7z1IIt9NA","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJlZTg2Y2MyYy04NjgxLTQ1OWEtYTk3Ny0zY2FmOTc1M2UxNGIiLCJleHAiOjE2MDk1Mjc3MjYsImp0aSI6IjE0NDBlNTg4LWI2NjgtNGVjNy05ZmJiLTU5OTM0ODhjMTE4NCIsImlhdCI6MTYwOTI2ODUyNn0.Hd6j4jzD5IKswvWqnJKG7XFLIBw-IRMLeCD4ojAZedA"
}
}
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'
And we receive the user as a response.
{
"user": {
"uid":"ee86cc2c-8681-459a-a977-3caf9753e14b",
"email":"guy01@guy.com",
"name":"",
"imageUrl":"",
"website":""
}
}
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}
"
}
}
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!
Top comments (1)
which find the 17 lession ?