Introduction
Following the completion of the series — Secure and performant full-stack authentication system using rust (actix-web) and sveltekit and Secure and performant full-stack authentication system using Python (Django) and SvelteKit — I felt I should keep the streak by building an equivalent system in PURE go with very minimal external dependencies. We won't use any fancy web framework apart from httprouter and other basic dependencies including a database driver (pq), and redis client. As usual, we'll be using SvelteKit at the front end, favouring JSDoc instead of TypeScript. The combination is ecstatic!
NOTE: We will be using some basic ideas from Let’s Go Further by Alex Edwards. If you are new to Go, such as me, I recommend you start with Let’s Go and then Let’s Go Further. The books are great!
System's Requirement Specification
Throughout this tutorial series, we'll be working towards implementing these requirements:
Build a user authentication system where a user authenticates with an E-mail/Password combination. E-mail addresses must be unique and verified by sending time-limited verification emails upon registration and the verification emails must support HTML. Until verified, no user is allowed to log in. Time attacks must be addressed by sending the mails asynchronously. Password hashing must be strong and only hashed passwords should be stored in the database. Password reset functionality should be incorporated and incepted using e-mail address verifications. A protected user profile update feature should be added so that only authenticated and authorized users can access it. The user profile should include a thumbnail which should be stored in AWS S3.
This is exactly what we implemented in the previous series. We want to learn how it can be done in Go.
DECLAIMER: I wasn't paid to promote Alex Edwards' books. Neither am I affiliated. I just found them useful and quite explanatory for beginner and intermediate Go developers alike! I mention them here for reference purposes.
Technology stack
For emphasis, our tech stack comprises:
-
Backend - Some packages that will be used are:
- Pure Go (v1.20)
- HttpRouter v1 - A lightweight high-performance HTTP request router for Go, etc.
-
Frontend - Some tools that will be used are:
- JavaScript - Language in which the frontend will be written
- SvelteKit v1 - Main frontend framework
- Pure CSS3 - Styles
- HTML5 - Structure
Assumption
A simple prerequisite to follow along is some familiarity with the Go Programming language — like some understanding of structs, goroutine, module system, and co. — JavaScript and CSS. You do not need to be an expert — I ain't one in any of the technologies.
Source code
The source code for this series is hosted on GitHub via:
go-auth
This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.
It is currently live here (the backend may be brought down soon).
To run locally, kindly follow the instructions in each subdirectory.
Implementation
Step 1: Create a project directory
To start off, create a folder. I called mine go-auth
, which will house all our source codes.
~/Documents/Projects/web$ mkdir go-auth && cd go-auth
Having created the folder and changed the directory into it, create one subfolder to house the backend codes and another to house the frontend source files:
~/Documents/Projects/web/go-auth$ mkdir go-auth-backend
~/Documents/Projects/web/go-auth$ npm create svelte@latest frontend
The second command creates a SvelteKit project and uses frontend
as its folder. You should follow the prompts. I chose a skeleton project, opted for JSDoc (not TypeScript), and subscribed to eslint
, prettier
and others. You should follow the instructions listed thereafter. For now, we'll be focusing on the backend.
In other to enable the Go module system for the go-auth-backend
folder, change the directory to it and issue the following command:
~/Documents/Projects/web/go-auth/go-auth-backend$ go init goauthbackend.johnowolabiidogun.dev
goauthbackend.johnowolabiidogun.dev
is the module's path. You should change this to yours as it is meant to be unique. With that, a go.mod
file will be created at the root of your directory.
Next, we will create a bunch of directories, a slight variation of this Go app template:
~/Documents/Projects/web/go-auth/go-auth-backend$ mkdir -p bin cmd/api internal migrations remote
~/Documents/Projects/web/go-auth/go-auth-backend$ touch Makefile
~/Documents/Projects/web/go-auth/go-auth-backend$ touch cmd/api/main.go
By now, we should have a structure like this:
.
├── Makefile
├── bin
├── cmd
│ └── api
│ └── main.go
├── go.mod
├── go.sum
├── internal
├── migrations
└── remote
-
bin
- Ready-to-deploy compiled binaries will be housed here -
cmd/api/
- Will house most of our route-related codes -
internal
- Will house our util-related codes including database models and validations, email sending logic and templating, cookie encryption and decryption, custom types, telemetry, token generation and validation, among others. -
remote
- Production scripts will live here.
The whole file structure idea was drafted from Let’s Go Further. The book is highly recommended.
Next, we will create some files: config.go
, server.go
, and db.go
in cmd/api/
to handle our configuration variables, start the Go server, and connect to a PostgreSQL database respectively.
~/Documents/Projects/web/go-auth/go-auth-backend$ touch cmd/api/config.go cmd/api/server.go cmd/api/db.go
Step2: Connect to PostgreSQL database and Redis store
Having structured our application, it's time to connect to our database and a redis store. Redis is needed for the temporary storage of tokens and cookies. That is surely more performant than storing them in the database.
To begin with, let's create a config
type in main.go
. This custom type will be made available to all our routes via another type called application
by binding the routes as functions to the type. One of Go's paradigms for OOP (Object-oriented Programming):
// cmd/api/main.go
package main
import (
"os"
"sync"
"time"
_ "github.com/lib/pq"
"github.com/redis/go-redis/v9"
"goauthbackend.johnowolabiidogun.dev/internal/jsonlog"
)
const version = "1.0.0"
// `config` type to house all our app's configurations
type config struct {
port int
env string
db struct {
dsn string
maxOpenConns int
maxIdleConns int
maxIdleTime string
}
redisURL string
}
// Main `application` type
type application struct {
config config
logger *jsonlog.Logger
redisClient *redis.Client
}
func main() {
logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo)
cfg, err := updateConfigWithEnvVariables()
if err != nil {
logger.PrintFatal(err, nil)
}
db, err := openDB(*cfg)
if err != nil {
logger.PrintFatal(err, nil)
}
defer db.Close()
logger.PrintInfo("database connection pool established", nil)
opt, err := redis.ParseURL(cfg.redisURL)
if err != nil {
logger.PrintFatal(err, nil)
}
client := redis.NewClient(opt)
logger.PrintInfo("redis connection pool established", nil)
app := &application{
config: *cfg,
logger: logger,
redisClient: client,
}
err = app.serve()
if err != nil {
logger.PrintFatal(err, nil)
}
}
That must be a lot! Let's go through it.
In the app's entry point, main()
, we initialized our small logging system. We could have used a third-party logging library. The logging code is in internal/jsonlog/jsonlog.go
:
// internal/jsonlog/jsonlog.go
package jsonlog
import (
"encoding/json"
"io"
"os"
"runtime/debug"
"sync"
"time"
)
type Level int8
const (
LevelInfo Level = iota
LevelError
LevelFatal
LevelOff
)
func (l Level) String() string {
switch l {
case LevelInfo:
return "INFO"
case LevelError:
return "ERROR"
case LevelFatal:
return "FATAL"
default:
return ""
}
}
type Logger struct {
out io.Writer
minLevel Level
mu sync.Mutex
}
func New(out io.Writer, minLevel Level) *Logger {
return &Logger{out: out, minLevel: minLevel}
}
func (l *Logger) PrintInfo(message string, properties map[string]string) {
l.print(LevelInfo, message, properties)
}
func (l *Logger) PrintError(err error, properties map[string]string) {
l.print(LevelError, err.Error(), properties)
}
func (l *Logger) PrintFatal(err error, properties map[string]string) {
l.print(LevelFatal, err.Error(), properties)
os.Exit(1) // For entries at the FATAL level, we also terminate the application.
}
func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) {
if level < l.minLevel {
return 0, nil
}
aux := struct {
Level string `json:"level"`
Time string `json:"time"`
Message string `json:"message"`
Properties map[string]string `json:"properties,omitempty"`
Trace string `json:"trace,omitempty"`
}{
Level: level.String(),
Time: time.Now().UTC().Format(time.RFC3339),
Message: message,
Properties: properties,
}
if level >= LevelError {
aux.Trace = string(debug.Stack())
}
var line []byte
line, err := json.Marshal(aux)
if err != nil {
line = []byte(LevelError.String() + ": unable to marshal log message: " + err.Error())
}
l.mu.Lock()
defer l.mu.Unlock()
return l.out.Write(append(line, '\n'))
}
func (l *Logger) Write(message []byte) (n int, err error) {
return l.print(LevelError, string(message), nil)
}
It's some sort of logging system well explained by Alex Edwards in Let’s Go Further. As stated, we could have used logrus or any other popular logging system in Go.
Moving on, we tried to get our configurations from updateConfigWithEnvVariables
function. This function lives in cmd/api/config.go
:
// cmd/api/config.go
package main
import (
"encoding/hex"
"flag"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
)
func updateConfigWithEnvVariables() (*config, error) {
// Load environment variables from `.env` file
err := godotenv.Load(".env", ".env.development")
if err != nil {
log.Fatal("Error loading .env file")
}
maxOpenConnsStr := os.Getenv("DB_MAX_OPEN_CONNS")
maxOpenConns, err := strconv.Atoi(maxOpenConnsStr)
if err != nil {
log.Fatal(err)
}
maxIdleConnsStr := os.Getenv("DB_MAX_IDLE_CONNS")
maxIdleConns, err := strconv.Atoi(maxIdleConnsStr)
if err != nil {
log.Fatal(err)
}
var cfg config
// Basic config
flag.IntVar(&cfg.port, "port", 8080, "API server port")
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
// Database config
flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("DATABASE_URL"), "PostgreSQL DSN")
flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", maxOpenConns, "PostgreSQL max open connections")
flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", maxIdleConns, "PostgreSQL max idle connections")
flag.StringVar(&cfg.db.maxIdleTime,
"db-max-idle-time",
os.Getenv("DB_MAX_IDLE_TIME"),
"PostgreSQL max connection idle time",
)
// Redis config
flag.StringVar(&cfg.redisURL, "redis-url", os.Getenv("REDIS_URL"), "Redis URL")
flag.Parse()
return &cfg, nil
}
In the function, we loaded our .env
and .env.development
files using godotenv which was installed using:
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/joho/godotenv
Having loaded it, we retrieved some of the variables in the files. Since we want our application to be robust enough, we provided an option where you can pass most of the variables as command-line arguments using the flag
package. This means that instead of providing DATABASE_URL
in .env
file, you can pass it like this:
~/Documents/Projects/web/go-auth/go-auth-backend$ go run ./cmd/api/ -db-dsn=<url>
Pretty neat! We used the flag
package to mutate
the cfg
variable we created which was later returned if there was no error with parsing the arguments and providing defaults.
Back to the main()
, we then opened our database connection using openDB
, a function located in cmd/api/db.go
:
// cmd/api/db.go
package main
import (
"context"
"database/sql"
"time"
_ "github.com/lib/pq"
)
func openDB(cfg config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.db.dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.db.maxOpenConns)
db.SetMaxIdleConns(cfg.db.maxIdleConns)
duration, err := time.ParseDuration(cfg.db.maxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = db.PingContext(ctx)
if err != nil {
return nil, err
}
return db, nil
}
Using our PostgreSQL driver and the built-in database/sql
, installed with go get github.com/lib/pq
, we opened connections to a "postgres"
database and set some parameters including the number of maximum overall connections, idle connections and the duration for idleness. Pretty basic!
Next, in main()
, we deferred the closure of the connection until our app closes. This is important. Then, using the recommended redis client for go, we parsed our redis URL, set in .env
. A successful parse returns some options which were then used to initialize a redis client.
These important app-wide variables and connections were then used to initialize our application
. Doing it this way ensures that they are accessible to all functions bound to the application
type.
With the initializations done, we needed to start our web server and a method, serve
, bound to the application
type helps us achieve this. The method looks like this:
// cmd/api/server.go
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func (app *application) serve() error {
// Declare a HTTP server using the same settings as in our main() function.
srv := &http.Server{
Addr: fmt.Sprintf(":%d", app.config.port),
Handler: app.routes(),
IdleTimeout: time.Minute,
ErrorLog: log.New(app.logger, "", 0),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
shutdownError := make(chan error)
// Start a background goroutine.
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit
app.logger.PrintInfo("shutting down server", map[string]string{
"signal": s.String(),
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
shutdownError <- err
}
app.logger.PrintInfo("completing background tasks", map[string]string{
"addr": srv.Addr})
app.wg.Wait()
shutdownError <- nil
}()
app.logger.PrintInfo("starting server", map[string]string{
"addr": srv.Addr,
"env": app.config.env,
})
err := srv.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
return err
}
err = <-shutdownError
if err != nil {
return err
}
app.logger.PrintInfo("stopped server", map[string]string{
"addr": srv.Addr,
})
return nil
}
I bet you were bewildered by that code. Is that what you always need to JUST start a Go web server? Definitely not! To start a server in Go, you just need something like this:
func (app *application) serve() error {
// Declare a HTTP server using the same settings as in our main() function.
srv := &http.Server{
Addr: fmt.Sprintf(":%d", app.config.port),
Handler: app.routes(),
IdleTimeout: time.Minute,
ErrorLog: log.New(app.logger, "", 0),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
err := srv.ListenAndServe()
if err != nil {
return err
}
}
and you are OKAY! However, we want to build a ROBUST system that won't just shut down at every CTRL + C
without finishing up pending requests and background tasks. The bulk of the code in serve()
was for the GRACEFUL shutdown of our application and, again, Alex Edwards delved into it well in Let’s Go Further.
Notice that serve()
implemented the application
type. With that implementation, serve()
can be called anywhere an application instance is available. Hence the reason it was called via app.serve()
in the main()
function. This is one way of implementing polymorphism in Go.
Step 3: Healthcheck route
Our server will refuse to start currently because we fed into its route instance that is non-existent!
..
srv := &http.Server{
Addr: fmt.Sprintf(":%d", app.config.port),
Handler: app.routes(), // <- Here!
IdleTimeout: time.Minute,
ErrorLog: log.New(app.logger, "", 0),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
...
Let's fix that by creating a new file, routes.go
, in cmd/api
where all our application's routes will live!
// cmd/api/routes.go
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/healthcheck/", app.healthcheckHandler)
return app.recoverPanic(router)
}
This is another "method" that implements the application
interface. It initialized an httprouter
— remember to install it via go get github.com/julienschmidt/httprouter
— and using its HandlerFunc
function, we fed the expected HTTP method, GET
in this case. The resource path, "/healthcheck/"
and the handler were also supplied. Let's check the hanler out:
// cmd/api/
package main
import (
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"status": "available",
"environment": app.config.env,
"version": version,
}
err := app.writeJSON(w, http.StatusOK, data, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
Go handlers take http.ResponseWriter
and http.Request
as arguments and using them, we can perform anything we want! In this case, we returned, in JSON, some basic information about our app using writeJSON
which was written in cmd/api/helpers.go
:
// cmd/api/helpers.go
package main
import (
"encoding/json"
"net/http"
)
func (app *application) writeJSON(w http.ResponseWriter, status int, data interface{}, headers http.Header) error {
js, err := json.Marshal(data)
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
writeJSON
is just a basic method that helps convert, using Go's JSON encoder, any type to JSON. It also helps to set appropriate headers and status using the supplied values in the handlers that call it.
In healthcheckHandler
, we also returned a server error in case there was an error "serializing" the data. This error is defined in cmd/api/errors.go
:
// cmd/api/errors.go
package main
import (
"fmt"
"net/http"
)
type envelope map[string]interface{}
func (app *application) logError(r *http.Request, err error) {
app.logger.PrintError(err, map[string]string{
"request_method": r.Method,
"request_url": r.URL.String(),
})
}
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) {
env := envelope{"error": message}
err := app.writeJSON(w, status, env, nil)
if err != nil {
app.logError(r, err)
w.WriteHeader(500)
}
}
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
app.logError(r, err)
message := "the server encountered a problem and could not process your request"
app.errorResponse(w, r, http.StatusInternalServerError, message)
}
serverErrorResponse
uses errorResponse
to tell the user about the unexpected issue with our app. Both of them used logError
to log the error to our logging console so that we can easily debug it. By now, you should see how beautiful it is with the "polymorphism" we have implemented! We can just use a method anywhere without breaking a sweat!
Back to routes.go
, we did not just return router
but app.recoverPanic(router)
. What is recoverPanic
? You asked. It's a middleware that does what its name suggests: gracefully recover from panic! We don't want to shut out our users without letting them know there was an issue.
// cmd/api/middleware.go
package main
import (
"fmt"
"net/http"
)
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
That's the middleware. It informs users that something went wrong before closing the connection. That's better if you ask me. With that, you can run the application with:
~/Documents/Projects/web/go-auth/go-auth-backend$ go run ./cmd/api/
Ensure you put the necessary environment variables in .env
and/or .env.development
.
You can visit http://127.0.0.1:8080/healthcheck/
now if your port is 8080
or http://127.0.0.1:<port>/healthcheck/
. Congratulations on laying a solid foundation for something great!
We will wrap up this article at this point. In the next one, we'll go into implementing our endpoints properly. See you!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)