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 base-32 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 
    // parameter 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` field 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