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.
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:
Insert()to insert a new token record in the database.New()will be a shortcut method which creates a new token using thegenerateToken()function and then callsInsert()to store the data.DeleteAllForUser()to delete all tokens with a specific scope for a specific user.
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:
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:
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