Setting up the users model
Now that our database table is set up, we’re going to update our internal/data package to contain a new User struct (to represent the data for an individual user), and create a UserModel type (which we will use to perform various SQL queries against our users table).
If you’re following along, go ahead and create an internal/data/users.go file to hold this new code:
$ touch internal/data/users.go
Let’s start by defining the User struct, along with some helper methods for setting and verifying the password for a user.
As we mentioned earlier, in this project we will use bcrypt to hash a user’s password before storing it in the database. So the first thing we need to do is install the golang.org/x/crypto/bcrypt package, which provides an easy-to-use Go implementation of the bcrypt algorithm.
$ go get golang.org/x/crypto/bcrypt@latest go: downloading golang.org/x/crypto v0.41.0 go get: added golang.org/x/crypto v0.41.0
Then in the internal/data/users.go file, go ahead and create the User struct and helper methods like so:
package data import ( "errors" "time" "golang.org/x/crypto/bcrypt" ) // Define a User struct to represent an individual user. Importantly, notice how we are // using the json:"-" struct tag to prevent the Password and Version fields from appearing // in any output when we encode it to JSON. Also notice that the Password field uses the // custom password type defined below. type User struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at"` Name string `json:"name"` Email string `json:"email"` Password password `json:"-"` Activated bool `json:"activated"` Version int `json:"-"` } // Create a custom password type which is a struct containing the plaintext and hashed // versions of the password for a user. The plaintext field is a *pointer* to a string, // so that we're able to distinguish between a plaintext password not being present in // the struct at all, versus a plaintext password which is the empty string "". type password struct { plaintext *string hash []byte } // The Set() method calculates the bcrypt hash of a plaintext password, and stores both // the hash and the plaintext versions in the struct. func (p *password) Set(plaintextPassword string) error { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) if err != nil { return err } p.plaintext = &plaintextPassword p.hash = hash return nil } // The Matches() method checks whether the provided plaintext password matches the // hashed password stored in the struct, returning true if it matches and false // otherwise. func (p *password) Matches(plaintextPassword string) (bool, error) { err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) if err != nil { switch { case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): return false, nil default: return false, err } } return true, nil }
We explained how the golang.org/x/crypto/bcrypt package works previously in Let’s Go, but let’s quickly recap the key points:
The
bcrypt.GenerateFromPassword()function generates a bcrypt hash of a password using a specific cost parameter (in the code above, we use a cost of12). The higher the cost, the slower and more computationally expensive it is to generate the hash. There is a balance to be struck here — we want the cost to be prohibitively expensive for attackers, but also not so slow that it harms the user experience of our API. This function returns a hash string in the format:$2a$[cost]$[22-character salt][31-character hash]
The
bcrypt.CompareHashAndPassword()function works by re-hashing the provided password using the same salt and cost parameter that is in the hash string that we’re comparing against. The re-hashed value is then checked against the original hash string using thesubtle.ConstantTimeCompare()function, which performs a comparison in constant time (to mitigate the risk of a timing attack). If they don’t match, then it will return abcrypt.ErrMismatchedHashAndPassworderror.
Adding validation checks
Let’s move on and create some validation checks for our User struct. Specifically, we want to:
- Check that the
Namefield is not the empty string, and the value is less than 500 bytes long. - Check that the
Emailfield is not the empty string, and that it matches the regular expression for email addresses that we added in ourvalidatorpackage earlier in the book. - If the
Password.plaintextfield is notnil, then check that the value is not the empty string and is between 8 and 72 bytes long. - Check that the
Password.hashfield is nevernil.
Additionally, we’re going to want to use the email and plaintext password validation checks again independently later in the book, so we’ll define those checks in some standalone functions.
Go ahead and update the internal/data/users.go file like so:
package data import ( "errors" "time" "greenlight.alexedwards.net/internal/validator" // New import "golang.org/x/crypto/bcrypt" ) ... func ValidateEmail(v *validator.Validator, email string) { v.Check(email != "", "email", "must be provided") v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") } func ValidatePasswordPlaintext(v *validator.Validator, password string) { v.Check(password != "", "password", "must be provided") v.Check(len(password) >= 8, "password", "must be at least 8 bytes long") v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long") } func ValidateUser(v *validator.Validator, user *User) { v.Check(user.Name != "", "name", "must be provided") v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") // Call the standalone ValidateEmail() helper. ValidateEmail(v, user.Email) // If the plaintext password is not nil, call the standalone // ValidatePasswordPlaintext() helper. if user.Password.plaintext != nil { ValidatePasswordPlaintext(v, *user.Password.plaintext) } // If the password hash is ever nil, this will be due to a logic error in our // codebase (probably because we forgot to set a password for the user). It's a // useful sanity check to include here, but it's not a problem with the data // provided by the client. So rather than adding an error to the validation map we // panic instead. if user.Password.hash == nil { panic("missing password hash for user") } }
Creating the UserModel
The next step in this process is setting up a UserModel type which isolates the database interactions with our PostgreSQL users table.
We’ll follow the same pattern here that we used for our MovieModel, and implement the following three methods:
Insert()to create a new user record in the database.GetByEmail()to retrieve the data for a user with a specific email address.Update()to change the data for a specific user.
Open up the internal/data/users.go file again, and add the following code:
package data import ( "context" // New import "database/sql" // New import "errors" "time" "greenlight.alexedwards.net/internal/validator" "golang.org/x/crypto/bcrypt" ) // Define a custom ErrDuplicateEmail error. var ( ErrDuplicateEmail = errors.New("duplicate email") ) ... // Create a UserModel struct which wraps the connection pool. type UserModel struct { DB *sql.DB } // Insert a new record in the database for the user. Note that the id, created_at and // version fields are all automatically generated by our database, so we use the // RETURNING clause to read them into the User struct after the insert, in the same way // that we did when creating a movie. func (m UserModel) Insert(user *User) error { query := ` INSERT INTO users (name, email, password_hash, activated) VALUES ($1, $2, $3, $4) RETURNING id, created_at, version` args := []any{user.Name, user.Email, user.Password.hash, user.Activated} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // If the table already contains a record with this email address, then when we try // to perform the insert there will be a violation of the UNIQUE "users_email_key" // constraint that we set up in the previous chapter. We check for this error // specifically, and return a custom ErrDuplicateEmail error instead. err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) if err != nil { switch { case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: return ErrDuplicateEmail default: return err } } return nil } // Retrieve the User details from the database based on the user's email address. // Because we have a UNIQUE constraint on the email column, this SQL query will only // return one record (or none at all, in which case we return an ErrRecordNotFound error). func (m UserModel) GetByEmail(email string) (*User, error) { query := ` SELECT id, created_at, name, email, password_hash, activated, version FROM users WHERE email = $1` var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, email).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 &user, nil } // Update the details for a specific user. Notice that we check against the version // field to help prevent any race conditions during the request cycle, just like we did // when updating a movie. And we also check for a violation of the "users_email_key" // constraint when performing the update, just like we did when inserting the user // record originally. func (m UserModel) Update(user *User) error { query := ` UPDATE users SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1 WHERE id = $5 AND version = $6 RETURNING version` args := []any{ user.Name, user.Email, user.Password.hash, user.Activated, user.ID, user.Version, } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) if err != nil { switch { case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: return ErrDuplicateEmail case errors.Is(err, sql.ErrNoRows): return ErrEditConflict default: return err } } return nil }
Hopefully that feels nice and straightforward — we’re using the same code patterns that we did for the CRUD operations on our movies table earlier in the book.
The only difference is that in some of the methods we’re specifically checking for any errors due to a violation of our unique users_email_key constraint. As we’ll see in the next chapter, by treating this as a special case we’ll be able to respond to clients with a message to say that “this email address is already in use”, rather than sending them a 500 Internal Server Error response like we normally would.
To finish all this off, the final thing we need to do is update our internal/data/models.go file to include the new UserModel in our parent Models struct. Like so:
package data ... type Models struct { Movies MovieModel Users UserModel // Add a new Users field. } func NewModels(db *sql.DB) Models { return Models{ Movies: MovieModel{DB: db}, Users: UserModel{DB: db}, // Initialize a new UserModel instance. } }