mkdir academy-go
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:
- mise — to manage different versions of programming languages, here Go and Node.js.
- goose — to generate and run database migrations,
- direnv — to manage settings and secrets in environment variables.
- modd — to automatically rebuild and reload the application upon changes to the source code.
- sqlc — to generate type-safe code for database operations based on the database schema.
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:
- github.com/alexedwards/argon2id — to hash passwords using the Argon2id password hashing algorithm.
- github.com/go-webauthn/webauthn — the actual WebAuthn implementation. We will be using this library to generate and validate registration and attestation challenges.
- templ — a type-safe templating language that compiles to Go.
- github.com/gorilla/schema — to parse URL-encoded data into structs.
- github.com/gorilla/sessions — for persisting session state in cookies. We will be using session storage to display flash notifications, for CSRF protection, and to persist WebAuthn challenges across requests.
- github.com/gookit/validate — for struct validation.
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:
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:
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:
id
: an automatically generated primary key of typebigint
(equivalent ofint64
),email
: case-insensitive string column with a unique index,display_name
,password_hash
: string column to store an password hashed using Argon2 in PHC string format,inserted_at
andupdated_at
: to store creation and modification times, respectively. The timestamps are stored without milliseconds (hence the type nametimestamp(0)
). We will store the times in the UTC time zone, regardless of your geographical location.
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:
- Create a test database.
- Run all schema migrations against the newly created database.
- 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(¶ms); !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(¶ms); !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))
}