Let's Go Further Authentication › Generating authentication tokens
Previous · Contents · Next
Chapter 15.2.

Generating authentication tokens

In this chapter we’re going to focus on building up the code for a new POST/v1/tokens/authentication endpoint, which will allow a client to exchange their credentials (email address and password) for a stateful authentication token.

At a high level, the process for exchanging a user’s credentials for an authentication token will work like this:

  1. The client sends a JSON request to a new POST/v1/tokens/authentication endpoint containing their credentials (email and password).
  2. We will look up the user record based on the email, and check if the password provided is the correct one for the user. If it’s not, then we send an error response.
  3. If the password is correct, we’ll use our app.models.Tokens.New() method to generate a token with an expiry time of 24 hours and the scope "authentication".
  4. We send this authentication token back to the client in a JSON response body.

Let’s begin in our internal/data/tokens.go file.

We need to update this file to define a new "authentication" scope, and add some struct tags to customize how the Token struct appears when it is encoded to JSON. Like so:

File: internal/data/tokens.go
package data

...

const (
    ScopeActivation     = "activation"
    ScopeAuthentication = "authentication" // Include a new authentication scope.
)

// Add struct tags to control how the struct appears when encoded to JSON.
type Token struct {
    Plaintext string    `json:"token"`
    Hash      []byte    `json:"-"`
    UserID    int64     `json:"-"`
    Expiry    time.Time `json:"expiry"`
    Scope     string    `json:"-"`
}

...

These new struct tags mean that only the Plaintext and Expiry fields will be included when encoding a Token struct — all the other fields will be omitted. We also rename the Plaintext field to "token", just because it’s a more meaningful name for clients than ‘plaintext’ is.

Altogether, this means that when we encode a Token struct to JSON the result will look similar to this:

{
    "token": "X3ASTT2CDAN66BACKSCI4SU7SI",
    "expiry": "2021-01-18T13:00:25.648511827+01:00"
}

Building the endpoint

Now let’s get into the meat of this chapter and set up all the code for the new POST/v1/tokens/authentication endpoint. By the time we’re finished, our API routes will look like this:

Method URL Pattern Handler Action
GET /v1/healthcheck healthcheckHandler Show application information
GET /v1/movies listMoviesHandler Show the details of all movies
POST /v1/movies createMovieHandler Create a new movie
GET /v1/movies/:id showMovieHandler Show the details of a specific movie
PATCH /v1/movies/:id updateMovieHandler Update the details of a specific movie
DELETE /v1/movies/:id deleteMovieHandler Delete a specific movie
POST /v1/users registerUserHandler Register a new user
PUT /v1/users/activated activateUserHandler Activate a specific user
POST /v1/tokens/authentication createAuthenticationTokenHandler Generate a new authentication token

If you’re following along, go ahead and create a new cmd/api/tokens.go file:

$ touch cmd/api/tokens.go

And in this new file we’ll add the code for the createAuthenticationTokenHandler.

Essentially, we want this handler to exchange the user’s email address and password for an authentication token, like so:

File: cmd/api/tokens.go
package main

import (
    "errors"
    "net/http"
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator"
)

func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
    // Parse the email and password from the request body.
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // Validate the email and password provided by the client.
    v := validator.New()

    data.ValidateEmail(v, input.Email)
    data.ValidatePasswordPlaintext(v, input.Password)

    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Lookup the user record based on the email address. If no matching user was
    // found, then we call the app.invalidCredentialsResponse() helper to send a 401
    // Unauthorized response to the client (we will create this helper in a moment).
    user, err := app.models.Users.GetByEmail(input.Email)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            app.invalidCredentialsResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Check if the provided password matches the actual password for the user.
    match, err := user.Password.Matches(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // If the passwords don't match, then we call the app.invalidCredentialsResponse()
    // helper again and return.
    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    // Otherwise, if the password is correct, we generate a new token with a 24-hour 
    // expiry time and the scope 'authentication'.
    token, err := app.models.Tokens.New(user.ID, 24*time.Hour, data.ScopeAuthentication)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Encode the token to JSON and send it in the response along with a 201 Created
    // status code.
    err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

Let’s quickly create the invalidCredentialsResponse() helper in our cmd/api/errors.go file too:

File: cmd/api/errors.go
package main

...

func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
    message := "invalid authentication credentials"
    app.errorResponse(w, r, http.StatusUnauthorized, message)
}

Then lastly, we need to include the POST /v1/tokens/authentication endpoint in our application routes.

File: cmd/api/routes.go
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)

    // Add the route for the POST /v1/tokens/authentication endpoint.
    router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)

    return app.recoverPanic(app.rateLimit(router))
}

With all that complete, we should now be able to generate an authentication token.

Go ahead and make a request to the new POST /v1/tokens/authentication endpoint with a valid email address and password for one of the users that you’ve previously created. You should get a 201 Created response and a JSON body containing an authentication token, similar to this:

$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/tokens/authentication
HTTP/1.1 201 Created
Content-Type: application/json
Date: Fri, 16 Apr 2021 09:03:36 GMT
Content-Length: 125

{
    "authentication_token": {
        "token": "IEYZQUBEMPPAKPOAWTPV6YJ6RM",
        "expiry": "2021-04-17T11:03:36.767078518+02:00"
    }
}

In contrast, if you try making a request with a well-formed but unknown email address, or an incorrect password, you should get an error response. For example:

$ BODY='{"email": "alice@example.com", "password": "wrong pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/tokens/authentication
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Date: Fri, 16 Apr 2021 09:54:01 GMT
Content-Length: 51

{
    "error": "invalid authentication credentials"
}

Before we continue, let’s quickly have a look at the tokens table in our PostgreSQL database to check that the authentication token has been created.

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight: 
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

greenlight=> SELECT * FROM tokens WHERE scope = 'authentication';
\x4390d2ff4af7346dd4238ffccb8a5b18e8c3af9aa8cf57852895ad0f8ee2c50d |       1 | 2021-04-17 11:03:37+02 | authentication

That’s looking good. We can see that the token is associated with the user with ID 1 (which if you’ve been following along will be the user alice@example.com) and has the correct scope and expiry time.


Additional information

The Authorization header

Occasionally you might come across other APIs or tutorials where authentication tokens are sent back to the client in an Authorization header, rather than in the response body like we are in this chapter.

You can do that, and in most cases it will probably work fine. But it’s important to be conscious that you’re making a willful violation of the HTTP specifications: Authorization is a request header, not a response header.