Webauthn Academy

Implementing Webauthn in Golang

This section is dedicated to an implementation of a WebAuthn (Web Authentication) workflow using the Go programming language.

This website is not meant as a complete learning resource for beginners, but rather a reference implementation of a complete Webauthn workflow. It is the text that I wish were available when I first started implementing Webauthn in my applications.

Who this text is for #

In general, I have written this text with an experienced audience in mind. On the other hand, even if you are a beginner, building a Web application as described in this walkthrough, although painful, is likely going to benefit you more than watching another learn-Python-in-3-hours video or trying to wish a job into existence. You are going to need a reasonably good knowledge of back end development, the UNIX command line, SQL, and all three languages used in browser environments (HTML, CSS, and JavaScript). I may leave several difficult code snippets unexplained, which I believe should be readable without explanation.

If you have any suggestions for improvements to the tutorial, feel free to reach out to me or to submit a Pull Request in the Github repository of this website.

The source code of the application we are going to build is available on Github: moroz/webauthn-academy-go.

Technological stack #

This website was developed on a variety of Linux-powered machines, using Go 1.23.1 and Node 20.17.0, and PostgreSQL 16.4. The back end application should work exactly the same on any UNIX-like operating system, and possibly even on Windows. However, the Web Authentication API in the browser is only supported on Linux, macOS, and Windows.

There are several command-line tools we will be using in this walkthrough:

Whenever possible, I try to use just the standard library, so with enough knowledge of the Go ecosystem, you should be able to modify the solution to use your preferred libraries. However, there are several libraries that handle everyday tasks much more elegantly than the standard library. A few notable Go examples:

We will be bundling CSS and JavaScript code using Vite, TypeScript, and SASS.

Initial setup #

The following walkthrough sets up a password authentication from scratch. Once this text is finalized, you will be able to skip to the section where I start implementing Webauthn. For now, you can just follow along.

Create a directory for the new project:

mkdir academy-go

Ensure Golang is installed (here using mise):

$ cd academy-go
$ mise install go@1.23.1 node@lts

# Save preferred versions of the Go toolchain and Node.js to .tool-versions
$ mise local go@1.23.1
$ mise local node@lts

# Check that Go and Node.js are installed with the correct versions
$ go version
go version go1.23.1 linux/amd64
$ node --version
v20.17.0

Initialize a Go module in this directory.

# Swap "moroz" for your Github username
go mod init github.com/moroz/webauthn-academy-go

Initialize a Git repository in this directory:

git init
git branch -M main
git add .
git commit -m "Initial commit"

In the remaining part of this article, I will not include any git commands or commit messages, as this would make the article too verbose. I encourage you to commit and push often, even if you think there is “nothing to commit,” or if you haven’t finished your tasks. It is also good practice to write informative commit messages.

Simple HTTP router using chi-router #

Let’s build a simple HTTP handler to process incoming requests. First, install go-chi/chi—a routing library built on top of the net/http package from the Go standard library:

go get -u github.com/go-chi/chi/v5

Then create a file named main.go in the project’s root directory:

File: main.go

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func main() {
	r := chi.NewRouter()

	r.Use(middleware.Logger)

	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "<h1>Hello from the router!</h1>")
	})

	log.Println("Listening on port 3000")
	log.Fatal(http.ListenAndServe(":3000", r))
}

This file uses a common Go idiom: the blocking call to http.ListenAndServe is wrapped in a call to log.Fatal. The blocking call will only return a value if the operation fails, for instance if a port is already in use. log.Fatal will then log the error message and terminate the program with a non-zero exit code.

Run the server:

$ go run .
2024/05/19 14:46:01 Listening on port 3000

When you visit localhost:3000 now, you should be greeted by this view:

A &ldquo;Hello world&rdquo;-like message served using <code>chi-router</code>.
A “Hello world”-like message served using chi-router.

Database schema migrations using goose #

In this section, we are going to set up goose, a command-line tool for database schema migrations.

Installing goose using tools.go #

Since goose is a command-line tool separate from our program logic, we want to install it as a standalone application. The Go toolchain allows us to track versions of CLI tools in go.mod using a technique called tools.go.

In the root directory of the project, run this command to download goose and add it to the project’s dependencies:

go get github.com/pressly/goose/v3/cmd/goose@latest

In the same directory, create a file called tools.go with the following contents:

File: tools.go

//go:build tools
// +build tools

package main

import (
	_ "github.com/pressly/goose/v3/cmd/goose"
)

Then, create a Makefile with the following contents:

File: Makefile

download:
	go mod download

install.tools: download
	@echo Installing tools from tools.go
	@grep _ tools.go | awk -F'"' '{print $$2}' | xargs -tI % go install %

If you run make install.tools now, you should end up with goose correctly installed in PATH:

$ make install.tools
go mod download
Installing tools from tools.go
go install github.com/pressly/goose/v3/cmd/goose
$ which goose
/home/karol/.local/share/mise/installs/go/1.23.1/bin/goose

Now, let’s set up a database. First, create a .envrc file. We will be using this file to set environment variables using direnv.

File: .envrc

export PGDATABASE=academy_dev
export DATABASE_URL="postgres://postgres:postgres@localhost/${PGDATABASE}?sslmode=disable"
export GOOSE_MIGRATION_DIR=db/migrations
export GOOSE_DRIVER=postgres
export GOOSE_DBSTRING="$DATABASE_URL"

By setting a PGDATABASE variable, we instruct the PostgreSQL CLI tools to connect to the project’s database by default. DATABASE_URL is a database connection string in URL format.

GOOSE_MIGRATION_DIR instructs Goose to look for migration files in the db/migrations directory. The GOOSE_DBSTRING makes Goose run the migration scripts against our development database. In the command line, source this script or run direnv allow to apply these settings and create a database:

# If you have configured direnv
$ direnv allow

# Otherwise just source this file
$ source .envrc

# Create the database 
$ createdb

The .envrc file should not be committed to Git. There are two main reasons for that: Firstly, in the future, the .envrc will likely contain some secrets that should not be exposed to the outside world, such as passwords, API tokens, etc. Secondly, every time you set up the project for local work, you may want to apply some changes to these variables that do not need to be propagated to the upstream Git repository. Therefore, we tell Git to ignore this file by adding its filename to .gitignore and commit a safe .envrc.sample file instead:

# Create a file with the contents of ".envrc"
# or append a line if the file exists
$ echo .envrc >> .gitignore

# Copy the content to the file we want to commit
$ cp .envrc .envrc.sample

If you add new required environment variables to your local .envrc file, make sure to also update .envrc.sample.

create_users migration #

Create a directory for database migrations. goose will not create one automatically and will fail with an error message if the directory does not exist when creating a migration file.

mkdir -p db/migrations

Generate a migration file for the users table. Do note that the file name contains a timestamp and will therefore be different each time you run this command.

$ goose create create_users sql
2024/09/13 08:00:48 Created new file: db/migrations/20240913000048_create_users.sql

In the newly created migration file, add instructions to create and tear down the users table:

File: db/migrations/20240913000048_create_users.sql

-- +goose Up
-- +goose StatementBegin
create extension if not exists citext;

create table users (
  id bigint primary key generated by default as identity,
  email citext not null unique,
  display_name varchar(80) not null,
  password_hash varchar(100) not null,
  inserted_at timestamp(0) not null default (now() at time zone 'utc'),
  updated_at timestamp(0) not null default (now() at time zone 'utc')
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
drop table users;
-- +goose StatementEnd

You can execute this migration using goose up:

$ goose up
2024/09/13 10:48:01 OK   20240913000048_create_users.sql (9.66ms)
2024/09/13 10:48:01 goose: successfully migrated database to version: 20240913000048

You can check whether the migration was successful by connecting to the database using psql and requesting information about the users table using \d+ users.

This migration should create a table with the following columns:

Type-safe SQL using sqlc #

Add the sqlc dependency to go.mod:

go get github.com/sqlc-dev/sqlc/cmd/sqlc@latest

Add the dependency to tools.go:

File: tools.go

//go:build tools
// +build tools

package main

import (
	_ "github.com/pressly/goose/v3/cmd/goose"
	_ "github.com/sqlc-dev/sqlc/cmd/sqlc"
)

Install tools using make install.tools:

$ make install.tools 
go mod download
Installing tools from tools.go
go install github.com/pressly/goose/v3/cmd/goose
go install github.com/sqlc-dev/sqlc/cmd/sqlc

$ which sqlc
/home/karol/.local/share/mise/installs/go/1.23.1/bin/sqlc

Create a configuration file at sqlc.yml:

File: sqlc.yml

version: "2"

sql:
  - engine: "postgresql"
    queries: "db/sql/*.sql"
    schema: "db/migrations"
    gen:
      go:
        out: "db/queries"
        sql_package: "pgx/v5"
        emit_pointers_for_null_types: true
        emit_result_struct_pointers: true
        initialisms: ["id", "aaguid"]
        overrides:
          - db_type: "public.citext"
            go_type: "string"

This config file tells sqlc to generate code for all queries defined in db/sql/*.sql. sqlc will infer data types for database columns based on the schema migrations defined with goose. Other settings of interest include emit_pointers_for_null_types: true (instructing sqlc to generate structs with pointer types for nullable columns, e. g. *string instead of sql.NullString).

File: db/sql/users.sql

-- name: InsertUser :one
insert into users (email, display_name, password_hash) values ($1, $2, $3) returning *;

Run the generator:

sqlc generate

If there is no output, it means that the generation has completed successfully. Now, in db/queries, you should find three files, db.go, models.go, and users.sql.go.

These files contain types, interfaces, and methods that we will use to interact with the database further in the project. Based on the short snippet of SQL code in db/sql/users.sql, sqlc managed to generate the following code:

File: db/queries/users.sql.go

// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.27.0
// source: users.sql

package queries

import (
	"context"
)

const insertUser = `-- name: InsertUser :one
insert into users (email, display_name, password_hash) values ($1, $2, $3) returning id, email, display_name, password_hash, inserted_at, updated_at
`

type InsertUserParams struct {
	Email        string
	DisplayName  string
	PasswordHash string
}

func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (*User, error) {
	row := q.db.QueryRow(ctx, insertUser, arg.Email, arg.DisplayName, arg.PasswordHash)
	var i User
	err := row.Scan(
		&i.ID,
		&i.Email,
		&i.DisplayName,
		&i.PasswordHash,
		&i.InsertedAt,
		&i.UpdatedAt,
	)
	return &i, err
}

Even though the query we wrote ended with returning *, sqlc expanded the asterisk into all the corresponding columns, defined all the necessary data structures, and generated type-safe code for this operation. Impressive!

Implementing user registration logic #

In this section, we will implement the business logic for the user registration workflow. The logic will be implemented within the services package. Define a UserService type in services/user_service.go:

File: services/user_service.go

package services

import (
	"github.com/moroz/webauthn-academy-go/db/queries"

	"context"
)

type UserService struct {
	queries *queries.Queries
}

func NewUserService(db queries.DBTX) UserService {
	return UserService{queries: queries.New(db)}
}

type RegisterUserParams struct {
	Email                string
	DisplayName          string
	Password             string
	PasswordConfirmation string
}

func (us *UserService) RegisterUser(ctx context.Context, params RegisterUserParams) (*queries.User, error) {
	return nil, nil
}

At this point, the RegisterUser method does nothing. However, we have a clearly defined API contract: we have defined a type for the input parameters, and know that the method should return a (*queries.User, error) tuple. This is enough to write some unit tests for the RegisterUser method and implement the logic later to get the test suite to pass.

According to this StackOverflow thread, what we are going to write is, by definition, an integration test, rather than a unit test, by merit of talking to an actual database. However, I find this distinction to be quite useless, because almost all tests that we are going to be writing for this project will be running against a real database. So, if you really care about correctness, please just keep in mind that everything below is actually an integration test.

Preparing a test database #

In order to run our integration tests against a dedicated database, we need to prepare the database in roughly the following steps:

  1. Create a test database.
  2. Run all schema migrations against the newly created database.
  3. On subsequent runs, clean database tables.

First, let us define new environment variables in .envrc. We will be using these variables to create and connect to the test database.

File: .envrc

# append these lines at the end
export TEST_DATABASE_NAME=academy_test
export TEST_DATABASE_URL="postgres://postgres:postgres@localhost/${TEST_DATABASE_NAME}?sslmode=disable"

Make sure to run direnv allow to approve these changes and to update .envrc.sample accordingly.

In Makefile, define the following targets to prepare a test database and run test suites:

File: Makefile

# append these lines at the end
guard-%:
	@test -n "${$*}" || (echo "FATAL: Environment variable $* is not set!"; exit 1)

db.test.prepare: guard-TEST_DATABASE_NAME guard-TEST_DATABASE_URL
	@createdb ${TEST_DATABASE_NAME} 2>/dev/null || true
	@env GOOSE_DBSTRING="${TEST_DATABASE_URL}" goose up

test: db.test.prepare
	go test -v ./...

This file utilizes GNU make syntax extensions to define a dynamic guard-% target, which ensures that each required environment variable is set and non-empty. If you are developing on a system that defaults to BSD make (such as FreeBSD), you may need to run all make commands as gmake.

The db.test.prepare uses this dynamic target to validate that both TEST_DATABASE_NAME and TEST_DATABASE_URL are non-empty before creating a tast database and running schema migrations against it using goose. If the database already exists, we ignore all errors and proceed with the schema migrations.

Finally, the test target runs the test suites of all packages in the project. Since the test target lists db.test.prepare as a dependency, make will ensure that all the migrations are correctly applied against the test database before the test suites are executed.

With these changes, you should be able to execute both targets and end up with a properly migrated test database, and the testing engine should inform you that it did not manage to find any test files in the project:

$ make db.test.prepare
2024/09/16 01:00:28 OK   20240913000048_create_users.sql (13.56ms)
2024/09/16 01:00:28 goose: successfully migrated database to version: 20240913000048

$ make test
2024/09/16 01:00:31 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
?       github.com/moroz/webauthn-academy-go/services   [no test files]

Setting up a test suite #

Even though Go comes with a built-in testing engine, writing tests with just the standard library tooling is very tedious and repetitive. Therefore we are going to install stretchr/testify.

First, install testify:

$ go get github.com/stretchr/testify
$ go get github.com/stretchr/testify/suite

In services/service_test.go, set up a services_test package. In this file, we are going to define the main test suite, which will later be shared by unit tests for all service types.

File: services/service_test.go

package services_test

import (
	"context"
	"os"
	"testing"

	"github.com/jackc/pgx/v5"
	"github.com/moroz/webauthn-academy-go/db/queries"
	"github.com/stretchr/testify/suite"
)

type ServiceTestSuite struct {
	suite.Suite
	db queries.DBTX
}

func TestServiceTestSuite(t *testing.T) {
	suite.Run(t, new(ServiceTestSuite))
}

func (s *ServiceTestSuite) SetupTest() {
	connString := os.Getenv("TEST_DATABASE_URL")
	db, err := pgx.Connect(context.Background(), connString)
	s.NoError(err)
	s.db = db

	_, err = s.db.Exec(context.Background(), "truncate users")
	s.NoError(err)
}

This file makes use of the github.com/stretchr/testify/suite package, providing convenience functions to run tests in test suites (pronounced like test sweets).

Using a test suite, our test examples can use setup and teardown callbacks. In this example, we define a SetupTestSuite() method on the ServiceTestSuite type, and within that method, we connect to the database and clean the users table to ensure that we are starting with an empty database. The calls to s.NoError(err) use test assertions, which are convenience methods defined in the github.com/stretchr/testify/assert package. For instance, when calling the method NoError with an error value, we assert that no error was returned from a function. If an error is indeed returned, the test will fail, hopefully with a descriptive error message.

Note that we also need to define a regular Go test example, here named TestServiceTestSuite, serving as an entry point for the Go test runner.

Next, we can test our data validation and registration logic in a new file:

File: services/user_service_test.go

package services_test

import (
	"context"

	"github.com/moroz/webauthn-academy-go/services"
)

func (s *ServiceTestSuite) TestRegisterUser() {
	params := services.RegisterUserParams{
		Email:                "registration@example.com",
		DisplayName:          "Example User",
		Password:             "foobar123123",
		PasswordConfirmation: "foobar123123",
	}

	srv := services.NewUserService(s.db)
	user, err := srv.RegisterUser(context.Background(), params)
	s.NoError(err)
	s.NotNil(user)
}

Making the tests pass #

If you run the tests now, this test example is going to fail, because we have not implemented the registration logic yet:

$ make test
2024/09/17 00:31:16 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
    user_service_test.go:20: 
                Error Trace:    /home/karol/working/webauthn/wip/services/user_service_test.go:20
                Error:          Expected value not to be nil.
                Test:           TestServiceTestSuite/TestRegisterUser
--- FAIL: TestServiceTestSuite (0.02s)
    --- FAIL: TestServiceTestSuite/TestRegisterUser (0.02s)
FAIL
FAIL    github.com/moroz/webauthn-academy-go/services   0.024s
FAIL
make: *** [Makefile:16: test] Error 1

We can make our initial test pass with the following implementation of RegisterUser:

File: services/user_service.go

func (us *UserService) RegisterUser(ctx context.Context, params RegisterUserParams) (*queries.User, error) {
	user, err := us.queries.InsertUser(ctx, queries.InsertUserParams{
		Email:        params.Email,
		DisplayName:  params.DisplayName,
		PasswordHash: params.Password,
	})

	return user, err
}

This implementation satisfies our initial test conditions:

$ make test
2024/09/17 00:57:05 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
--- PASS: TestServiceTestSuite (0.03s)
    --- PASS: TestServiceTestSuite/TestRegisterUser (0.03s)
PASS
ok      github.com/moroz/webauthn-academy-go/services   0.030s

However, there is a problem with this implementation: we are storing passwords in plain text. The recommended way to store passwords in a database is to hash them using a dedicated password hashing algorithm. In the next subsection, we are going to modify our implementation to use Argon2id instead.

Hashing passwords using Argon2id #

Before implementing password hashing, let us first add a test example. Using the Regexp matcher and a regular expression, we test if the PasswordHash field on the returned queries.User struct matches the PHC string format for Argon2id:

File: services/user_service_test.go

func (s *ServiceTestSuite) TestRegisterUser() {
	params := services.RegisterUserParams{
		Email:                "registration@example.com",
		DisplayName:          "Example User",
		Password:             "foobar123123",
		PasswordConfirmation: "foobar123123",
	}

	srv := services.NewUserService(s.db)
	user, err := srv.RegisterUser(context.Background(), params)
	s.NoError(err)
	s.NotNil(user)

	s.Regexp(regexp.MustCompile(`^\$argon2id\$`), user.PasswordHash)
}

This regular expression will only match if the string starts with the substring $argon2id$. We need to escape dollar signs as \$ so that they are interpreted as literal dollar signs and not as “end of string.” Do note that we are passing the source for this regular expression as a raw string, surrounded with backticks (`) rather than double quotes ("). This way we do not need to escape backslashes (we can write \ instead of \\).

This test successfully fails:

$ make test
2024/09/17 01:36:39 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
    user_service_test.go:23: 
                Error Trace:    /home/karol/working/webauthn/wip/services/user_service_test.go:23
                Error:          Expect "foobar123123" to match "^\$argon2id\$"
                Test:           TestServiceTestSuite/TestRegisterUser
--- FAIL: TestServiceTestSuite (0.02s)
    --- FAIL: TestServiceTestSuite/TestRegisterUser (0.02s)
FAIL
FAIL    github.com/moroz/webauthn-academy-go/services   0.026s
FAIL
make: *** [Makefile:16: test] Error 1

The first step to make it pass is to install argon2id:

$ go get github.com/alexedwards/argon2id
go: added github.com/alexedwards/argon2id v1.0.0

Then, in RegisterUser, hash the password before inserting it into the database:

File: services/user_service.go

func (us *UserService) RegisterUser(ctx context.Context, params RegisterUserParams) (*queries.User, error) {
	hash, err := argon2id.CreateHash(params.Password, argon2id.DefaultParams)
	if err != nil {
		return nil, err
	}

	user, err := us.queries.InsertUser(ctx, queries.InsertUserParams{
		Email:        params.Email,
		DisplayName:  params.DisplayName,
		PasswordHash: hash,
	})

	return user, err
}

The test should now be passing:

$ make test
2024/09/17 01:41:04 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
--- PASS: TestServiceTestSuite (0.04s)
    --- PASS: TestServiceTestSuite/TestRegisterUser (0.04s)
PASS
ok      github.com/moroz/webauthn-academy-go/services   0.043s

Validating inputs using gookit/validate #

gookit/validate is a simple and customizable struct validation library. As is the case with many open source Go libraries, its documentation is written in Chinglish and it is easier to read if you understand Chinese.

Why not go-playground/validator, you may ask?

For reasons I cannot fathom, the Golang ecosystem has settled on go-playground/validator as the state of the art for struct validation. I admit, this library has a considerable set of built-in validators, but even at version 10, its documentation and codebase is dreadful, full of cryptic logic, tight coupling to equally poorly documented libraries, and single-letter variable names. gookit/validate, on the other hand, is much simpler, and I do not need to use a mysterious “universal translator” library just to display a custom error message.

Start by installing gookit/validate:

$ go get github.com/gookit/validate
go: added github.com/gookit/filter v1.2.1
go: added github.com/gookit/goutil v0.6.15
go: added github.com/gookit/validate v1.5.2

Let’s build a failing test to ensure that user registration fails if any of the parameters is an empty string:

File: services/user_service_test.go

func (s *ServiceTestSuite) TestRegisterUserWithMissingAttributes() {
	examples := []services.RegisterUserParams{
		{Email: "", DisplayName: "Example User", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "Example User", Password: "", PasswordConfirmation: "foobar123123"},
	}

	srv := services.NewUserService(s.db)

	for _, params := range examples {
		user, err := srv.RegisterUser(context.Background(), params)
		s.Nil(user)
		s.IsType(validate.Errors{}, err)
	}
}

On the NewUserParams struct type, define annotations for gookit/validate:

File: services/user_service.go

// import "github.com/gookit/validate"
// ...

type RegisterUserParams struct {
	Email                string `validate:"required|email"`
	DisplayName          string `validate:"required"`
	Password             string `validate:"required|min_len:8|max_len:64"`
	PasswordConfirmation string `validate:"eq_field:Password"`
}

func (us *UserService) RegisterUser(ctx context.Context, params RegisterUserParams) (*queries.User, error) {
	if v := validate.Struct(&params); !v.Validate() {
		return nil, v.Errors
	}

	hash, err := argon2id.CreateHash(params.Password, argon2id.DefaultParams)
	if err != nil {
		return nil, err
	}

	user, err := us.queries.InsertUser(ctx, queries.InsertUserParams{
		Email:        params.Email,
		DisplayName:  params.DisplayName,
		PasswordHash: hash,
	})

	return user, err
}

With these changes, our tests for required validations should again be passing:

$ make test
2024/09/17 16:36:00 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
=== RUN   TestServiceTestSuite/TestRegisterUserWithMissingAttributes
--- PASS: TestServiceTestSuite (0.07s)
    --- PASS: TestServiceTestSuite/TestRegisterUser (0.05s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithMissingAttributes (0.02s)
PASS
ok      github.com/moroz/webauthn-academy-go/services   (cached)

When running the test in dlv, we can see the actual error and the corresponding error messages (see my dotfiles for Neovim integration):

Type 'help' for list of commands.
Breakpoint 1 set at 0xd3cf7c for github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithMissingAttributes() ./user_service_test.go:38
> [Breakpoint 1] github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithMissingAttributes() ./user_service_test.go:38 (hits goroutine(146):1 total:1) (PC: 0xd3cf7c)
    33:
    34:         srv := services.NewUserService(s.db)
    35:
    36:         for _, params := range examples {
    37:                 user, err := srv.RegisterUser(context.Background(), params)
=>  38:                 s.Error(err)
    39:                 s.Nil(user)
    40:         }
    41: }
(dlv) p err
error(github.com/gookit/validate.Errors) [
        "Email": [
                "required": "Email is required to not be empty", 
        ], 
]
(dlv) 

The error message is technically correct, but the wording is strange. Let’s update the tests to ensure that the error message is equal to "can't be blank":

File: services/user_service_test.go

func (s *ServiceTestSuite) TestRegisterUserWithMissingAttributes() {
	examples := []services.RegisterUserParams{
		{Email: "", DisplayName: "Example User", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "Example User", Password: "", PasswordConfirmation: "foobar123123"},
	}

	srv := services.NewUserService(s.db)

	for _, params := range examples {
		user, err := srv.RegisterUser(context.Background(), params)
		s.Nil(user)
		s.IsType(validate.Errors{}, err)

		actual := err.(validate.Errors).OneError().Error()
		s.Equal("can't be blank", actual)
	}
}
$ make test                                                                                                                          
2024/09/17 18:57:05 goose: no migrations to run. current version: 20240913000048                                                                              
go test -v ./...                                                                                                                                              
?       github.com/moroz/webauthn-academy-go    [no test files]                                                                                               
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]                                                                                       
=== RUN   TestServiceTestSuite                                                                                                                                
=== RUN   TestServiceTestSuite/TestRegisterUser                                                                                                               
=== RUN   TestServiceTestSuite/TestRegisterUserWithMissingAttributes                                                                                          
    user_service_test.go:42:                                                                                                                                  
                Error Trace:    /home/karol/working/webauthn/wip/services/user_service_test.go:42                                                             
                Error:          Not equal: 
                                expected: "can't be blank"
                                actual  : "Email is required to not be empty"

# ... many similar errors below ...

File: services/user_service.go

type RegisterUserParams struct {
	Email                string `validate:"required|email"`
	DisplayName          string `validate:"required"`
	Password             string `validate:"required|min_len:8|max_len:64"`
	PasswordConfirmation string `validate:"eq_field:Password" message:"passwords do not match"`
}

func init() {
	validate.AddGlobalMessages(map[string]string{
		"required": "can't be blank",
		"min_len":  "must be at least %d characters long",
		"max_len":  "must be at most %d characters long",
	})
}

Again, we can verify the changes in the debugger:

Type 'help' for list of commands.
Breakpoint 1 set at 0xd3d39c for github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithMissingAttributes() ./user_service_test.go:38
> [Breakpoint 1] github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithMissingAttributes() ./user_service_test.go:38 (hits goroutine(86):1 total:1) (PC: 0xd3d39c)
    33:
    34:         srv := services.NewUserService(s.db)
    35:
    36:         for _, params := range examples {
    37:                 user, err := srv.RegisterUser(context.Background(), params)
=>  38:                 s.Error(err)
    39:                 s.Nil(user)
    40:         }
    41: }
(dlv) p err
error(github.com/gookit/validate.Errors) [
        "Email": [
                "required": "can't be blank", 
        ], 
]
(dlv) 

Analogously, we can add more tests for the remaining validations. The following listing shows the test file in its entirety, which I am not going to explain in detail:

File: services/user_service_test.go

package services_test

import (
	"context"
	"regexp"

	"github.com/gookit/validate"
	"github.com/moroz/webauthn-academy-go/services"
)

func (s *ServiceTestSuite) TestRegisterUser() {
	params := services.RegisterUserParams{
		Email:                "registration@example.com",
		DisplayName:          "Example User",
		Password:             "foobar123123",
		PasswordConfirmation: "foobar123123",
	}

	srv := services.NewUserService(s.db)
	user, err := srv.RegisterUser(context.Background(), params)
	s.NoError(err)
	s.NotNil(user)

	s.Regexp(regexp.MustCompile(`^\$argon2id\$`), user.PasswordHash)
}

func (s *ServiceTestSuite) TestRegisterUserWithMissingAttributes() {
	examples := []services.RegisterUserParams{
		{Email: "", DisplayName: "Example User", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "", Password: "foobar123123", PasswordConfirmation: "foobar123123"},
		{Email: "registration@example.com", DisplayName: "Example User", Password: "", PasswordConfirmation: "foobar123123"},
	}

	srv := services.NewUserService(s.db)

	for _, params := range examples {
		user, err := srv.RegisterUser(context.Background(), params)
		s.Nil(user)
		s.IsType(validate.Errors{}, err)

		actual := err.(validate.Errors).OneError().Error()
		s.Equal("can't be blank", actual)
	}
}

func (s *ServiceTestSuite) TestRegisterUserWithInvalidEmail() {
	params := services.RegisterUserParams{
		Email: "user@invalid", DisplayName: "Invalid User", Password: "foobar123123", PasswordConfirmation: "foobar123123",
	}

	srv := services.NewUserService(s.db)

	user, err := srv.RegisterUser(context.Background(), params)
	s.Nil(user)
	s.IsType(validate.Errors{}, err)

	actual := err.(validate.Errors).Field("Email")
	s.Equal(map[string]string{"email": "is not a valid email address"}, actual)
}

func (s *ServiceTestSuite) TestRegisterUserWithShortPassword() {
	params := services.RegisterUserParams{
		Email: "user@example.com", DisplayName: "Invalid User", Password: "foo", PasswordConfirmation: "foo",
	}

	srv := services.NewUserService(s.db)

	user, err := srv.RegisterUser(context.Background(), params)
	s.Nil(user)
	s.IsType(validate.Errors{}, err)

	actual := err.(validate.Errors).Field("Password")
	s.Equal(map[string]string{"min_len": "must be at least 8 characters long"}, actual)
}

func (s *ServiceTestSuite) TestRegisterUserWithTooLongPassword() {
	password := ""
	for _ = range 80 {
		password += "a"
	}

	params := services.RegisterUserParams{
		Email: "user@example.com", DisplayName: "Invalid User", Password: password, PasswordConfirmation: password,
	}

	srv := services.NewUserService(s.db)

	user, err := srv.RegisterUser(context.Background(), params)
	s.Nil(user)
	s.IsType(validate.Errors{}, err)

	actual := err.(validate.Errors).Field("Password")
	s.Equal(map[string]string{"max_len": "must be at most 64 characters long"}, actual)
}

func (s *ServiceTestSuite) TestRegisterUserWithInvalidPasswordConfirmation() {
	params := services.RegisterUserParams{
		Email: "user@example.com", DisplayName: "Invalid User", Password: "foobar123123", PasswordConfirmation: "different_password",
	}

	srv := services.NewUserService(s.db)

	user, err := srv.RegisterUser(context.Background(), params)
	s.Nil(user)
	s.IsType(validate.Errors{}, err)

	actual := err.(validate.Errors).Field("PasswordConfirmation")
	s.Equal(map[string]string{"eq_field": "passwords do not match"}, actual)
}

Email address should be unique #

At the moment, if you try to register a user with a non-unique email address, the action will fail on database level, because there is a unique constraint on the email column. This is enough to ensure that the data stored in the database is correct, but it’s not exactly the best user experience. Ideally, we would like to get validation errors just like for all the other validations. One possible way to do this would be to query the database every time when we want to insert or update a row, like this:

select exists (select 1 from users where email = 'user@example.com');
-- if it returns `true`, abandon the operation

This is the approach used by validates_uniqueness_of in ActiveRecord, which is the default ORM in Ruby on Rails. It is arguably the simplest way to solve it, but on its own, it does not guarantee data correctness—you always need to use it together with a database constraint.

Another way is to create a unique constraint in the database, and if a constraint validation prevents a record from being inserted, we convert the raw error struct returned by the database into a pretty validation message:

insert into users (email) values ('user@example.com');
-- Okeyday! Value correctly inserted!

insert into users (email) values ('user@example.com');
-- OOPSIE! Return a validation error!

This is the approach I am going to use in this tutorial, and it is inspired by Ecto for Elixir.

Database-side uniqueness validation #

Start by writing tests for the way we want the application to work. First, create a user and ensure the first one can be correctly saved. Then, try to register the same user with different casing: unmodified, all-caps, and with the first letter of each word capitalized. Since the email column is stored as citext (case-insensitive text), a unique constraint should handle each of these cases correctly, rejecting each of the three records with exactly the same error.

File: services/user_service_test.go

func (s *ServiceTestSuite) TestRegisterUserWithDuplicateEmail() {
	params := services.RegisterUserParams{
		Email: "uniqueness@example.com", DisplayName: "Uniqueness Test", Password: "foobar123123", PasswordConfirmation: "foobar123123",
	}

	srv := services.NewUserService(s.db)

	user, err := srv.RegisterUser(context.Background(), params)
	s.NotNil(user)
	s.Nil(err)

	emails := []string{params.Email, strings.ToUpper(params.Email), "Uniqueness@Example.Com"}

	for _, email := range emails {
		params.Email = email
		user, err := srv.RegisterUser(context.Background(), params)
		s.Nil(user)
		s.IsType(validate.Errors{}, err)

		actual := err.(validate.Errors).Field("Email")
		s.Equal(map[string]string{"unique": "has already been taken"}, actual)
	}
}

If you run the tests now, the example panics due to an incorrect type cast: the error value returned from RegisterUser is still a raw *pgconn.PgError struct rather than a validation error:

$ make test                                                                                                                                                                                 
2024/09/20 01:14:22 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
=== RUN   TestServiceTestSuite/TestRegisterUserWithDuplicateEmail
    user_service_test.go:128: 
                Error Trace:    /home/karol/working/webauthn/wip/services/user_service_test.go:128
                Error:          Expected nil, but got: &queries.User{ID:0, Email:"", DisplayName:"", PasswordHash:"", InsertedAt:pgtype.Timestamp{Time:time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), InfinityModifier:0, Valid:false}, UpdatedAt:pgtype.Timestamp{Time:time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), InfinityModifier:0, Valid:false}}
                Test:           TestServiceTestSuite/TestRegisterUserWithDuplicateEmail
    user_service_test.go:129: 
                Error Trace:    /home/karol/working/webauthn/wip/services/user_service_test.go:129
                Error:          Object expected to be of type validate.Errors, but was *pgconn.PgError
                Test:           TestServiceTestSuite/TestRegisterUserWithDuplicateEmail
    iface.go:275: test panicked: interface conversion: error is *pgconn.PgError, not validate.Errors
        goroutine 102 [running]:
// ... stacktrace omitted for brevity ...

We can check the exact error value in a debugger:

Type 'help' for list of commands.
Breakpoint 1 set at 0xd3edda for github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithDuplicateEmail() ./user_service_test.go:128
> [Breakpoint 1] github.com/moroz/webauthn-academy-go/services_test.(*ServiceTestSuite).TestRegisterUserWithDuplicateEmail() ./user_service_test.go:128 (hits goroutine(146):1 total:1) (PC: 0xd3edda)
   123:         emails := []string{params.Email, strings.ToUpper(params.Email), "Uniqueness@Example.Com"}
   124:
   125:         for _, email := range emails {
   126:                 params.Email = email
   127:                 user, err := srv.RegisterUser(context.Background(), params)
=> 128:                 s.Nil(user)
   129:                 s.IsType(validate.Errors{}, err)
   130:
   131:                 actual := err.(validate.Errors).Field("Email")
   132:                 s.Equal(map[string]string{"unique": "has already been taken"}, actual)
   133:         }
(dlv) p err
error(*github.com/jackc/pgx/v5/pgconn.PgError) *{
        Severity: "ERROR",
        SeverityUnlocalized: "ERROR",
        Code: "23505",
        Message: "duplicate key value violates unique constraint \"users_email_key\"",
        Detail: "Key (email)=(uniqueness@example.com) already exists.",
        Hint: "",
        Position: 0,
        InternalPosition: 0,
        InternalQuery: "",
        Where: "",
        SchemaName: "public",
        TableName: "users",
        ColumnName: "",
        DataTypeName: "",
        ConstraintName: "users_email_key",
        File: "nbtinsert.c",
        Line: 666,
        Routine: "_bt_check_unique",}

According to Appendix A. PostgreSQL Error Codes in the PostgreSQL documentation (which I highly recommend you read in your spare time), the error code 23505 stands for unique_violation, and indicates that a row we are trying to insert violates a unique constraint. The error struct also contains the name of the validated constraint, which in this case is the name generated automatically by Postgres—users_email_key. With this knowledge, we can update the RegisterUser method accordingly:

File: services/user_service.go

func (us *UserService) RegisterUser(ctx context.Context, params RegisterUserParams) (*queries.User, error) {
	if v := validate.Struct(&params); !v.Validate() {
		return nil, v.Errors
	}

	hash, err := argon2id.CreateHash(params.Password, argon2id.DefaultParams)
	if err != nil {
		return nil, err
	}

	user, err := us.queries.InsertUser(ctx, queries.InsertUserParams{
		Email:        params.Email,
		DisplayName:  params.DisplayName,
		PasswordHash: hash,
	})

	// intercept "unique_violation" errors on the email column
	if err, ok := err.(*pgconn.PgError); ok && err.Code == "23505" && err.ConstraintName == "users_email_key" {
		return nil, validate.Errors{"Email": {"unique": "has already been taken"}}
	}

	return user, err
}

With these modifications, all validation tests should be passing:

$ make test
2024/09/20 01:43:50 goose: no migrations to run. current version: 20240913000048
go test -v ./...
?       github.com/moroz/webauthn-academy-go    [no test files]
?       github.com/moroz/webauthn-academy-go/db/queries [no test files]
=== RUN   TestServiceTestSuite
=== RUN   TestServiceTestSuite/TestRegisterUser
=== RUN   TestServiceTestSuite/TestRegisterUserWithDuplicateEmail
=== RUN   TestServiceTestSuite/TestRegisterUserWithInvalidEmail
=== RUN   TestServiceTestSuite/TestRegisterUserWithInvalidPasswordConfirmation
=== RUN   TestServiceTestSuite/TestRegisterUserWithMissingAttributes
=== RUN   TestServiceTestSuite/TestRegisterUserWithShortPassword
=== RUN   TestServiceTestSuite/TestRegisterUserWithTooLongPassword
--- PASS: TestServiceTestSuite (0.15s)
    --- PASS: TestServiceTestSuite/TestRegisterUser (0.04s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithDuplicateEmail (0.06s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithInvalidEmail (0.01s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithInvalidPasswordConfirmation (0.01s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithMissingAttributes (0.01s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithShortPassword (0.01s)
    --- PASS: TestServiceTestSuite/TestRegisterUserWithTooLongPassword (0.01s)
PASS
ok      github.com/moroz/webauthn-academy-go/services   0.161s

Build a registration page #

In this section, we are going to build a HTTP handler that will display a HTML page with a registration form. Later on, we are going to use this form to register users for the app.

Create a UserRegistrationController #

In a new package called handlers, we are going to define a struct called UserRegistrationController, embedding a pointer to its own instance of queries.Queries. When we instantiate an instance of this controller, we will always pass a database connection (represented by the queries.DBTX interface). This pattern is called dependency injection and will help us test the HTTP stack against a test database further in the project. Each action of this controller will be defined as a method on the controller struct.

File: handlers/user_registration_controller.go

package handlers

import (
	"fmt"
	"net/http"

	"github.com/moroz/webauthn-academy-go/db/queries"
)

type userRegistrationController struct {
	queries *queries.Queries
}

func UserRegistrationController(db queries.DBTX) userRegistrationController {
	return userRegistrationController{queries.New(db)}
}

func (uc *userRegistrationController) New(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "<h1>Hello from UserRegistrationController!</h1>")
}

Move the router to package handlers #

In the same package, add a function that will instantiate the application’s router. Just like in the case of UserRegistrationController, the router requires a database connection, which we will pass to each controller we instantiate. We mount the sign up page at GET /sign-up.

File: handlers/router.go

package handlers

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/moroz/webauthn-academy-go/db/queries"
)

func NewRouter(db queries.DBTX) http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Logger)

	userRegistrations := UserRegistrationController(db)
	r.Get("/sign-up", userRegistrations.New)

	return r
}

Create a configuration package #

In config/config.go, add a module to encapsulate the logic for reading and validating application configuration from environment variables.

File: config/config.go

package config

import (
	"log"
	"os"
)

func MustGetenv(key string) string {
	value := os.Getenv(key)
	if value == "" {
		log.Fatalf("FATAL: Environment variable %s is not set!", key)
	}
	return value
}

var DATABASE_URL = MustGetenv("DATABASE_URL")

The helper function MustGetenv wraps os.Getenv so that if any required environment variable is unset or empty, the function will log an error message and terminate the program. Failing early helps identify configuration errors early on. Since this helper has its own package, we can import it anywhere in the program, without having to worry about circular dependency errors.

Instantiate the router in main() #

File: main.go

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/moroz/webauthn-academy-go/config"
	"github.com/moroz/webauthn-academy-go/handlers"
)

func main() {
	db, err := pgxpool.New(context.Background(), config.DATABASE_URL)
	if err != nil {
		log.Fatal(err)
	}

	router := handlers.NewRouter(db)

	log.Println("Listening on port 3000")
	log.Fatal(http.ListenAndServe(":3000", router))
}