Registering a user
Now that we’ve laid the groundwork, let’s start putting it to use by creating a new API endpoint to manage the process of registering (or signing up) a new user.
| 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 |
When a client calls this new POST /v1/users endpoint, we will expect them to provide the following details for the new user in a JSON request body. Similar to this:
{
"name": "Alice Smith",
"email": "alice@example.com",
"password": "pa55word"
}
When we receive this, the registerUserHandler should create a new User struct containing these details, validate it with the ValidateUser() helper, and then pass it to our UserModel.Insert() method to create a new database record.
In fact, we’ve already written most of the code we need for the registerUserHandler — it’s now just a matter of piecing it all together in the correct order.
If you’re following along, go ahead and create a new cmd/api/users.go file:
$ touch cmd/api/users.go
And then add the new registerUserHandler method containing the following code:
package main import ( "errors" "net/http" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/validator" ) func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { // Create an anonymous struct to hold the expected data from the request body. var input struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } // Parse the request body into the anonymous struct. err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } // Copy the data from the request body into a new User struct. Notice also that we // set the Activated field to false, which isn't strictly necessary because the // Activated field will have the zero value of false by default. But setting this // explicitly helps to make our intentions clear to anyone reading the code. user := &data.User{ Name: input.Name, Email: input.Email, Activated: false, } // Use the Password.Set() method to generate and store the hashed and plaintext // passwords. err = user.Password.Set(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } v := validator.New() // Validate the user struct and return the error messages to the client if any of // the checks fail. if data.ValidateUser(v, user); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Insert the user data into the database. err = app.models.Users.Insert(user) if err != nil { switch { // If we get a ErrDuplicateEmail error, use the v.AddError() method to manually // add a message to the validator instance, and then call our // failedValidationResponse() helper. case errors.Is(err, data.ErrDuplicateEmail): v.AddError("email", "a user with this email address already exists") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Write a JSON response containing the user data along with a 201 Created status // code. err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
Before we try this out, we also need to add the new POST /v1/users endpoint to our 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) // Add the route for the POST /v1/users endpoint. router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) return app.recoverPanic(app.rateLimit(router)) }
Once that’s done, make sure all the files are saved and fire up the API.
Then go ahead and make a request to the POST /v1/users endpoint to register a new user with the email address alice@example.com. You should get a 201 Created response displaying the details for the user, similar to this:
$ BODY='{"name": "Alice Smith", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 201 Created
Content-Type: application/json
Date: Mon, 15 Mar 2021 14:42:58 GMT
Content-Length: 152
{
"user": {
"id": 1,
"created_at": "2021-03-15T15:42:58+01:00",
"name": "Alice Smith",
"email": "alice@example.com",
"activated": false
}
}
That’s looking good. We can see from the status code that the user record has been successfully created, and in the JSON response we can see the system-generated information for the new user — including the user’s ID and activation status.
If you take a look at your PostgreSQL database, you should also see the new record in the users table. Similar to this:
$ 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 users; id | created_at | name | email | password_hash | activated | version ----+------------------------+-------------+-------------------+-------------------------------------+-----------+--------- 1 | 2021-04-11 14:29:45+02 | Alice Smith | alice@example.com | \x24326124313224526157784d67356d... | f | 1 (1 row)
OK, let’s try making another request to our API but with some invalid user details. This time our validation checks will kick in and the client should receive the relevant error messages. For example:
$ BODY='{"name": "", "email": "bob@invalid.", "password": "pass"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
"error": {
"email": "must be a valid email address",
"name": "must be provided",
"password": "must be at least 8 bytes long"
}
}
Lastly, try registering a second account for alice@example.com. This time you should get a validation error containing an “a user with this email address already exists” message, like so:
$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 30 Dec 2020 14:22:06 GMT
Content-Length: 78
{
"error": {
"email": "a user with this email address already exists"
}
}
If you want, you can also try sending some requests using alternative casings of alice@example.com — such as ALICE@example.com or Alice@Example.com. Because the email column in our database has the type citext, these alternative versions will be successfully identified as duplicates too.
Additional information
Email case-sensitivity
Let’s talk quickly about email addresses case-sensitivity in a bit more detail.
Thanks to the specifications in RFC 2821, the domain part of an email address (
username@domain) is case-insensitive. This means we can be confident that the real-life user behindalice@example.comis the same person asalice@EXAMPLE.COM.The username part of an email address may or may not be case-sensitive — it depends on the email provider. Almost every major email provider treats the username as case-insensitive, but it is not absolutely guaranteed. All we can say here is that the real-life user behind the address
alice@example.comis very probably (but not definitely) the same asALICE@example.com.
So, what does this mean for our application?
From a security point of view, we should always store the email address using the exact casing provided by the user during registration, and we should send them emails using that exact casing only. If we don’t, there is a risk that emails could be delivered to the wrong real-life user. It’s particularly important to be aware of this in any workflows that use email for authentication purposes, such as a password-reset workflow.
However, because alice@example.com and ALICE@example.com are very probably the same user, we should generally treat email addresses as case-insensitive for comparison purposes.
In our registration workflow, using a case-insensitive comparison prevents users from accidentally (or intentionally) registering multiple accounts by just using different casing. And from a user-experience point-of-view, in workflows like login, activation or password resets, it’s more forgiving for users if we don’t require them to submit their request with exactly the same email casing that they used when registering.
User enumeration
It’s important to be aware that our registration endpoint is vulnerable to user enumeration. For example, if an attacker wants to know whether alice@example.com has an account with us, all they need to do is send a request like this:
$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
"error": {
"email": "a user with this email address already exists"
}
}
And they have the answer right there. We’re explicitly telling the attacker that alice@example.com is already a user.
So, what are the risks of leaking this information?
The first and most obvious risk relates to user privacy. For services that are sensitive or confidential you probably don’t want to make it obvious who has an account. The second risk is that it makes it easier for an attacker to compromise a user’s account. Once they know a user’s email address, they can potentially:
- Target the user via social engineering or another type of tailored attack to get their password.
- Search for the email address in leaked password tables, and try those same passwords on our service.
Preventing enumeration attacks typically requires two things:
- Making sure that the response sent to the client is always exactly the same, irrespective of whether a user exists or not. Generally, this means changing your response wording to be ambiguous, and notifying the user of any problems in a side-channel (such as sending them an email to inform them that they already have an account).
- Making sure that the time taken to send the response is always the same, irrespective of whether a user exists or not. In Go, this generally means offloading work to a background goroutine.
These mitigations, unfortunately, tend to increase the complexity of your application and add friction and obscurity to your workflows. For all your regular users who are not attackers, they’re a negative from a UX point of view. You have to ask: is it worth the trade-off?
There are a few things to think about when answering this question. How important is user privacy in your application? How attractive (high-value) is a compromised account to an attacker? How important is it to reduce friction in your user workflows? The answers to those questions will vary from project-to-project, and will help form the basis for your decision.
It’s worth noting that many big-name services, including Twitter, GitHub and Amazon, don’t prevent user enumeration (at least not on their registration pages). I’m not suggesting that this makes it OK — just that those companies have decided that the additional friction for the user is worse than the privacy and security risks in their specific case.