Authentication system using Golang and Sveltekit - Login and Logout
Tags: go, webdev, tutorial, programming
Published: 2023-06-04 19:58:55

## 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 Sirneij/go-auth %}

## 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][1], [golangcollege/session][2] and [gorilla/sessions][3], we won't use any but using [this great guide][4], 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`:

```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
}
```

Reading through the code with [this guide][4] 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:

```go
// 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{})
    ...
}
...
```

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

```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
    ...
}
...
```

With that, we can now create a login handler.

### Step 2: User login

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

```go
// 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")
}
```

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

```go
...
    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
    }
...
```

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:

```go
// 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")
}
```

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

```go
// 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
}
```

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:

```go
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")
}
```

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:

```go
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
}
```

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`:

```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)
    ...
}
```

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

## 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](https://www.linkedin.com/in/idogun-john-nelson/) and Twitter: [Twitter](https://twitter.com/Sirneij).

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

[1]: https://github.com/alexedwards/scs "SCS: HTTP Session Management for Go"
[2]: https://github.com/golangcollege/sessions "Sessions"
[3]: https://github.com/gorilla/sessions "Package gorilla/sessions provides cookie and filesystem sessions and infrastructure for custom session backends."
[4]: https://www.alexedwards.net/blog/working-with-cookies-in-go "A complete guide to working with Cookies in Go"