Let's Go Further Appendices › Managing password resets
Previous · Contents · Next
Chapter 21.1.

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:

  1. A client sends a request to the POST /v1/tokens/password-reset endpoint containing the email address of the user whose password they want to reset.
  2. If a user with that email address exists in the users table, and the user has already confirmed their email address by activating, then generate a cryptographically-secure high-entropy random token.
  3. Store a hash of this token in the tokens table, alongside the user ID and a short (30-60 minute) expiry time for the token.
  4. Send the original (unhashed) token to the user in an email.
  5. If the owner of the email address didn’t request a password reset token, they can ignore the email.
  6. Otherwise, they can submit the token to the PUT /v1/users/password endpoint along with their new password. If the hash of the token exists in the tokens table and hasn’t expired, then generate a bcrypt hash of the new password and update the user’s record.
  7. 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:

File: internal/data/tokens.go
package data

...

const (
    ScopeActivation     = "activation"
    ScopeAuthentication = "authentication"
    ScopePasswordReset  = "password-reset"
)

...

And then adding the following two handlers:

File: cmd/api/tokens.go
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)
    }
}
File: cmd/api/users.go
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:

File: internal/mailer/templates/token_password_reset.tmpl
{{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:

File: cmd/api/routes.go
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.