DEV Community

Cover image for Authentication system using Golang and Sveltekit - Login and Logout
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Golang and Sveltekit - Login and Logout

Introduction

Having seen the beauty we made so far, let's add more features so that registered and activated users can log in and out of our system while also being about to access some user-only content.

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: User cookies

Since we are building a session-based authentication system, we need to encrypt non-sensitive user data in cookies. These cookies will then be sent to the users' browsers so that users won't always need to provide login every time to access some private resources. In our case, we will also save the encrypted cookie in redis to double-check incoming requests. Our system's cookies will have max-age whose value can be changed using an environment variable. For encryption, we will use some encoded secrets, whose value can also be changed using an environment variable.

Although there are pretty good session managers in the Go ecosystem such as alexedwards/scs, golangcollege/session and gorilla/sessions, we won't use any but using this great guide, we'll write our own. This is to keep our project's dependence on external packages at the barest minimum.

The entire code for the cookie encryption and decryption is located in internal/cookies/cookies.go:

// internal/cookies/cookies.go
package cookies

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"
)

var (
    ErrValueTooLong = errors.New("cookie value too long")
    ErrInvalidValue = errors.New("invalid cookie value")
)

func Write(w http.ResponseWriter, cookie http.Cookie) error {
    cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value))

    if len(cookie.String()) > 4096 {
        return ErrValueTooLong
    }

    http.SetCookie(w, &cookie)

    return nil
}

func Read(r *http.Request, name string) (string, error) {
    cookie, err := r.Cookie(name)
    if err != nil {
        return "", err
    }

    value, err := base64.URLEncoding.DecodeString(cookie.Value)
    if err != nil {
        return "", ErrInvalidValue
    }

    return string(value), nil
    }

func WriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return err
    }

    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return err
    }

    nonce := make([]byte, aesGCM.NonceSize())
    _, err = io.ReadFull(rand.Reader, nonce)
    if err != nil {
        return err
    }

    plaintext := fmt.Sprintf("%s:%s", cookie.Name, cookie.Value)

    encryptedValue := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)

    cookie.Value = string(encryptedValue)

    return Write(w, cookie)
}

func ReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) {
    encryptedValue, err := Read(r, name)
    if err != nil {
        return "", err
    }

    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return "", err
    }

    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }

    nonceSize := aesGCM.NonceSize()

    if len(encryptedValue) < nonceSize {
        return "", ErrInvalidValue
    }

    nonce := encryptedValue[:nonceSize]
    ciphertext := encryptedValue[nonceSize:]

    plaintext, err := aesGCM.Open(nil, []byte(nonce), []byte(ciphertext), nil)
    if err != nil {
        return "", ErrInvalidValue
    }

    expectedName, value, ok := strings.Cut(string(plaintext), ":")
    if !ok {
        return "", ErrInvalidValue
    }

    if expectedName != name {
        return "", ErrInvalidValue
    }

    return value, nil
}
Enter fullscreen mode Exit fullscreen mode

Reading through the code with this guide at your side, you will definitely not be lost.

The only data we will encrypt in the cookies is the UserID type. We need to register this in the cmd/api/main.go file. Also, we will use this opportunity to add some data to our config type:

// cmd/api/main.go
...
type config struct {
    ...
    tokenExpiration struct {
        durationString string
        duration       time.Duration
    }
    secret struct {
        HMC               string
        secretKey         []byte
        sessionExpiration time.Duration
    }
    ...
}
...
func main() {
    gob.Register(&data.UserID{})
    ...
}
...
Enter fullscreen mode Exit fullscreen mode

We also need to update cmd/api/config.go:

// cmd/api/config.go
...
func updateConfigWithEnvVariables() (*config, error) {
    ...
    // Secret
    flag.StringVar(&cfg.secret.HMC, "secret-key", os.Getenv("HMC_SECRET_KEY"), "HMC Secret Key")
    ...
    secretKey, err := hex.DecodeString(cfg.secret.HMC)
    if err != nil {
        return nil, err
    }
    cfg.secret.secretKey = secretKey
    sessionDuration, err := time.ParseDuration(os.Getenv("SESSION_EXPIRATION"))
    if err != nil {
        return nil, err
    }
    cfg.secret.sessionExpiration = sessionDuration

    // Token Expiration
    tokexpirationStr := os.Getenv("TOKEN_EXPIRATION")
    duration, err := time.ParseDuration(tokexpirationStr)
    if err != nil {
        return nil, err
    }
    cfg.tokenExpiration.durationString = tokexpirationStr
    cfg.tokenExpiration.duration = duration
    ...
}
...
Enter fullscreen mode Exit fullscreen mode

With that, we can now create a login handler.

Step 2: User login

Let's open cmd/api/login.go and fill it with:

// cmd/api/login.go
package main

import (
    "bytes"
    "encoding/gob"
    "errors"
    "net/http"

    "goauthbackend.johnowolabiidogun.dev/internal/cookies"
    "goauthbackend.johnowolabiidogun.dev/internal/data"
)

func (app *application) loginUserHandler(w http.ResponseWriter, r *http.Request) {
    // Expected data from the user
    var input struct {
        Email    string `json:"email"`
        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
    }

    db_user, err := app.models.Users.GetEmail(input.Email, true)
    if err != nil {

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

    match, err := db_user.Password.Matches(input.Password)
    if err != nil {

        return
    }

    if !match {
        app.badRequestResponse(w, r, errors.New("email and password combination does not match"))
        return
    }

    var userID = data.UserID{
        Id: db_user.ID,
    }

    var buf bytes.Buffer

    // Gob-encode the user data, storing the encoded output in the buffer.
    err = gob.NewEncoder(&buf).Encode(&userID)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened encoding your data"))
        return
    }

    session := buf.String()

    // Store session in redis
    err = app.storeInRedis("sessionid_", session, userID.Id, app.config.secret.sessionExpiration)
    if err != nil {
        app.logError(r, err)
    }

    cookie := http.Cookie{
        Name:     "sessionid",
        Value:    session,
        Path:     "/",
        MaxAge:   int(app.config.secret.sessionExpiration.Seconds()),
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write an encrypted cookie containing the gob-encoded data as normal.
    err = cookies.WriteEncrypted(w, cookie, app.config.secret.secretKey)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened setting your cookie data"))
        return
    }

    app.writeJSON(w, http.StatusOK, db_user, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "Logged in successfully")
}
Enter fullscreen mode Exit fullscreen mode

Most of the code should be pretty familiar by now. Only this section isn't:

...
    var userID = data.UserID{
        Id: db_user.ID,
    }

    var buf bytes.Buffer

    // Gob-encode the user data, storing the encoded output in the buffer.
    err = gob.NewEncoder(&buf).Encode(&userID)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened encoding your data"))
        return
    }

    session := buf.String()

    // Store session in redis
    err = app.storeInRedis("sessionid_", session, userID.Id, app.config.secret.sessionExpiration)
    if err != nil {
        app.logError(r, err)
    }

    cookie := http.Cookie{
        Name:     "sessionid",
        Value:    session,
        Path:     "/",
        MaxAge:   int(app.config.secret.sessionExpiration.Seconds()),
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write an encrypted cookie containing the gob-encoded data as normal.
    err = cookies.WriteEncrypted(w, cookie, app.config.secret.secretKey)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened setting your cookie data"))
        return
    }
...
Enter fullscreen mode Exit fullscreen mode

We are encoding the user's ID and storing it in redis, setting the cookie, and then encrypting it.

Step 3: User logout

Now to the logout handler:

// cmd/api/logout.go
package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

func (app *application) logoutUserHandler(w http.ResponseWriter, r *http.Request) {
    userID, status, err := app.extractParamsFromSession(r)
    if err != nil {
        switch *status {
        case http.StatusUnauthorized:
            app.unauthorizedResponse(w, r, err)

        case http.StatusBadRequest:
            app.badRequestResponse(w, r, errors.New("invalid cookie"))

        case http.StatusInternalServerError:
            app.serverErrorResponse(w, r, err)

        default:
            app.serverErrorResponse(w, r, errors.New("something happened and we could not fullfil your request at the moment"))
        }
        return
    }

    // Get session from redis
    _, err = app.getFromRedis(fmt.Sprintf("sessionid_%s", userID.Id))
    if err != nil {
        app.unauthorizedResponse(w, r, errors.New("you are not authorized to access this resource"))
        return
    }

    // Delete session from redis
    ctx := context.Background()
    _, err = app.redisClient.Del(ctx, fmt.Sprintf("sessionid_%s", userID.Id)).Result()
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened decosing your cookie data"))
        return
    }

    http.SetCookie(w, &http.Cookie{
        Name:    "sessionid",
        Value:   "",
        Expires: time.Now(),
    })

    // Respond with success
    app.successResponse(w, r, http.StatusOK, "You have successfully logged out")
}
Enter fullscreen mode Exit fullscreen mode

Every other thing should be familiar aside from the extractParamsFromSession black box:

// cmd/api/helpers.go
...
func (app *application) extractParamsFromSession(r *http.Request) (*data.UserID, *int, error) {
    gobEncodedValue, err := cookies.ReadEncrypted(r, "sessionid", app.config.secret.secretKey)

    if err != nil {
        var errorData error
        var status int
        switch {
        case errors.Is(err, http.ErrNoCookie):
            status = http.StatusUnauthorized
            errorData = errors.New("you are not authorized to access this resource")

        case errors.Is(err, cookies.ErrInvalidValue):
            app.logger.PrintError(err, nil, app.config.debug)
            status = http.StatusBadRequest
            errorData = errors.New("invalid cookie")

        default:
            status = http.StatusInternalServerError
            errorData = errors.New("something happened getting your cookie data")

        }
        return nil, &status, errorData
    }

    var userID data.UserID

    reader := strings.NewReader(gobEncodedValue)
    if err := gob.NewDecoder(reader).Decode(&userID); err != nil {
        status := http.StatusInternalServerError
        return nil, &status, errors.New("something happened decosing your cookie data")
    }

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

We are decrypting the sessionid provided by the user and extracting the user's ID. This ID is what we need to get and delete the token from redis. Appropriate errors are returned at every stage.

Step 4: Getting currently active user

If a user is logged in and has an authentic session token, we want to return such user's data without providing email and password every time. This handler does that:

package main

import (
    "errors"
    "fmt"
    "net/http"
)

func (app *application) currentUserHandler(w http.ResponseWriter, r *http.Request) {

    userID, status, err := app.extractParamsFromSession(r)
    if err != nil {
        switch *status {
        case http.StatusUnauthorized:
            app.unauthorizedResponse(w, r, err)

        case http.StatusBadRequest:
            app.badRequestResponse(w, r, errors.New("invalid cookie"))

        case http.StatusInternalServerError:
            app.serverErrorResponse(w, r, err)

        default:
            app.serverErrorResponse(
                w,
                r,
                errors.New("something happened and we could not fullfil your request at the moment"),
            )
        }
        return
    }

    // Get session from redis
    _, err = app.getFromRedis(fmt.Sprintf("sessionid_%s", userID.Id))
    if err != nil {
        app.unauthorizedResponse(w, r, errors.New("you are not authorized to access this resource"))
        return
    }

    db_user, err := app.models.Users.Get(userID.Id)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusOK, db_user, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "User was retrieved successfully")
}
Enter fullscreen mode Exit fullscreen mode

Almost the same as the logout route aside from the fact that we ain't deleting the token and we used a method to return the user from the database:

func (um UserModel) Get(id uuid.UUID) (*User, error) {
    query := `
    SELECT 
        u.*, p.* 
    FROM 
        users u 
        LEFT JOIN user_profile p ON p.user_id = u.id 
    WHERE 
        u.is_active = true AND u.id = $1
    `
    var user User
    var userP UserProfile
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    err := um.DB.QueryRowContext(ctx, query, id).Scan(&user.ID,
        &user.Email, &user.Password.hash, &user.FirstName, &user.LastName, &user.IsActive, &user.IsStaff, &user.IsSuperuser, &user.Thumbnail, &user.DateJoined, &userP.ID, &userP.UserID, &userP.PhoneNumber, &userP.BirthDate, &userP.GithubLink,
    )
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }
    user.Profile = userP
    return &user, nil
}

func (um UserModel) GetEmail(email string, active bool) (*User, error) {
    query := `
    SELECT 
        u.*, p.*
    FROM 
        users u 
        JOIN user_profile p ON p.user_id = u.id 
    WHERE 
        u.is_active = $2 AND u.email = $1`

    var user User
    var userP UserProfile

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    err := um.DB.QueryRowContext(ctx, query, email, active).Scan(
        &user.ID,
        &user.Email,
        &user.Password.hash,
        &user.FirstName,
        &user.LastName,
        &user.IsActive,
        &user.IsStaff,
        &user.IsSuperuser,
        &user.Thumbnail,
        &user.DateJoined,
        &userP.ID,
        &userP.UserID,
        &userP.PhoneNumber,
        &userP.BirthDate,
        &userP.GithubLink,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            if active {
                return nil, ErrRecordNotFound
            } else {
                return nil, errors.New("an inactive user with the provided email address was not found")
            }
        default:
            return nil, err
        }
    }

    user.Profile = userP

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

The methods are simple to reason about.

Any other methods and snippets omitted can be gotten from the project's GitHub repository.

Now, let's register these routes in the cmd/api/routes.go:

// cmd/api/routes.go
...
func (app *application) routes() http.Handler {
    ...
    router.HandlerFunc(http.MethodPost, "/users/login/", app.loginUserHandler)
    router.HandlerFunc(http.MethodPost, "/users/logout/", app.logoutUserHandler)
    router.HandlerFunc(http.MethodGet, "/users/current-user/", app.currentUserHandler)
    ...
}
Enter fullscreen mode Exit fullscreen mode

That's it for now, see you in the next one.

Outro

Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...

Top comments (0)