Authenticating requests
Now that our clients have a way to exchange their credentials for an authentication token, let’s look at how we can use that token to authenticate them, so we know exactly which user a request is coming from.
Essentially, once a client has an authentication token we will expect them to include it with all subsequent requests in an Authorization header, like so:
Authorization: Bearer IEYZQUBEMPPAKPOAWTPV6YJ6RM
When we receive these requests, we’ll use a new authenticate() middleware method to execute the following logic:
- If the authentication token is not valid, then we will send the client a
401 Unauthorizedresponse and an error message to let them know that their token is malformed or invalid. - If the authentication token is valid, we will look up the user details and add their details to the request context.
- If no
Authorizationheader was provided at all, then we will add the details for an anonymous user to the request context instead.
Creating the anonymous user
Let’s start with the final bullet point, and first define an anonymous user in our internal/data/users.go file, like so:
package data ... // Declare a new AnonymousUser variable. var AnonymousUser = &User{} type User struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at"` Name string `json:"name"` Email string `json:"email"` Password password `json:"-"` Activated bool `json:"activated"` Version int `json:"-"` } // Check if a User instance is the AnonymousUser. func (u *User) IsAnonymous() bool { return u == AnonymousUser } ...
So here we’ve created a new AnonymousUser variable, which holds a pointer to a User struct representing an inactivated user with no ID, name, email or password.
We’ve also implemented an IsAnonymous() method on the User struct, so whenever we have a User instance we can easily check whether it is the AnonymousUser instance or not. For example:
data.AnonymousUser.IsAnonymous() // → Returns true otherUser := &data.User{} otherUser.IsAnonymous() // → Returns false
Reading and writing to the request context
The other setup step, before we get into creating the authenticate() middleware itself, relates to storing the user details in the request context.
We discussed what request context is and how to use it in detail in Let’s Go, and if any of this feels unfamiliar then I recommend rereading that section of the book before continuing. But as a quick reminder:
Every
http.Requestthat our application processes has acontext.Contextembedded in it, which we can use to store key-value pairs containing arbitrary data during the lifetime of the request. In this case we want to store aUserstruct containing the current user’s information.Any values stored in the request context have the type
any. This means that after retrieving a value from the request context you need to assert it back to its original type before using it.It’s good practice to use your own custom type for the request context keys. This helps prevent naming collisions between your code and any third-party packages which are also using the request context to store information.
To help with this, let’s create a new cmd/api/context.go file containing some helper methods for reading/writing the User struct to and from the request context.
If you’re following along, go ahead and create the new file:
$ touch cmd/api/context.go
And then add the following code:
package main import ( "context" "net/http" "greenlight.alexedwards.net/internal/data" ) // Define a custom contextKey type, with the underlying type string. type contextKey string // Convert the string "user" to a contextKey type and assign it to the userContextKey // constant. We'll use this constant as the key for getting and setting user information // in the request context. const userContextKey = contextKey("user") // The contextSetUser() method returns a new copy of the request with the provided // User struct added to the context. Note that we use our userContextKey constant as the // key. func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request { ctx := context.WithValue(r.Context(), userContextKey, user) return r.WithContext(ctx) } // The contextGetUser() retrieves the User struct from the request context. The only // time that we'll use this helper is when we logically expect there to be User struct // value in the context, and if it doesn't exist it will firmly be a programmer error. // As we discussed earlier in the book, it's OK to panic in those circumstances. func (app *application) contextGetUser(r *http.Request) *data.User { user, ok := r.Context().Value(userContextKey).(*data.User) if !ok { panic("missing user value in request context") } return user }
Creating the authentication middleware
Now that we’ve got those things in place, we’re ready to start work on our authenticate() middleware itself.
Open up your cmd/api/middleware.go file, and add the following code:
package main import ( "errors" // New import "fmt" "net" "net/http" "strings" // New import "sync" "time" "greenlight.alexedwards.net/internal/data" // New import "greenlight.alexedwards.net/internal/validator" // New import "golang.org/x/time/rate" ) ... func (app *application) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add the "Vary: Authorization" header to the response. This indicates to any // caches that the response may vary based on the value of the Authorization // header in the request. w.Header().Add("Vary", "Authorization") // Retrieve the value of the Authorization header from the request. This will // return the empty string "" if there is no such header found. authorizationHeader := r.Header.Get("Authorization") // If there is no Authorization header found, use the contextSetUser() helper // that we just made to add the AnonymousUser to the request context. Then we // call the next handler in the chain and return without executing any of the // code below. if authorizationHeader == "" { r = app.contextSetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } // Otherwise, we expect the value of the Authorization header to be in the format // "Bearer <token>". We try to split this into its constituent parts, and if the // header isn't in the expected format we return a 401 Unauthorized response // using the invalidAuthenticationTokenResponse() helper (which we will create // in a moment). headerParts := strings.Split(authorizationHeader, " ") if len(headerParts) != 2 || headerParts[0] != "Bearer" { app.invalidAuthenticationTokenResponse(w, r) return } // Extract the actual authentication token from the header parts. token := headerParts[1] // Validate the token to make sure it is in a sensible format. v := validator.New() // If the token isn't valid, use the invalidAuthenticationTokenResponse() // helper to send a response, rather than the failedValidationResponse() helper // that we'd normally use. if data.ValidateTokenPlaintext(v, token); !v.Valid() { app.invalidAuthenticationTokenResponse(w, r) return } // Retrieve the details of the user associated with the authentication token, // again calling the invalidAuthenticationTokenResponse() helper if no // matching record was found. IMPORTANT: Notice that we are using // ScopeAuthentication as the first parameter here. user, err := app.models.Users.GetForToken(data.ScopeAuthentication, token) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.invalidAuthenticationTokenResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } // Call the contextSetUser() helper to add the user information to the request // context. r = app.contextSetUser(r, user) // Call the next handler in the chain. next.ServeHTTP(w, r) }) }
There’s quite a lot of code there, so to help clarify things, let’s quickly reiterate the actions that this middleware is taking:
- If a valid authentication token is provided in the
Authorizationheader, then aUserstruct containing the corresponding user details will be stored in the request context. - If no
Authorizationheader is provided at all, ourAnonymousUserstruct will be stored in the request context. - If the
Authorizationheader is provided, but it’s malformed or contains an invalid value, the client will be sent a401 Unauthorizedresponse using theinvalidAuthenticationTokenResponse()helper.
Talking of which, let’s head to our cmd/api/errors.go file and create that helper as follows:
package main ... func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { w.Header().Set("WWW-Authenticate", "Bearer") message := "invalid or missing authentication token" app.errorResponse(w, r, http.StatusUnauthorized, message) }
Finally, we need to add the authenticate() middleware to our handler chain. We want to use this middleware on all requests — after our panic recovery and rate limiter middleware, but before our router.
Go ahead and update the cmd/api/routes.go file accordingly:
package main ... func (app *application) routes() http.Handler { router := httprouter.New() router.NotFound = http.HandlerFunc(app.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler) router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) // Use the authenticate() middleware on all requests. return app.recoverPanic(app.rateLimit(app.authenticate(router))) }
Demonstration
Let’s test this out by first making a request with no Authorization header. Behind the scenes, our authenticate() middleware will add the AnonymousUser to the request context and the request should complete successfully. Like so:
$ curl localhost:4000/v1/healthcheck
{
"status": "available",
"system_info": {
"environment": "development",
"version": "1.0.0"
}
}
Then let’s try the same thing, but with a valid authentication token in the Authorization header. This time, the relevant user details should be added to the request context, and we should again get a successful response. For example:
$ curl -d '{"email": "alice@example.com", "password": "pa55word"}' localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "FXCZM44TVLC6ML2NXTOW5OHFUE",
"expiry": "2021-04-17T12:20:30.02833444+02:00"
}
}
$ curl -H "Authorization: Bearer FXCZM44TVLC6ML2NXTOW5OHFUE" localhost:4000/v1/healthcheck
{
"status": "available",
"system_info": {
"environment": "development",
"version": "1.0.0"
}
}
In contrast, you can also try sending some requests with an invalid authentication token or a malformed Authorization header. In these cases you should get a 401 Unauthorized response, like so:
$ curl -i -H "Authorization: Bearer XXXXXXXXXXXXXXXXXXXXXXXXXX" localhost:4000/v1/healthcheck
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Vary: Authorization
Www-Authenticate: Bearer
Date: Fri, 16 Apr 2021 10:23:06 GMT
Content-Length: 56
{
"error": "invalid or missing authentication token"
}
$ curl -i -H "Authorization: INVALID" localhost:4000/v1/healthcheck
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Vary: Authorization
Www-Authenticate: Bearer
Date: Fri, 16 Apr 2021 10:23:26 GMT
Content-Length: 56
{
"error": "invalid or missing authentication token"
}