Managing password resets
If the API you’re building is for a public audience, it’s likely that you’ll want to include functionality for a user to reset their password when they forget it.
To support this in your API, you could add the following two endpoints:
| Method | URL Pattern | Handler | Action |
|---|---|---|---|
| POST | /v1/tokens/password-reset | createPasswordResetTokenHandler | Generate a new password reset token |
| PUT | /v1/users/password | updateUserPasswordHandler | Update the password for a specific user |
And implement a workflow like this:
- A client sends a request to the
POST /v1/tokens/password-resetendpoint containing the email address of the user whose password they want to reset. - If a user with that email address exists in the
userstable, and the user has already confirmed their email address by activating, then generate a cryptographically-secure high-entropy random token. - Store a hash of this token in the
tokenstable, alongside the user ID and a short (30-60 minute) expiry time for the token. - Send the original (unhashed) token to the user in an email.
- If the owner of the email address didn’t request a password reset token, they can ignore the email.
- Otherwise, they can submit the token to the
PUT /v1/users/passwordendpoint along with their new password. If the hash of the token exists in thetokenstable and hasn’t expired, then generate a bcrypt hash of the new password and update the user’s record. - Delete all existing password reset tokens for the user.
In our codebase, you could implement this workflow by creating a new password-reset token scope:
package data ... const ( ScopeActivation = "activation" ScopeAuthentication = "authentication" ScopePasswordReset = "password-reset" ) ...
And then adding the following two handlers:
package main ... // Generate a password reset token and send it to the user's email address. func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) { // Parse and validate the user's email address. var input struct { Email string `json:"email"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } v := validator.New() if data.ValidateEmail(v, input.Email); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Try to retrieve the corresponding user record for the email address. If it can't // be found, return an error message to the client. user, err := app.models.Users.GetByEmail(input.Email) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): v.AddError("email", "no matching email address found") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Return an error message if the user is not activated. if !user.Activated { v.AddError("email", "user account must be activated") app.failedValidationResponse(w, r, v.Errors) return } // Otherwise, create a new password reset token with a 45-minute expiry time. token, err := app.models.Tokens.New(user.ID, 45*time.Minute, data.ScopePasswordReset) if err != nil { app.serverErrorResponse(w, r, err) return } // Email the user with their password reset token. app.background(func() { data := map[string]any{ "passwordResetToken": token.Plaintext, } // Since email addresses MAY be case sensitive, notice that we are sending this // email using the address stored in our database for the user --- not to the // input.Email address provided by the client in this request. err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data) if err != nil { app.logger.Error(err.Error()) } }) // Send a 202 Accepted response and confirmation message to the client. env := envelope{"message": "an email will be sent to you containing password reset instructions"} err = app.writeJSON(w, http.StatusAccepted, env, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
package main ... // Verify the password reset token and set a new password for the user. func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) { // Parse and validate the user's new password and password reset token. var input struct { Password string `json:"password"` TokenPlaintext string `json:"token"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } v := validator.New() data.ValidatePasswordPlaintext(v, input.Password) data.ValidateTokenPlaintext(v, input.TokenPlaintext) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Retrieve the details of the user associated with the password reset token, // returning an error message if no matching record was found. user, err := app.models.Users.GetForToken(data.ScopePasswordReset, input.TokenPlaintext) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): v.AddError("token", "invalid or expired password reset token") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Set the new password for the user. err = user.Password.Set(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } // Save the updated user record in our database, checking for any edit conflicts as // normal. 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 was successful, then delete all password reset tokens for the user. err = app.models.Tokens.DeleteAllForUser(data.ScopePasswordReset, user.ID) if err != nil { app.serverErrorResponse(w, r, err) return } // Send the user a confirmation message. env := envelope{"message": "your password was successfully reset"} err = app.writeJSON(w, http.StatusOK, env, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
You should also create an internal/mailer/templates/token_password_reset.tmpl file containing the email templates for the password reset email, similar to this:
{{define "subject"}}Reset your Greenlight password{{end}}
{{define "plainBody"}}
Hi,
Please send a `PUT /v1/users/password` request with the following JSON body to set a new password:
{"password": "your new password", "token": "{{.passwordResetToken}}"}
Please note that this is a one-time use token and it will expire in 45 minutes. If you need
another token please make a `POST /v1/tokens/password-reset` request.
Thanks,
The Greenlight Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Please send a <code>PUT /v1/users/password</code> request with the following JSON body to set a new password:</p>
<pre><code>
{"password": "your new password", "token": "{{.passwordResetToken}}"}
</code></pre>
<p>Please note that this is a one-time use token and it will expire in 45 minutes.
If you need another token please make a <code>POST /v1/tokens/password-reset</code> request.</p>
<p>Thanks,</p>
<p>The Greenlight Team</p>
</body>
</html>
{{end}}
And add the necessary routes to the cmd/api/routes.go file:
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.requirePermission("movies:read", app.listMoviesHandler)) router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler)) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler)) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler)) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler)) router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) // Add the PUT /v1/users/password endpoint. router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) // Add the POST /v1/tokens/password-reset endpoint. router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) return app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router))))) }
Once those things are all in place, you should be able to get a new password reset token by making a request like this:
$ curl -X POST -d '{"email": "alice@example.com"}' localhost:4000/v1/tokens/password-reset
{
"message": "an email will be sent to you containing password reset instructions"
}
And then you can initiate the actual password change by sending a request containing the token received in the email. For example:
$ BODY='{"password": "your new password", "token": "Y7QCRZ7FWOWYLXLAOC2VYOLIPY"}'
$ curl -X PUT -d "$BODY" localhost:4000/v1/users/password
{
"message": "your password was successfully reset"
}
Additional information
Web application workflow
Just like the activation workflow we looked at earlier, if your API is the backend to a website (rather than a completely standalone service) you can tweak this password reset workflow to make it more intuitive for users.
For example, you could ask the user to enter the token into a form on your website along with their new password, and use some JavaScript to submit the form contents to your PUT /v1/users/password endpoint. The email to support that workflow could look something like this:
Hi, To reset your password please visit https://example.com/users/password and enter the following secret code along with your new password: -------------------------- Y7QCRZ7FWOWYLXLAOC2VYOLIPY -------------------------- Please note that this code will expire in 45 minutes. If you need another code please visit https://example.com/tokens/password-reset. Thanks, The Greenlight Team
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, To reset your password please click the following link: https://example.com/users/password?token=Y7QCRZ7FWOWYLXLAOC2VYOLIPY Please note that this link will expire in 45 minutes. If you need another password reset link please visit https://example.com/tokens/password-reset. Thanks, The Greenlight Team
This page can then display a form in which the user enters their new password, and some JavaScript on the webpage should then extract the token from the URL and submit it to the PUT /v1/users/password endpoint along with the new password. Again, if you go with this second option, you need to take steps to avoid the token being leaked in a referrer header.