Activating a user
In this chapter we’re going to move on to the part of the activation workflow where we actually activate a user. But before we write any code, I’d like to quickly talk about the relationship between users and tokens in our system.
What we have is known in relational database terms as a one-to-many relationship — where one user may have many tokens, but a token can only belong to one user.
When you have a one-to-many relationship like this, you’ll potentially want to execute queries against the relationship from two different sides. In our case, for example, we might want to either:
- Retrieve the user associated with a token.
- Retrieve all tokens associated with a user.
To implement these queries in your code, a clean and clear approach is to update your database models to include some additional methods, like this:
UserModel.GetForToken(token) → Retrieve the user associated with a token TokenModel.GetAllForUser(user) → Retrieve all tokens associated with a user
The nice thing about this approach is that the returned entities align with each model’s main responsibility: the UserModel method is returning a user, and the TokenModel method is returning tokens.
Creating the activateUserHandler
Now that we’ve got a very high-level idea of how we’re going to query the user ↔ token relationship in our database models, let’s start to build up the code for activating a user.
In order to do this, we’ll need to add a new PUT /v1/users/activated endpoint to our API:
| 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 |
And the workflow will look like this:
- The user submits the plaintext activation token (which they just received in their email) to the
PUT /v1/users/activatedendpoint. - We validate the plaintext token to check that it matches the expected format, sending the client an error message if necessary.
- We then call the
UserModel.GetForToken()method to retrieve the details of the user associated with the provided token. If there is no matching token found, or it has expired, we send the client an error message. - We activate the associated user by setting
activated = trueon the user record and update it in our database. - We delete all activation tokens for the user from the
tokenstable. We can do this using theTokenModel.DeleteAllForUser()method that we made earlier. - We send the updated user details in a JSON response.
Let’s begin in our cmd/api/users.go file and create the new activateUserHandler to work through these steps:
package main ... func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) { // Parse the plaintext activation token from the request body. var input struct { TokenPlaintext string `json:"token"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } // Validate the plaintext token provided by the client. v := validator.New() if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Retrieve the details of the user associated with the token using the // GetForToken() method (which we will create in a minute). If no matching record // is found, then we let the client know that the token they provided is not valid. user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): v.AddError("token", "invalid or expired activation token") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Update the user's activation status. user.Activated = true // Save the updated user record in our database, checking for any edit conflicts in // the same way that we did for our movie records. err = app.models.Users.Update(user) if err != nil { switch { case errors.Is(err, data.ErrEditConflict): app.editConflictResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } // If everything went successfully, then we delete all activation tokens for the // user. err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID) if err != nil { app.serverErrorResponse(w, r, err) return } // Send the updated user details to the client in a JSON response. err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
If you try to compile the application at this point, you’ll get an error because the UserModel.GetForToken() method doesn’t yet exist. Let’s go ahead and create that now.
The UserModel.GetForToken method
As we mentioned above, we want the UserModel.GetForToken() method to retrieve the details of the user associated with a particular activation token. If there is no matching token found, or it has expired, we want this to return a ErrRecordNotFound error instead.
In order to do that, we’ll need to execute the following SQL query on our database:
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version FROM users INNER JOIN tokens ON users.id = tokens.user_id WHERE tokens.hash = $1 AND tokens.scope = $2 AND tokens.expiry > $3
This is more complicated than most of the SQL queries we’ve used so far, so let’s take a moment to explain what it is doing.
In this query we are using INNER JOIN to join together information from the users and tokens tables. Specifically, we’re using the ON users.id = tokens.user_id clause to specify that we want to join records where the user id value equals the token user_id.
Behind the scenes, you can think of INNER JOIN as creating an ‘interim’ table containing the joined data from both tables. Then, in our SQL query, we use the WHERE clause to filter this interim table to leave only rows where the token hash and token scope match specific placeholder parameter values, and the token expiry is after a specific time. Because the token hash is also a primary key, we will always be left with exactly one record which contains the details of the user associated with the token hash (or no records at all, if there wasn’t a matching token).
If you’re following along, open up your internal/data/users.go file and add a GetForToken() method which executes this SQL query like so:
package data import ( "context" "crypto/sha256" // New import "database/sql" "errors" "time" "greenlight.alexedwards.net/internal/validator" "golang.org/x/crypto/bcrypt" ) ... func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) { // Calculate the SHA-256 hash of the plaintext token provided by the client. // Remember that this returns a byte *array* with length 32, not a slice. tokenHash := sha256.Sum256([]byte(tokenPlaintext)) // Set up the SQL query. query := ` SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version FROM users INNER JOIN tokens ON users.id = tokens.user_id WHERE tokens.hash = $1 AND tokens.scope = $2 AND tokens.expiry > $3` // Create a slice containing the query arguments. Notice how we use the [:] operator // to get a slice containing the token hash, rather than passing in the array (which // is not supported by the pq driver), and that we pass the current time as the // value to check against the token expiry. args := []any{tokenHash[:], tokenScope, time.Now()} var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // Execute the query, scanning the return values into a User struct. If no matching // record is found we return an ErrRecordNotFound error. err := m.DB.QueryRowContext(ctx, query, args...).Scan( &user.ID, &user.CreatedAt, &user.Name, &user.Email, &user.Password.hash, &user.Activated, &user.Version, ) if err != nil { switch { case errors.Is(err, sql.ErrNoRows): return nil, ErrRecordNotFound default: return nil, err } } // Return the matching user. return &user, nil }
Now that this is in place, the final thing we need to do is add the PUT /v1/users/activated endpoint to the cmd/api/routes.go file. Like so:
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) // Add the route for the PUT /v1/users/activated endpoint. router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) return app.recoverPanic(app.rateLimit(router)) }
As an aside, I should quickly explain that the reason we’re using PUT rather than POST for this endpoint is because it’s idempotent.
If a client sends the same PUT /v1/users/activated request multiple times, the first will succeed (assuming the token is valid) and then any subsequent requests will result in an error being sent to the client (because the token has been used and deleted from the database). But the important thing is that nothing in our application state (i.e. database) changes after that first request.
Basically, there are no application state side-effects from the client sending the same request multiple times, which means that the endpoint is idempotent and using PUT is more appropriate than POST.
Alright, let’s restart the API and then try this out.
First, try making some requests to the PUT /v1/users/activated endpoint containing some invalid tokens. You should get the appropriate error messages in response, like so:
$ curl -X PUT -d '{"token": "invalid"}' localhost:4000/v1/users/activated
{
"error": {
"token": "must be 26 bytes long"
}
}
$ curl -X PUT -d '{"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}' localhost:4000/v1/users/activated
{
"error": {
"token": "invalid or expired activation token"
}
}
Then try making a request using a valid activation token from one of your emails (which will be in your Mailtrap inbox if you’re following along). In my case, I’ll use the token P4B3URJZJ2NW5UPZC2OHN4H2NM to activate the user faith@example.com (who we created in the previous chapter).
You should get a JSON response back with an activated field that confirms that the user has been activated, similar to this:
$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
"user": {
"id": 7,
"created_at": "2021-04-15T20:25:41+02:00",
"name": "Faith Smith",
"email": "faith@example.com",
"activated": true
}
}
And if you try repeating the request again with the same token, you should now get an "invalid or expired activation token" error due to the fact we have deleted all activation tokens for faith@example.com.
$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
"error": {
"token": "invalid or expired activation token"
}
}
Lastly, let’s take a quick look in our database to see the state of our users table.
$ 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 email, activated, version FROM users;
email | activated | version
-------------------+-----------+---------
alice@example.com | f | 1
bob@example.com | f | 1
carol@example.com | f | 1
dave@example.com | f | 1
edith@example.com | f | 1
faith@example.com | t | 2
In contrast to all the other users, we can see that faith@example.com has now got the value activated = true and the version number for their user record has been bumped up to 2.
Additional information
Web application workflow
If your API is the backend to a website, rather than a completely standalone service, you can tweak the activation workflow to make it simpler and more intuitive for users while still being secure.
There are two main options here. The first, and most robust, option is to ask the user to copy-and-paste the token into a form on your website which then performs the PUT /v1/users/activate request for them using some JavaScript. The welcome email to support that workflow could look something like this:
Hi, Thanks for signing up for a Greenlight account. We're excited to have you on board! For future reference, your user ID number is 123. To activate your Greenlight account please visit https://example.com/users/activate and enter the following code: -------------------------- RMMCV3MZCEBYQADXBODCLTAF6L -------------------------- Please note that this code will expire in 3 days and can only be used once. Thanks, The Greenlight Team
This approach is fundamentally simple and secure — effectively your website just provides a form that performs the PUT request for the user, rather than them needing to do it manually using curl or another tool.
Alternatively, if you don’t want the user to copy-and-paste a token, you could ask them to click a link containing the token which takes them to a page on your website. Similar to this:
Hi, Thanks for signing up for a Greenlight account. We're excited to have you on board! For future reference, your user ID number is 123. To activate your Greenlight account please click the following link: https://example.com/users/activate?token=RMMCV3MZCEBYQADXBODCLTAF6L Please note that this link will expire in 3 days and can only be used once. Thanks, The Greenlight Team
This page should then display a button that says something like ‘Confirm your account activation’, and some JavaScript on the webpage can extract the token from the URL and submit it to your PUT /v1/users/activate API endpoint when the user clicks the button.
If you go with this second option, you also need to take steps to avoid the token being leaked in a referrer header if the user navigates to a different site. You can use the Referrer-Policy: Origin header or <meta name="referrer" content="origin"> HTML tag to mitigate this, although you should be aware that it’s not supported by absolutely all web browsers (support is currently at 97%).
In all cases though, whatever the email and workflow looks like in terms of the front-end and user-experience, the back-end API endpoint that we’ve implemented is the same and doesn’t need to change.
SQL query timing attack
It’s worth pointing out that the SQL query we’re using in UserModel.GetForToken() is theoretically vulnerable to a timing attack, because PostgreSQL’s evaluation of the tokens.hash = $1 condition is not performed in constant-time.
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version FROM users INNER JOIN tokens ON users.id = tokens.user_id WHERE tokens.hash = $1 --<-- This is vulnerable to a timing attack AND tokens.scope = $2 AND tokens.expiry > $3
Although it would be somewhat tricky to pull off, in theory an attacker could issue thousands of requests to our PUT /v1/users/activated endpoint and analyze tiny discrepancies in the average response time to build up a picture of a hashed activation token value in the database.
But, in our case, even if a timing attack was successful it would only leak the hashed token value from the database — not the plaintext token value that the user actually needs to submit to activate their account.
So the attacker would still need to use brute-force to find a 26-character string which happens to have the same SHA-256 hash that they discovered from the timing attack. This is incredibly difficult to do, and not viable with current technology.