Resetting Passwords

In this post, I create a quick and dirty Go web server to demonstrate password reset functionality. My goal is to show you the basics of a password reset flow, so to keep things simple, I’ve taken a couple of shortcuts. Please read this for lots of best practice advice on this process.

No HTTPS - The HTTP server accepts passwords in plaintext.

No CAPTCHAs - If you want to prevent against bruteforce attempts to discover user accounts, you’ll want to implement this at the point the user submits their reset request.

No reset emails - Rather than write any code to support sending emails to users, I’m simulating this by dumping the reset URL out to the console (in the server). I’m NOT returning the reset URL to the user, as this would be a huge breach of privacy, as the ownership of the email is a big factor in securing the process.

No password reset page - To keep things simple, the reset functionality all happens in one request to the service. In the real world, your reset token URL will direct the user to a page where they can reset their token as a separate step.

The code is pragmatic - I’ve omitted request validation and given the HTTP handlers too much responsibility. The outcome is code that’s hard to test but easy to demonstrate.

All code can be found here.

Database setup

I’m going to use CockroachDB for this example. It’s super quick to setup and easy to talk to, download it from the website and run it with the following command:

$ cockroach start-single-node --listen-addr=localhost:26257 --insecure

$ cockroach sql --insecure

The database has been kept simple. It consists of 2 tables, one to store users and another to store their password reset tokens:

create table "user" (
    "id" uuid primary key default uuid_v4()::uuid,
    "email" string not null,
    "password" bytes not null,
    unique ("email")
);

create table "reset" (
    "id" uuid primary key default uuid_v4()::uuid,
    "user_id" uuid not null references "user" ("id"),
    "token" string not null,
    "expiry" timestamp not null
);
Server

server is a struct that holds the configuration variables required by the various endpoints and helpful functions of the application.

type server struct {
    // The database connection pool.
    db                    *sql.DB

    // The complexity of of the bcrypt hash.
    hashCost              int

    // The size of the reset token that will be sent to the user.
    // Bigger tokens offer better security.
    tokenSize             int

    // The time after which reset tokens will no longer be valid.
    tokenExpiry           time.Duration

    // The format of the reset URL that will be sent to the user.
    resetURLFormat        string

    // The amount of time between token table clear downs.
    sweepInterval         time.Duration

    // The amount of time we'll give the database between batch
    // deletions of reset tokens if there are more than sweepLimit
    // tokens to delete.
    sweepOverflowInterval time.Duration

    // The number of tokens to delete in any one go. Larger numbers
    // will delete more tokens and lock the reset token for longer.
    sweepLimit            int64
}
Helpers

I’ll cover the helper functions first, these will be used throughout the code, so when we dig into the guts of the HTTP handlers, you’ll have a better idea of what’s going on.

check takes a hashed password and a plaintext password and checks them for equality. If the password’s don’t match, false will be returned without an error, as this is an expected flow.

func check(hash, pw []byte) (bool, error) {
    if err := bcrypt.CompareHashAndPassword(hash, pw); err != nil {
        if err == bcrypt.ErrMismatchedHashAndPassword {
            return false, nil
        }
        return false, err
    }

    return true, nil
}

token takes a token size and returns a pseudorandom token string. This function will crash the application if random data can’t be generated for the token, or if not enough random data is generated

var (
    tokenRunes = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
)

func token(size int) (string, error) {
    bytes := make([]byte, size)
    if n, err := rand.Read(bytes); err != nil || n < size {
        log.Fatalf("cryptographic prng not available: %v", err)
    }

    output := make([]rune, size)
    for i := 0; i < size; i++ {
        output[i] = tokenRunes[bytes[i]%byte(len(tokenRunes))]
    }

    return string(output), nil
}

bindClose binds a given value to an HTTP request body, closing the request body on successul binding. This will return an error if the request couldn’t be unmarshalled or if the request body couldn’t be closed.

func bindClose(r *http.Request, val interface{}) error {
    if err := json.NewDecoder(r.Body).Decode(val); err != nil {
        return err
    }
    return r.Body.Close()
}

respond marshals a given object to JSON and sends it to the ResponseWriter, falling back to writing an error message if this fails. Nothing should write to ResponseWriter after a call to this function is made.

func respond(w http.ResponseWriter, code int, val interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)

	if err := json.NewEncoder(w).Encode(val); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		w.Header().Set("Content-Type", "text/plain")

		fmt.Fprintln(w, "error writing response")
	}
}

sweep clears down the reset token table. At every sweepInterval, it will begin a process of deleting sweepLimit items until none are left, continually sweeping for sweepLimit items at every sweepOverflowInterval, until the table is clear. I could have used Redis or similar, which provides TTLs for rows but to keep things simple, I’m doing everything in the database. Note that this method never exits, so call in it a goroutine.

func sweep(s *server) {
    const stmt = `delete from "reset" where "expiry" < $1 limit $2`

    for range time.Tick(s.sweepInterval) {
        for range time.NewTicker(s.sweepOverflowInterval).C {
            result, err := s.db.Exec(stmt, time.Now().UTC(), s.sweepLimit)
            if err != nil {
                log.Printf("error deleting tokens: %v", err)
            }

            affected, err := result.RowsAffected()
            if err != nil {
                log.Printf("error deleting tokens: %v", err)
            }

            if affected < s.sweepLimit {
                break
            }
        }
    }
}

simpleResponse is shared by a number of endpoints to return a simple text-based message to the user.

type simpleResponse struct {
    Message string `json:"message"`
}
Main function

Open a connection to the database and ping to test. Stopping the application if either the connect or the ping attempt fails.

db, err := sql.Open("postgres", "postgres://root@localhost:25267/defaultdb?sslmode=disable")
if err != nil {
    log.Fatalf("error opening database connection: %v", err)
}

if err = db.Ping(); err != nil {
    log.Fatalf("error testing database connection: %v", err)
}
defer db.Close()

Configure some sensible defaults for the server configuration object.

I’ll use a value of 14 for hashCost, as at the time of writing, this strikes a balance between computational complexity and performance, you’ll want to set your own depending on your needs.

I’ll use a value of 80 for tokenSize, as this is large enough that the liklihood of a correct guess is next to impossible.

I’ll use a value of 1,000 for sweepLimit, as this is is a sensible balance between locking the database by trying to delete too many rows and having to make too many requests because the number is too low.

s := &server{
    db:                    db,
    hashCost:              14,
    tokenSize:             80,
    tokenExpiry:           time.Hour,
    resetURLFormat:        "http://localhost:1234/reset/%s",
    sweepLimit:            1000,
    sweepInterval:         time.Minute * 30,
    sweepOverflowInterval: time.Second * 1,
}

Spin up an HTTP router and register the handlers we’ll need for this example. /register to create new users, /login to demonstrate that passwords are being changed, /forgot to request a new password, and /reset to configure a new password. Note that by using this router, it’s possible to configure the HTTP methods that are available for each, saving us from having to check in the handlers themsevles.

handler := mux.NewRouter()
handler.HandleFunc("/register", register(s)).Methods(http.MethodPost)
handler.HandleFunc("/login", login(s)).Methods(http.MethodPost)
handler.HandleFunc("/forgot", forgot(s)).Methods(http.MethodPost)
handler.HandleFunc("/reset/{token}", reset(s)).Methods(http.MethodPost)

Configure an HTTP server with some sensible timeouts. Depending on your server, you’ll want to tweak these values. It’s important that you keep some form of timeout in place because your server will be more vulnerable to DDoS attacks without. Read this post for more information on securing Go servers that are exposed to the Internet.

hs := &http.Server{
    Addr:              ":1234",
    Handler:           handler,
    ReadHeaderTimeout: time.Second * 2,
    ReadTimeout:       time.Second * 2,
    WriteTimeout:      time.Second * 3,
    IdleTimeout:       time.Second * 10,
}

Start the sweep worker. This is being done in a separate goroutine as it never exits.

go sweep(s)

Kick off the HTTP server and log any errors that get returned. Note that if ListenAndServe returns, that error will always be non-nil, so it’s fine to log.Fatal this.

log.Fatal(hs.ListenAndServe())
Register endpoint

The /register endpoint is a POST endpoint that accepts an email address and a plaintext password in a JSON body. It performs the following operations:

  1. Binds the email address and password to an in-memory object.
  2. Hashes the password for storage in the database. I’m using bcrypt, as it provides salt and is configurably complex, making it impervious to rainbox tables and bruteforcing.
  3. Insert a new user into the user table with the hashed password.
  4. Return the new user’s ID (or an error if a user already exists with the given email address).
func register(s *server) http.HandlerFunc {
    type request struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    type response struct {
        ID string `json:"id"`
    }

    return func(w http.ResponseWriter, r *http.Request) {
        var req request
        if err := bindClose(r, &req); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), s.hashCost)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        row := s.db.QueryRow(
            `insert into "user" ("email", "password") values ($1, $2) returning id`,
            req.Email,
            hash)

        var id string
        if err := row.Scan(&id); err != nil {
            if pqerr, ok := err.(*pq.Error); ok && pqerr.Code == "23505" {
                respond(w, http.StatusConflict, simpleResponse{Message: "email address already registered"})
                return
            }

            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        respond(w, http.StatusOK, response{ID: id})
    }
}
Login endpoint

The /login endpoint is a POST endpoint that accepts an email address and a plaintext password in a JSON body. It performs the following operations:

  1. Binds the email address and password to an in-memory object.
  2. Looks up the user’s stored password using their email address.
  3. Compares the password provided against the stored password.
  4. Responds with a message reminding the developer to implement JWTs.

In the event that the user enters an incorrect email or password, the same message will be returned to the user: “invalid email or password provided”. This prevents nefarious users from using the login endpoint to discover registered users.

func login(s *server) http.HandlerFunc {
    type request struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    return func(w http.ResponseWriter, r *http.Request) {
        var req request
        if err := bindClose(r, &req); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        row := s.db.QueryRow(
            `select "password" from "user" where "email" = $1`,
            req.Email)

        var password []byte
        if err := row.Scan(&password); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        ok, err := check(password, []byte(req.Password))
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }
        if !ok {
            respond(w, http.StatusUnauthorized, simpleResponse{Message: "invalid email or password"})
            return
        }

        respond(w, http.StatusOK, simpleResponse{Message: "implement JWTs"})
    }
}
Forgot endpoint

The /forgot endpoint is a POST endpoint that accepts an email address in a JSON body. It performs the following operations:

  1. Binds the email address to an in-memory object.
  2. Prepares a canned response to respond with, regardless of whether a reset token has been generated or not. This prevent nefarious users from using the forgot endpoint to discover registered users.
  3. Fetches the user’s ID using their email address. This will be stored alongside the password reset token and has the benefit of ensuring we only generate and store tokens for valid accounts.
  4. Generates and stores the password reset token, with a given expiry.
  5. Dumps the password reset token out to the console (as explained above).
func forgot(s *server) http.HandlerFunc {
    type request struct {
        Email string `json:"email"`
    }

    return func(w http.ResponseWriter, r *http.Request) {
        var req request
        if err := bindClose(r, &req); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        successMessage := fmt.Sprintf(
            "a reset url has been emailed to %s, it will expire in %s",
            req.Email,
            s.tokenExpiry)

        row := s.db.QueryRow(
            `select "id" from "user" where "email" = $1`,
            req.Email)

        var userID string
        if err := row.Scan(&userID); err != nil {
            respond(w, http.StatusOK, simpleResponse{Message: successMessage})
            return
        }

        token, err := token(s.tokenSize)
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        _, err = s.db.Exec(
            `insert into "reset" ("user_id", "token", "expiry") values ($1, $2, $3)`,
            userID,
            token,
            time.Now().UTC().Add(s.tokenExpiry))
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        log.Printf(s.resetURLFormat, token)

        respond(w, http.StatusOK, simpleResponse{Message: successMessage})
    }
}
Reset endpoint

The /reset endpoint is a POST endpoint that accepts a plaintext password in a JSON body and a password reset token in the URL path. It performs the following operations:

  1. Fetches the password reset token from the URL path.
  2. Binds the password to an in-memory object.
  3. Looks up the user’s ID and the ID of the password reset token.
  4. Hashes the new password and stores it against the user ID we previously fetched.
  5. Delete the password reset token using the token ID we previously fetched.
func reset(s *server) http.HandlerFunc {
    type request struct {
        Password string `json:"password"`
    }

    return func(w http.ResponseWriter, r *http.Request) {
        token, ok := mux.Vars(r)["token"]
        if !ok {
            http.Error(w, "missing token", http.StatusUnprocessableEntity)
            return
        }

        var req request
        if err := bindClose(r, &req); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }

        row := s.db.QueryRow(
            `select "id", "user_id" from "reset" where "token" = $1 and "expiry" < $2`,
            token,
            time.Now().UTC().Add(s.tokenExpiry))

        var tokenID string
        var userID string
        if err := row.Scan(&tokenID, &userID); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), s.hashCost)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        _, err = s.db.Exec(
            `update "user" set "password" = $1 where "id" = $2`,
            hash,
            userID)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        _, err = s.db.Exec(
            `delete from "reset" where "id" = $1`,
            tokenID)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        respond(w, http.StatusOK, simpleResponse{Message: "password reset"})
    }
}
Testing the service
/register

Register a new user with a call to the /register endpoint:

$ curl -X POST http://localhost:1234/register -d '{"email": "a@b.c", "password": "secret"}'
{"id":"a283a7f2-f3c9-4c04-b79b-dab7963c87ca"}

Check that the user has been created successfully with a call to the database:

SELECT * FROM "user";
id email password
a283a7f2-f3c9-4c04-b79b-dab7963c87ca a@b.c $2a$14$3m2RsVtQ/AJZeKFmcRazXeAi0HIr1/XfDQhT0MfjCxWAMYl916TdG


Attempt to create another user with the same email address with a call to the /register endpoint:

$ curl -X POST http://localhost:1234/register -d '{"email": "a@b.c", "password": "secret"}'
{"message":"email address already registered"}
/login

Login as the new user with a call to the /login endpoint:

$ curl -X POST http://localhost:1234/login -d '{"email": "a@b.c", "password": "secret"}'
{"message":"implement JWTs"}

Attempt to login with the wrong email with a call to the /login endpoint:

$ curl -X POST http://localhost:1234/login -d '{"email": "a@b.d", "password": "secret"}'
{"message":"invalid email or password"}

Attempt to login with the wrong password with a call to the /login endpoint:

$ curl -X POST http://localhost:1234/login -d '{"email": "a@b.c", "password": "incorrect"}'
{"message":"invalid email or password"}
/forgot

Ask the service for a password reset token with a call to the /forgot endpoint:

$ curl -X POST http://localhost:1234/forgot -d '{"email": "a@b.c"}'
{"message":"a reset url has been emailed to a@b.c, it will expire in 1h0m0s"}

Check that the reset token has been created successfully with a call to the database:

SELECT "user_id", "token", "expiry" FROM "reset";
user_id token expiry
a283a7f2-f3c9-4c04-b79b-dab7963c87ca r1XdUSGJGWQLaNyfrGNmeZWrSANdjN93UbQZt9fjom3gRVof8lebHYTa9NGajpsN0XlY2JzMMB3g4z8H 2019-12-16 17:26:25.563352+00:00


In the console of the running server, we also see that the reset token has been dumped:

2019/12/16 16:26:25 http://localhost:1234/reset/r1XdUSGJGWQLaNyfrGNmeZWrSANdjN93UbQZt9fjom3gRVof8lebHYTa9NGajpsN0XlY2JzMMB3g4z8H
/reset

Update the user’s password with a call to the /reset endpoint:

$ curl -X POST http://localhost:1234/reset/r1XdUSGJGWQLaNyfrGNmeZWrSANdjN93UbQZt9fjom3gRVof8lebHYTa9NGajpsN0XlY2JzMMB3g4z8H -d '{"password": "new-password"}'

Check that the user’s password has been updated successfully with a call to the database:

SELECT * FROM "user";
id email password
a283a7f2-f3c9-4c04-b79b-dab7963c87ca a@b.c $2a$14$UfaLGehi9NKbgPwYgCpu2eV2Tf7/ZDwb.ca4yCp6fwei1owSonqPq


Login with the new password with a call to the /login endpoint:

$ curl -X POST http://localhost:1234/login -d '{"email": "a@b.c", "password": "new-password"}'
{"message":"implement JWTs"}

Attempt to login with the original password with a call to the /login endpoint:

$ curl -X POST http://localhost:1234/login -d '{"email": "a@b.c", "password": "secret"}'
{"message":"invalid email or password"}