Introduction
With the basic setup laid bare, it's time to build a truly useful API service for our authentication system. In this article, we will delve into user registration, storage in the database, password hashing using argon2id, sending templated emails, and generating truly random and secure tokens, among others. Let's get on!
Source code
The source code for this series is hosted on GitHub via:
go-auth
This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.
It is currently live here (the backend may be brought down soon).
To run locally, kindly follow the instructions in each subdirectory.
Implementation
Step 1: Create the user's database schema
We need a database table to store our application's users' data. To generate and migrate a schema, we'll use golang migrate. Kindly follow these instructions to install it on your Operating system. To create a pair of migration files (up and down) for our user table, issue the following command in your terminal and at the root of your project:
~/Documents/Projects/web/go-auth/go-auth-backend$ migrate create -seq -ext=.sql -dir=./migrations create_users_table
-seq
instructs the CLI to use sequential numbering as against the default, which is the Unix timestamp. We opted to use .sql
file extensions for the generated files by passing -ext
. The generated files will live in the migrations
folder we created in the previous article and -dir
allows us to specify that. Lastly, we fed it with the real name of the files we want to create. You should see two files in the migrations
folder by name. Kindly open the up
and fill in the following schema:
-- migrations/000001_create_users_table.up.sql
-- Add up migration script here
-- User table
CREATE TABLE IF NOT EXISTS users(
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
is_active BOOLEAN DEFAULT FALSE,
is_staff BOOLEAN DEFAULT FALSE,
is_superuser BOOLEAN DEFAULT FALSE,
thumbnail TEXT NULL,
date_joined TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS users_id_email_is_active_indx ON users (id, email, is_active);
-- Create a domain for phone data type
CREATE DOMAIN phone AS TEXT CHECK(
octet_length(VALUE) BETWEEN 1
/*+*/
+ 8 AND 1
/*+*/
+ 15 + 3
AND VALUE ~ '^\+\d+$'
);
-- User details table (One-to-one relationship)
CREATE TABLE user_profile (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE,
phone_number phone NULL,
birth_date DATE NULL,
github_link TEXT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS users_detail_id_user_id ON user_profile (id, user_id);
In the down
file, we should have:
-- migrations/000001_create_users_table.down.sql
-- Add down migration script here
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS user_profile;
We have been using these schemas right from when we started the authentication series.
Next, we need to execute the files so that those tables will be really created in our database:
migrate -path=./migrations -database=<DATABASE_URL> up
Ensure you replace <DATABASE_URL>
with your real database URL. If everything goes well, your table should now be created in your database.
It should be noted that instead of manually migrating the database, we could do that automatically, at start-up, in the main()
function.
Step 2: Setting up our user model
To abstract away interacting with the database, we will create some sort of model
, an equivalent of Django's model. But before then, let's create a type for our users in internal/data/user_types.go
(create the file as it doesn't exist yet):
// internal/data/user_types.go
package data
import (
"database/sql"
"errors"
"time"
"github.com/google/uuid"
"goauthbackend.johnowolabiidogun.dev/internal/types"
)
type UserProfile struct {
ID *uuid.UUID `json:"id"`
UserID *uuid.UUID `json:"user_id"`
PhoneNumber *string `json:"phone_number"`
BirthDate types.NullTime `json:"birth_date"`
GithubLink *string `json:"github_link"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Password password `json:"-"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsActive bool `json:"is_active"`
IsStaff bool `json:"is_staff"`
IsSuperuser bool `json:"is_superuser"`
Thumbnail *string `json:"thumbnail"`
DateJoined time.Time `json:"date_joined"`
Profile UserProfile `json:"profile"`
}
type password struct {
plaintext *string
hash string
}
type UserModel struct {
DB *sql.DB
}
type UserID struct {
Id uuid.UUID
}
var (
ErrDuplicateEmail = errors.New("duplicate email")
)
These are just the basic types we'll be working on within this system. You will notice that there are three columns: names of the fields, field types, and the "renames" of the fields in JSON. The last column is very useful because, in Go, field names MUST start with capital letters for them to be accessible outside their package. The same goes to type names. Therefore, we need a way to properly send field names to requesting users and Go helps with that using the built-in encoding/json
package. Notice also that our Password
field was renamed to -
. This omits that field entirely from the JSON responses it generates. How cool is that! We also defined a custom password
type. This makes it easier to generate the hash of our users' passwords.
Then, there is this not-so-familiar types.NullTime
in the UserProfile
type. It was defined in internal/types/time.go
:
// internal/types/time.go
package types
import (
"fmt"
"reflect"
"strings"
"time"
"github.com/lib/pq"
)
// NullTime is an alias for pq.NullTime data type
type NullTime pq.NullTime
// Scan implements the Scanner interface for NullTime
func (nt *NullTime) Scan(value interface{}) error {
var t pq.NullTime
if err := t.Scan(value); err != nil {
return err
}
// if nil then make Valid false
if reflect.TypeOf(value) == nil {
*nt = NullTime{t.Time, false}
} else {
*nt = NullTime{t.Time, true}
}
return nil
}
// MarshalJSON for NullTime
func (nt *NullTime) MarshalJSON() ([]byte, error) {
if !nt.Valid {
return []byte("null"), nil
}
val := fmt.Sprintf("\"%s\"", nt.Time.Format(time.RFC3339))
return []byte(val), nil
}
const dateFormat = "2006-01-02"
// UnmarshalJSON for NullTime
func (nt *NullTime) UnmarshalJSON(b []byte) error {
t, err := time.Parse(dateFormat, strings.Replace(
string(b),
"\"",
"",
-1,
))
if err != nil {
return err
}
nt.Time = t
nt.Valid = true
return nil
}
The reason for this is the difficulty encountered while working with possible null values for users' birthdates. This article explains it quite well and the code above was some modification of the code there.
It should be noted that to use UUID in Go, you need an external package (we used github.com/google/uuid
in our case, so install it with go get github.com/google/uuid
).
Next is handling password hashing:
// internal/data/user_password.go
package data
import (
"log"
"github.com/alexedwards/argon2id"
)
func (p *password) Set(plaintextPassword string) error {
hash, err := argon2id.CreateHash(plaintextPassword, argon2id.DefaultParams)
if err != nil {
return err
}
p.plaintext = &plaintextPassword
p.hash = hash
return nil
}
func (p *password) Matches(plaintextPassword string) (bool, error) {
match, err := argon2id.ComparePasswordAndHash(plaintextPassword, p.hash)
if err != nil {
log.Fatal(err)
}
return match, nil
}
We used github.com/alexedwards/argon2id
package to assist in hashing and matching our users' passwords. It's Go's implementation of argon2id. The Set
"method" does the hashing when a user registers whereas Matches
confirms it when such a user wants to log in.
To validate users' inputs, a very good thing to do, we have:
// internal/data/user_validation.go
package data
import "goauthbackend.johnowolabiidogun.dev/internal/validator"
func ValidateEmail(v *validator.Validator, email string) {
v.Check(email != "", "email", "email must be provided")
v.Check(validator.Matches(email, validator.EmailRX), "email", "email must be a valid email address")
}
func ValidatePasswordPlaintext(v *validator.Validator, password string) {
v.Check(password != "", "password", "password must be provided")
v.Check(len(password) >= 8, "password", "password must be at least 8 bytes long")
v.Check(len(password) <= 72, "password", "password must not be more than 72 bytes long")
}
func ValidateUser(v *validator.Validator, user *User) {
v.Check(user.FirstName != "", "first_name", "first name must be provided")
v.Check(user.LastName != "", "last_name", "last name must be provided")
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)
}
}
The code uses another custom package to validate email
, password
, first name
, and last name
— the data required during registration. The custom package looks like this:
// internal/validator/validator.go
package validator
import "regexp"
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
type Validator struct {
Errors map[string]string
}
// New is a helper which creates a new Validator instance with an empty errors map.
func New() *Validator {
return &Validator{Errors: make(map[string]string)}
}
// Valid returns true if the errors map doesn't contain any entries.
func (v *Validator) Valid() bool {
return len(v.Errors) == 0
}
// AddError adds an error message to the map (so long as no entry already exists for // the given key).
func (v *Validator) AddError(key, message string) {
if _, exists := v.Errors[key]; !exists {
v.Errors[key] = message
}
}
// Check adds an error message to the map only if a validation check is not 'ok'.
func (v *Validator) Check(ok bool, key, message string) {
if !ok {
v.AddError(key, message)
}
}
// In returns true if a specific value is in a list of strings.
func In(value string, list ...string) bool {
for i := range list {
if value == list[i] {
return true
}
}
return false
}
// Matches returns true if a string value matches a specific regexp pattern.
func Matches(value string, rx *regexp.Regexp) bool {
return rx.MatchString(value)
}
// Unique returns true if all string values in a slice are unique.
func Unique(values []string) bool {
uniqueValues := make(map[string]bool)
for _, value := range values {
uniqueValues[value] = true
}
return len(values) == len(uniqueValues)
}
Pretty easy to reason along with.
It's finally time to create the model:
// internal/data/models.go
package data
import (
"database/sql"
"errors"
)
var (
ErrRecordNotFound = errors.New("a user with these details was not found")
)
type Models struct {
Users UserModel
}
func NewModels(db *sql.DB) Models {
return Models{
Users: UserModel{DB: db},
}
}
With this, if we have another model, all we need to do is register it in Models
and initialize it in NewModels
.
Now, we need to make this model accessible to our application. To do this, add models
to our application
type in main.go
and initialize it inside the main()
function:
// cmd/api/main.go
...
import (
...
"goauthbackend.johnowolabiidogun.dev/internal/data"
...
)
type application struct {
..
models data.Models
...
}
func main() {
...
app := &application{
...
models: data.NewModels(db),
...
}
...
}
...
That makes the models
available to all route handlers and functions that implement the application
type.
Step 3: User registration route handler
Let's put housekeeping to good use. Create a new file, register.go
, in cmd/api
and make it look like this:
// cmd/api/register.go
package main
import (
"errors"
"net/http"
"time"
"goauthbackend.johnowolabiidogun.dev/internal/data"
"goauthbackend.johnowolabiidogun.dev/internal/tokens"
"goauthbackend.johnowolabiidogun.dev/internal/validator"
)
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
// Expected data from the user
var input struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"password"`
}
// Try reading the user input to JSON
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
user := &data.User{
Email: input.Email,
FirstName: input.FirstName,
LastName: input.LastName,
}
// Hash user password
err = user.Password.Set(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
// Validate the user input
v := validator.New()
if data.ValidateUser(v, user); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
// Save the user in the database
userID, err := app.models.Users.Insert(user)
if err != nil {
switch {
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
}
// Generate 6-digit token
otp, err := tokens.GenerateOTP()
if err != nil {
app.logError(r, err)
}
err = app.storeInRedis("activation_", otp.Hash, userID.Id, app.config.tokenExpiration.duration)
if err != nil {
app.logError(r, err)
}
now := time.Now()
expiration := now.Add(app.config.tokenExpiration.duration)
exact := expiration.Format(time.RFC1123)
// Send email to user, using separate goroutine, for account activation
app.background(func() {
data := map[string]interface{}{
"token": tokens.FormatOTP(otp.Secret),
"userID": userID.Id,
"frontendURL": app.config.frontendURL,
"expiration": app.config.tokenExpiration.durationString,
"exact": exact,
}
err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
if err != nil {
app.logError(r, err)
}
app.logger.PrintInfo("Email successfully sent.", nil, app.config.debug)
})
// Respond with success
app.successResponse(
w,
r,
http.StatusAccepted,
"Your account creation was accepted successfully. Check your email address and follow the instruction to activate your account. Ensure you activate your account before the token expires",
)
}
Though a bit long, reading through the lines gives you the whole idea! We expect four (4) fields from the user. After converting them to proper JSON using readJSON
, a method created previously, we initialized the User
type, set hash the supplied password and then validate the user-supplied data. If everything is good, we used Insert
, a method on the User
type that lives in internal/data/user_queries.go
, to save the user in the database. The method is simple:
// internal/data/user_queries.go
package data
import (
"context"
"database/sql"
"errors"
"log"
"time"
"github.com/google/uuid"
)
func (um UserModel) Insert(user *User) (*UserID, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
tx, err := um.DB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
var userID uuid.UUID
query_user := `
INSERT INTO users (email, password, first_name, last_name) VALUES ($1, $2, $3, $4) RETURNING id`
args_user := []interface{}{user.Email, user.Password.hash, user.FirstName, user.LastName}
if err := tx.QueryRowContext(ctx, query_user, args_user...).Scan(&userID); err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return nil, ErrDuplicateEmail
default:
return nil, err
}
}
query_user_profile := `
INSERT INTO user_profile (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING RETURNING user_id`
_, err = tx.ExecContext(ctx, query_user_profile, userID)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
id := UserID{
Id: userID,
}
return &id, nil
}
We used Go's database transaction to execute our SQL queries. We also provided 3 seconds timeout for our database to finish up or get timed out! If the insertion query is successful, the user's ID is returned.
Next, we generated a token for the new user. The token is a random and cryptographically secure 6-digit number which then gets encoded using the sha252 algorithm. The entire logic is:
// internal/tokens/utils.go
package tokens
import (
"crypto/rand"
"crypto/sha256"
"fmt"
"math/big"
"strings"
"goauthbackend.johnowolabiidogun.dev/internal/validator"
)
type Token struct {
Secret string
Hash string
}
func GenerateOTP() (*Token, error) {
bigInt, err := rand.Int(rand.Reader, big.NewInt(900000))
if err != nil {
return nil, err
}
sixDigitNum := bigInt.Int64() + 100000
// Convert the integer to a string and get the first 6 characters
sixDigitStr := fmt.Sprintf("%06d", sixDigitNum)
token := Token{
Secret: sixDigitStr,
}
hash := sha256.Sum256([]byte(token.Secret))
token.Hash = fmt.Sprintf("%x\n", hash)
return &token, nil
}
func FormatOTP(s string) string {
length := len(s)
half := length / 2
firstHalf := s[:half]
secondHalf := s[half:]
words := []string{firstHalf, secondHalf}
return strings.Join(words, " ")
}
func ValidateSecret(v *validator.Validator, secret string) {
v.Check(secret != "", "token", "must be provided")
v.Check(len(secret) == 6, "token", "must be 6 bytes long")
}
After the token generation, we temporarily store the token hash in redis using the storeInRedis
method and then send an email, in the background using a different goroutine, to the user with instructions on how to activate their accounts. The functions used are located in cmd/api/helpers.go
:
// cmd/api/helpers.go
...
func (app *application) storeInRedis(prefix string, hash string, userID uuid.UUID, expiration time.Duration) error {
ctx := context.Background()
err := app.redisClient.Set(
ctx,
fmt.Sprintf("%s%s", prefix, userID),
hash,
expiration,
).Err()
if err != nil {
return err
}
return nil
}
func (app *application) background(fn func()) {
app.wg.Add(1)
go func() {
defer app.wg.Done()
// Recover any panic.
defer func() {
if err := recover(); err != nil {
app.logger.PrintError(fmt.Errorf("%s", err), nil, app.config.debug)
}
}()
// Execute the arbitrary function that we passed as the parameter.
fn()
}()
}
The tokens expire and get deleted from redis after TOKEN_EXPIRATION
has elapsed.
I think we should stop here as this article is getting pretty long. In the next one, we will implement missing methods, configure our app for email sending and implement activating users' accounts handler. Enjoy!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (1)
Wow! This is a pretty awesome Code Review.
I did use quite some Java/C++. So I got sold to that. However, I will definitely keep your review and ensure that my subsequent Go projects are idiomatic enough.
As for the "data" semantic, this system was part of a bigger project with many data models. I only extracted the user part but forgot to rename it to something more intuitive.
Thank you so much for this feedback. I honestly await another in my subsequent articles.