DEV Community

Cover image for Authentication system using Golang and Sveltekit - Initialization and setup
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

Authentication system using Golang and Sveltekit - Initialization and setup

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:

GitHub logo Sirneij / go-auth

A fullstack session-based authentication system using golang and sveltekit

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

By now, we should have a structure like this:

.
├── Makefile
├── bin
├── cmd
│   └── api
│       └── main.go
├── go.mod
├── go.sum
├── internal
├── migrations
└── remote
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
...
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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)