Let's Go Further User activation › Creating secure activation tokens
Previous · Contents · Next
Chapter 14.2.

Creating secure activation tokens

The integrity of our activation process hinges on one key thing: the ‘unguessability’ of the token that we send to the user’s email address. If the token is easy to guess or can be brute-forced, then it would be possible for an attacker to activate a user’s account even if they don’t have access to the user’s email inbox.

Because of this, we want the token to be generated by a cryptographically secure random number generator (CSPRNG) and have enough entropy (or randomness) that it is impossible to guess. Since Go 1.24, an easy way to create a token that matches these criteria is by using the rand.Text() function from the crypto/rand package.

This will generate tokens that contain 128 bits (16 bytes) of entropy, encoded using the standard base32 alphabet. In practice, this means that rand.Text() returns strings that are 26 characters long and look like this:

CN5MWVETIILGP32FBV3EOGBNRV
LYDISI72PTLGTIVEDSV5IATEAR
EADOYJU5WJC3CCR3KZPSJW5BJA

If you’re following along, go ahead and create a new internal/data/tokens.go file. This will act as the home for all our logic related to creating and managing tokens over the next couple of chapters.

$ touch internal/data/tokens.go

Then in this file let’s define a Token struct (to represent the data for an individual token), and a generateToken() function that we can use to create a new token.

This is another time where it’s probably easiest to jump straight into the code, and describe what’s happening as we go along.

File: internal/data/tokens.go
package data

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base32"
    "time"
)

// Define constants for the token scope. For now we just define the scope "activation"
// but we'll add additional scopes later in the book.
const (
    ScopeActivation = "activation"
)

// Define a Token struct to hold the data for an individual token. This includes the 
// plaintext and hashed versions of the token, associated user ID, expiry time and 
// scope.
type Token struct {
    Plaintext string
    Hash      []byte
    UserID    int64
    Expiry    time.Time
    Scope     string
}

func generateToken(userID int64, ttl time.Duration, scope string) *Token {
    // Create a Token instance. In this, we set the Plaintext field to be a random 
    // token generated by rand.Text(), and also set values for the user ID, expiry, and 
    // scope of the token. Notice that we add the provided ttl (time-to-live) duration 
    // argument to the current time to get the expiry time.
    token := &Token{
        Plaintext: rand.Text(),
        UserID:    userID,
        Expiry:    time.Now().Add(ttl),
        Scope:     scope,
    }

    // Generate a SHA-256 hash of the plaintext token string. This will be the value 
    // that we store in the `hash` column of our database table. Note that the 
    // sha256.Sum256() function returns an *array* of length 32, so to make it easier to  
    // work with we convert it to a slice using the [:] operator before storing it.
    hash := sha256.Sum256([]byte(token.Plaintext))
    token.Hash = hash[:]

    return token
}

Creating the TokenModel and validation checks

OK, let’s move on and set up a TokenModel type which encapsulates the database interactions with our PostgreSQL tokens table. We’ll follow a very similar pattern to the MovieModel and UsersModel again, and we’ll implement the following three methods on it:

We’ll also create a new ValidateTokenPlaintext() function, which will check that a plaintext token provided by a client in the future is exactly 26 bytes long.

Open up the internal/data/tokens.go file again, and add the following code:

File: internal/data/tokens.go
package data

import (
    "context" // New import
    "crypto/rand"
    "crypto/sha256"
    "database/sql" // New import
    "encoding/base32"
    "time"

    "greenlight.alexedwards.net/internal/validator" // New import
)

...

// Check that the plaintext token has been provided and is exactly 26 bytes long.
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
    v.Check(tokenPlaintext != "", "token", "must be provided")
    v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
}

// Define the TokenModel type.
type TokenModel struct {
    DB *sql.DB
}

// The New() method is a shortcut which creates a new Token struct and then inserts the
// data in the tokens table.
func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) {
    token := generateToken(userID, ttl, scope)

    err := m.Insert(token)
    return token, err
}

// Insert() adds the data for a specific token to the tokens table.
func (m TokenModel) Insert(token *Token) error {
    query := `
        INSERT INTO tokens (hash, user_id, expiry, scope) 
        VALUES ($1, $2, $3, $4)`

    args := []any{token.Hash, token.UserID, token.Expiry, token.Scope}

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    _, err := m.DB.ExecContext(ctx, query, args...)
    return err
}

// DeleteAllForUser() deletes all tokens for a specific user and scope.
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
    query := `
        DELETE FROM tokens 
        WHERE scope = $1 AND user_id = $2`

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    _, err := m.DB.ExecContext(ctx, query, scope, userID)
    return err
}

And finally, we need to update the internal/data/models.go file so that the new TokenModel is included in our parent Models struct. Like so:

File: internal/data/models.go
package data

...

type Models struct {
    Movies MovieModel
    Tokens TokenModel // Add a new Tokens field.
    Users  UserModel
}

func NewModels(db *sql.DB) Models {
    return Models{
        Movies: MovieModel{DB: db},
        Tokens: TokenModel{DB: db}, // Initialize a new TokenModel instance.
        Users:  UserModel{DB: db},
    }
}

At this point you should be able to restart the application, and everything should work without a hitch.

$ go run ./cmd/api/
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development