DEV Community

Cover image for Authentication system using Golang and Sveltekit - User registration
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

Authentication system using Golang and Sveltekit - User registration

Introduction

With the basic setup laid bare, it's time to build a truly useful API service for our authentication system. In this article, we will delve into user registration, storage in the database, password hashing using argon2id, sending templated emails, and generating truly random and secure tokens, among others. Let's get on!

Source code

The source code for this series is hosted on GitHub via:

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 the user's database schema

We need a database table to store our application's users' data. To generate and migrate a schema, we'll use golang migrate. Kindly follow these instructions to install it on your Operating system. To create a pair of migration files (up and down) for our user table, issue the following command in your terminal and at the root of your project:

~/Documents/Projects/web/go-auth/go-auth-backend$ migrate create -seq -ext=.sql -dir=./migrations create_users_table
Enter fullscreen mode Exit fullscreen mode

-seq instructs the CLI to use sequential numbering as against the default, which is the Unix timestamp. We opted to use .sql file extensions for the generated files by passing -ext. The generated files will live in the migrations folder we created in the previous article and -dir allows us to specify that. Lastly, we fed it with the real name of the files we want to create. You should see two files in the migrations folder by name. Kindly open the up and fill in the following schema:

-- migrations/000001_create_users_table.up.sql
-- Add up migration script here
-- User table
CREATE TABLE IF NOT EXISTS users(
    id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    is_active BOOLEAN DEFAULT FALSE,
    is_staff BOOLEAN DEFAULT FALSE,
    is_superuser BOOLEAN DEFAULT FALSE,
    thumbnail TEXT NULL,
    date_joined TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS users_id_email_is_active_indx ON users (id, email, is_active);
-- Create a domain for phone data type
CREATE DOMAIN phone AS TEXT CHECK(
    octet_length(VALUE) BETWEEN 1
    /*+*/
    + 8 AND 1
    /*+*/
    + 15 + 3
    AND VALUE ~ '^\+\d+$'
);
-- User details table (One-to-one relationship)
CREATE TABLE user_profile (
    id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL UNIQUE,
    phone_number phone NULL,
    birth_date DATE NULL,
    github_link TEXT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS users_detail_id_user_id ON user_profile (id, user_id);
Enter fullscreen mode Exit fullscreen mode

In the down file, we should have:

-- migrations/000001_create_users_table.down.sql
-- Add down migration script here
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS user_profile;
Enter fullscreen mode Exit fullscreen mode

We have been using these schemas right from when we started the authentication series.

Next, we need to execute the files so that those tables will be really created in our database:

migrate -path=./migrations -database=<DATABASE_URL> up
Enter fullscreen mode Exit fullscreen mode

Ensure you replace <DATABASE_URL> with your real database URL. If everything goes well, your table should now be created in your database.

It should be noted that instead of manually migrating the database, we could do that automatically, at start-up, in the main() function.

Step 2: Setting up our user model

To abstract away interacting with the database, we will create some sort of model, an equivalent of Django's model. But before then, let's create a type for our users in internal/data/user_types.go (create the file as it doesn't exist yet):

// internal/data/user_types.go
package data

import (
    "database/sql"
    "errors"
    "time"

    "github.com/google/uuid"
    "goauthbackend.johnowolabiidogun.dev/internal/types"
)

type UserProfile struct {
    ID          *uuid.UUID     `json:"id"`
    UserID      *uuid.UUID     `json:"user_id"`
    PhoneNumber *string        `json:"phone_number"`
    BirthDate   types.NullTime `json:"birth_date"`
    GithubLink  *string        `json:"github_link"`
}

type User struct {
    ID          uuid.UUID   `json:"id"`
    Email       string      `json:"email"`
    Password    password    `json:"-"`
    FirstName   string      `json:"first_name"`
    LastName    string      `json:"last_name"`
    IsActive    bool        `json:"is_active"`
    IsStaff     bool        `json:"is_staff"`
    IsSuperuser bool        `json:"is_superuser"`
    Thumbnail   *string     `json:"thumbnail"`
    DateJoined  time.Time   `json:"date_joined"`
    Profile     UserProfile `json:"profile"`
}

type password struct {
    plaintext *string
    hash      string
}

type UserModel struct {
    DB *sql.DB
}

type UserID struct {
    Id uuid.UUID
}

var (
    ErrDuplicateEmail = errors.New("duplicate email")
)
Enter fullscreen mode Exit fullscreen mode

These are just the basic types we'll be working on within this system. You will notice that there are three columns: names of the fields, field types, and the "renames" of the fields in JSON. The last column is very useful because, in Go, field names MUST start with capital letters for them to be accessible outside their package. The same goes to type names. Therefore, we need a way to properly send field names to requesting users and Go helps with that using the built-in encoding/json package. Notice also that our Password field was renamed to -. This omits that field entirely from the JSON responses it generates. How cool is that! We also defined a custom password type. This makes it easier to generate the hash of our users' passwords.

Then, there is this not-so-familiar types.NullTime in the UserProfile type. It was defined in internal/types/time.go:

// internal/types/time.go
package types

import (
    "fmt"
    "reflect"
    "strings"
    "time"

    "github.com/lib/pq"
)

// NullTime is an alias for pq.NullTime data type
type NullTime pq.NullTime

// Scan implements the Scanner interface for NullTime
func (nt *NullTime) Scan(value interface{}) error {
    var t pq.NullTime
    if err := t.Scan(value); err != nil {
        return err
    }

    // if nil then make Valid false
    if reflect.TypeOf(value) == nil {
        *nt = NullTime{t.Time, false}
    } else {
        *nt = NullTime{t.Time, true}
    }

    return nil
}

// MarshalJSON for NullTime
func (nt *NullTime) MarshalJSON() ([]byte, error) {
    if !nt.Valid {
        return []byte("null"), nil
    }
    val := fmt.Sprintf("\"%s\"", nt.Time.Format(time.RFC3339))
    return []byte(val), nil
}

const dateFormat = "2006-01-02"

// UnmarshalJSON for NullTime
func (nt *NullTime) UnmarshalJSON(b []byte) error {
    t, err := time.Parse(dateFormat, strings.Replace(
        string(b),
        "\"",
        "",
        -1,
    ))

    if err != nil {
        return err
    }

    nt.Time = t
    nt.Valid = true

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The reason for this is the difficulty encountered while working with possible null values for users' birthdates. This article explains it quite well and the code above was some modification of the code there.

It should be noted that to use UUID in Go, you need an external package (we used github.com/google/uuid in our case, so install it with go get github.com/google/uuid).

Next is handling password hashing:

// internal/data/user_password.go
package data

import (
    "log"

    "github.com/alexedwards/argon2id"
)

func (p *password) Set(plaintextPassword string) error {
    hash, err := argon2id.CreateHash(plaintextPassword, argon2id.DefaultParams)
    if err != nil {
        return err
    }
    p.plaintext = &plaintextPassword
    p.hash = hash
    return nil
}

func (p *password) Matches(plaintextPassword string) (bool, error) {
    match, err := argon2id.ComparePasswordAndHash(plaintextPassword, p.hash)
    if err != nil {
        log.Fatal(err)
    }

    return match, nil
}
Enter fullscreen mode Exit fullscreen mode

We used github.com/alexedwards/argon2id package to assist in hashing and matching our users' passwords. It's Go's implementation of argon2id. The Set "method" does the hashing when a user registers whereas Matches confirms it when such a user wants to log in.

To validate users' inputs, a very good thing to do, we have:

// internal/data/user_validation.go
package data

import "goauthbackend.johnowolabiidogun.dev/internal/validator"

func ValidateEmail(v *validator.Validator, email string) {
    v.Check(email != "", "email", "email must be provided")
    v.Check(validator.Matches(email, validator.EmailRX), "email", "email must be a valid email address")
}

func ValidatePasswordPlaintext(v *validator.Validator, password string) {
    v.Check(password != "", "password", "password must be provided")
    v.Check(len(password) >= 8, "password", "password must be at least 8 bytes long")
    v.Check(len(password) <= 72, "password", "password must not be more than 72 bytes long")
}

func ValidateUser(v *validator.Validator, user *User) {
    v.Check(user.FirstName != "", "first_name", "first name must be provided")
    v.Check(user.LastName != "", "last_name", "last name must be provided")

    ValidateEmail(v, user.Email)
    // If the plaintext password is not nil, call the standalone // ValidatePasswordPlaintext() helper.
    if user.Password.plaintext != nil {
        ValidatePasswordPlaintext(v, *user.Password.plaintext)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code uses another custom package to validate email, password, first name, and last name — the data required during registration. The custom package looks like this:

// internal/validator/validator.go
package validator

import "regexp"

var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

type Validator struct {
    Errors map[string]string
}

// New is a helper which creates a new Validator instance with an empty errors map.
func New() *Validator {
    return &Validator{Errors: make(map[string]string)}
}

// Valid returns true if the errors map doesn't contain any entries.
func (v *Validator) Valid() bool {
    return len(v.Errors) == 0
}

// AddError adds an error message to the map (so long as no entry already exists for // the given key).
func (v *Validator) AddError(key, message string) {
    if _, exists := v.Errors[key]; !exists {
        v.Errors[key] = message
    }
}

// Check adds an error message to the map only if a validation check is not 'ok'.
func (v *Validator) Check(ok bool, key, message string) {
    if !ok {
        v.AddError(key, message)
    }
}

// In returns true if a specific value is in a list of strings.
func In(value string, list ...string) bool {
    for i := range list {
        if value == list[i] {
            return true
        }
    }
    return false
}

// Matches returns true if a string value matches a specific regexp pattern.
func Matches(value string, rx *regexp.Regexp) bool {
    return rx.MatchString(value)
}

// Unique returns true if all string values in a slice are unique.
func Unique(values []string) bool {
    uniqueValues := make(map[string]bool)
    for _, value := range values {
        uniqueValues[value] = true
    }
    return len(values) == len(uniqueValues)
}
Enter fullscreen mode Exit fullscreen mode

Pretty easy to reason along with.

It's finally time to create the model:

// internal/data/models.go
package data

import (
    "database/sql"
    "errors"
)

var (
    ErrRecordNotFound = errors.New("a user with these details was not found")
)

type Models struct {
    Users UserModel
}

func NewModels(db *sql.DB) Models {
    return Models{
        Users: UserModel{DB: db},
    }
}
Enter fullscreen mode Exit fullscreen mode

With this, if we have another model, all we need to do is register it in Models and initialize it in NewModels.

Now, we need to make this model accessible to our application. To do this, add models to our application type in main.go and initialize it inside the main() function:

// cmd/api/main.go
...

import (
    ...
    "goauthbackend.johnowolabiidogun.dev/internal/data"
    ...
)
type application struct {
    ..
    models      data.Models
    ...
}

func main() {
    ...
    app := &application{
        ...
        models:      data.NewModels(db),
        ...
    }
    ...
}
...
Enter fullscreen mode Exit fullscreen mode

That makes the models available to all route handlers and functions that implement the application type.

Step 3: User registration route handler

Let's put housekeeping to good use. Create a new file, register.go, in cmd/api and make it look like this:

// cmd/api/register.go
package main

import (
    "errors"
    "net/http"
    "time"

    "goauthbackend.johnowolabiidogun.dev/internal/data"
    "goauthbackend.johnowolabiidogun.dev/internal/tokens"
    "goauthbackend.johnowolabiidogun.dev/internal/validator"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    // Expected data from the user
    var input struct {
        Email     string `json:"email"`
        FirstName string `json:"first_name"`
        LastName  string `json:"last_name"`
        Password  string `json:"password"`
    }
    // Try reading the user input to JSON
    err := app.readJSON(w, r, &input)
    if err != nil {

        app.badRequestResponse(w, r, err)
        return
    }

    user := &data.User{
        Email:     input.Email,
        FirstName: input.FirstName,
        LastName:  input.LastName,
    }

    // Hash user password
    err = user.Password.Set(input.Password)
    if err != nil {

        app.serverErrorResponse(w, r, err)
        return
    }

    // Validate the user input
    v := validator.New()
    if data.ValidateUser(v, user); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Save the user in the database
    userID, err := app.models.Users.Insert(user)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrDuplicateEmail):
            v.AddError("email", "A user with this email address already exists")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Generate 6-digit token
    otp, err := tokens.GenerateOTP()
    if err != nil {
        app.logError(r, err)
    }

    err = app.storeInRedis("activation_", otp.Hash, userID.Id, app.config.tokenExpiration.duration)
    if err != nil {
        app.logError(r, err)
    }

    now := time.Now()
    expiration := now.Add(app.config.tokenExpiration.duration)
    exact := expiration.Format(time.RFC1123)

    // Send email to user, using separate goroutine, for account activation
    app.background(func() {
        data := map[string]interface{}{
            "token":       tokens.FormatOTP(otp.Secret),
            "userID":      userID.Id,
            "frontendURL": app.config.frontendURL,
            "expiration":  app.config.tokenExpiration.durationString,
            "exact":       exact,
        }
        err = app.mailer.Send(user.Email, "user_welcome.tmpl", data)
        if err != nil {
            app.logError(r, err)
        }
        app.logger.PrintInfo("Email successfully sent.", nil, app.config.debug)
    })

    // Respond with success
    app.successResponse(
        w,
        r,
        http.StatusAccepted,
        "Your account creation was accepted successfully. Check your email address and follow the instruction to activate your account. Ensure you activate your account before the token expires",
    )
}
Enter fullscreen mode Exit fullscreen mode

Though a bit long, reading through the lines gives you the whole idea! We expect four (4) fields from the user. After converting them to proper JSON using readJSON, a method created previously, we initialized the User type, set hash the supplied password and then validate the user-supplied data. If everything is good, we used Insert, a method on the User type that lives in internal/data/user_queries.go, to save the user in the database. The method is simple:

// internal/data/user_queries.go
package data

import (
    "context"
    "database/sql"
    "errors"
    "log"
    "time"

    "github.com/google/uuid"
)

func (um UserModel) Insert(user *User) (*UserID, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    tx, err := um.DB.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }

    var userID uuid.UUID

    query_user := `
    INSERT INTO users (email, password, first_name, last_name) VALUES ($1, $2, $3, $4) RETURNING id`
    args_user := []interface{}{user.Email, user.Password.hash, user.FirstName, user.LastName}

    if err := tx.QueryRowContext(ctx, query_user, args_user...).Scan(&userID); err != nil {
        switch {
        case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
            return nil, ErrDuplicateEmail
        default:
            return nil, err
        }

    }

    query_user_profile := `
    INSERT INTO user_profile (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING RETURNING user_id`

    _, err = tx.ExecContext(ctx, query_user_profile, userID)

    if err != nil {
        return nil, err
    }

    if err = tx.Commit(); err != nil {
        return nil, err
    }
    id := UserID{
        Id: userID,
    }

    return &id, nil
}
Enter fullscreen mode Exit fullscreen mode

We used Go's database transaction to execute our SQL queries. We also provided 3 seconds timeout for our database to finish up or get timed out! If the insertion query is successful, the user's ID is returned.

Next, we generated a token for the new user. The token is a random and cryptographically secure 6-digit number which then gets encoded using the sha252 algorithm. The entire logic is:

// internal/tokens/utils.go
package tokens

import (
    "crypto/rand"
    "crypto/sha256"
    "fmt"
    "math/big"

    "strings"

    "goauthbackend.johnowolabiidogun.dev/internal/validator"
)

type Token struct {
    Secret string
    Hash   string
}

func GenerateOTP() (*Token, error) {
    bigInt, err := rand.Int(rand.Reader, big.NewInt(900000))
    if err != nil {
        return nil, err
    }
    sixDigitNum := bigInt.Int64() + 100000

    // Convert the integer to a string and get the first 6 characters
    sixDigitStr := fmt.Sprintf("%06d", sixDigitNum)

    token := Token{
        Secret: sixDigitStr,
    }

    hash := sha256.Sum256([]byte(token.Secret))

    token.Hash = fmt.Sprintf("%x\n", hash)

    return &token, nil
}

func FormatOTP(s string) string {
    length := len(s)
    half := length / 2
    firstHalf := s[:half]
    secondHalf := s[half:]
    words := []string{firstHalf, secondHalf}
    return strings.Join(words, " ")
}

func ValidateSecret(v *validator.Validator, secret string) {
    v.Check(secret != "", "token", "must be provided")
    v.Check(len(secret) == 6, "token", "must be 6 bytes long")
}
Enter fullscreen mode Exit fullscreen mode

After the token generation, we temporarily store the token hash in redis using the storeInRedis method and then send an email, in the background using a different goroutine, to the user with instructions on how to activate their accounts. The functions used are located in cmd/api/helpers.go:

// cmd/api/helpers.go

...
func (app *application) storeInRedis(prefix string, hash string, userID uuid.UUID, expiration time.Duration) error {
    ctx := context.Background()
    err := app.redisClient.Set(
        ctx,
        fmt.Sprintf("%s%s", prefix, userID),
        hash,
        expiration,
    ).Err()
    if err != nil {
        return err
    }

    return nil
}

func (app *application) background(fn func()) {
    app.wg.Add(1)

    go func() {

        defer app.wg.Done()
        // Recover any panic.
        defer func() {
            if err := recover(); err != nil {
                app.logger.PrintError(fmt.Errorf("%s", err), nil, app.config.debug)
            }
        }()
        // Execute the arbitrary function that we passed as the parameter.
        fn()
    }()
}
Enter fullscreen mode Exit fullscreen mode

The tokens expire and get deleted from redis after TOKEN_EXPIRATION has elapsed.

I think we should stop here as this article is getting pretty long. In the next one, we will implement missing methods, configure our app for email sending and implement activating users' accounts handler. Enjoy!

Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

Top comments (1)

Collapse
 
sirneij profile image
John Owolabi Idogun

Wow! This is a pretty awesome Code Review.

I did use quite some Java/C++. So I got sold to that. However, I will definitely keep your review and ensure that my subsequent Go projects are idiomatic enough.

As for the "data" semantic, this system was part of a bigger project with many data models. I only extracted the user part but forgot to rename it to something more intuitive.

Thank you so much for this feedback. I honestly await another in my subsequent articles.