Authentication system using Golang and Sveltekit - Profile update and app metric
Tags: go, webdev, tutorial
Published: 2023-06-06 22:12:49

## Introduction

Users of our application might make mistakes while filling out the registration form. Or they might change their names. To not bore our users with not-so-important details at the registration stage, we omitted some fields such as `thumbnail`, `github_link`, `birth_date`, and `phone_number`. We need to provide an interface for our users to update these fields. For picture uploads, we will use AWS S3. Also, as developers, it is important for us to quickly know how and when to increase or reduce our application's infrastructure. We will use Golang's [`expvar`][5] and [`httpsnoop`][1] to provide such an interface.

## Source code

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

{% github Sirneij/go-auth %}

## Implementation

### Step 1: Setting up AWS S3 client

In order to use AWS S3 to handle our system's file uploads, we need to build some foundations. Using AWS in Golang, as in many other languages, requires installing its SDK. Parts of the SDK are general and required while others are need-based. Therefore, let's first install the core SDK and the config modules:

```shell
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2/config
```

Then the AWS service API client for S3:

```shell
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2/service/s3
```

> NOTE: Before you proceed, kindly [Get your AWS access keys][2]. It's needed for the next steps.

Now, let's add AWS configurations to our app's `config` type and API client to the `application` type. Then initialize the client:

```go
// cmd/api/main.go
import (
    ...
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/credentials"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    ...
)

type config struct {
    ...
    awsConfig struct {
        AccessKeyID     string
        AccessKeySecret string
        Region          string
        BucketName      string
        BaseURL         string
        s3_key_prefix   string
    }
}

type application struct {
    ...
    S3Client    *s3.Client
}

func main() {
    ...

    sdkConfig := aws.Config{
        Region: cfg.awsConfig.Region,
        Credentials: credentials.NewStaticCredentialsProvider(
            cfg.awsConfig.AccessKeyID, cfg.awsConfig.AccessKeySecret, "",
        ),
    }

    s3Client := s3.NewFromConfig(sdkConfig)

    ...

    app := &application{
        ...
        S3Client:    s3Client,
    }
    ...
}
```

The above additions added some details to the `config` type. Using them, we built an AWS SDK config instance which was later used to initialize an AWS S3 API client.

The application won't compile yet since we haven't really loaded these credentials in the `config`. We will do that in `cmd/api/config.go`:

```go
// cmd/api/config.go
...
func updateConfigWithEnvVariables() (*config, error) {
    ...
    // AWS configs
    flag.StringVar(&cfg.awsConfig.AccessKeyID, "aws-access-key", os.Getenv("AWS_ACCESS_KEY_ID"), "AWS Access KeyID")
    flag.StringVar(&cfg.awsConfig.AccessKeySecret, "aws-access-secret", os.Getenv("AWS_SECRET_ACCESS_KEY"), "AWS Access Secret")
    flag.StringVar(&cfg.awsConfig.Region, "aws-region", os.Getenv("AWS_REGION"), "AWS region")
    flag.StringVar(&cfg.awsConfig.BucketName, "aws-bucketname", os.Getenv("AWS_S3_BUCKET_NAME"), "AWS bucket name")
    flag.Parse()

    cfg.awsConfig.BaseURL = fmt.Sprintf(
        "https://%s.s3.%s.amazonaws.com",
        cfg.awsConfig.BucketName,
        cfg.awsConfig.Region,
    )

    cfg.awsConfig.s3_key_prefix = "media/go-auth/"
    ...
}
```

The details were loaded from our app's `.env` file. Also, we are hard-coding `s3_key_prefix` in this case but you can make it dynamic if you wish.

Now, we can use these configurations to upload and delete files.

### Step 2: Uploading and deleting files from AWS S3

Our design decision will be to have different endpoints to upload and delete files from S3. But before the endpoints, we'll abstract away the upload and delete logic. The logic will live in `cmd/api/s3_utils.go`:

```go
// cmd/api/s3_utils.go
package main

import (
    "context"
    "crypto/rand"
    "encoding/base32"
    "fmt"
    "net/http"
    "strings"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/aws/aws-sdk-go-v2/service/s3/types"
)

func (app *application) uploadFileToS3(r *http.Request) (*string, error) {
    file, handler, err := r.FormFile("thumbnail")
    if err != nil {
        app.logError(r, err)
        return nil, err
    }
    defer file.Close()

    b := make([]byte, 16)
    _, err = rand.Read(b)
    if err != nil {
        app.logError(r, err)
        return nil, err
    }

    // Encode bytes in base32 without the trailing ==
    s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)

    fileName := fmt.Sprintf("%s_%s", s, handler.Filename)
    key := fmt.Sprintf("%s%s", app.config.awsConfig.s3_key_prefix, fileName)

    _, err = app.S3Client.PutObject(context.Background(), &s3.PutObjectInput{
        Bucket: aws.String(app.config.awsConfig.BucketName),
        Key:    aws.String(key),
        Body:   file,
    })
    if err != nil {
        app.logError(r, err)
        return nil, err
    }

    s3_url := fmt.Sprintf("%s/%s", app.config.awsConfig.BaseURL, key)

    return &s3_url, nil
}

func (app *application) deleteFileFromS3(r *http.Request) (bool, error) {
    thumbnailURL := r.FormValue("thumbnail_url")
    var objectIds []types.ObjectIdentifier

    word := "media"
    substrings := strings.Split(thumbnailURL, word)
    key := fmt.Sprintf("%s%s", word, substrings[len(substrings)-1])

    objectIds = append(objectIds, types.ObjectIdentifier{Key: aws.String(key)})
    _, err := app.S3Client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{
        Bucket: aws.String(app.config.awsConfig.BucketName),
        Delete: &types.Delete{Objects: objectIds},
    })
    if err != nil {
        app.logError(r, err)
        return false, err
    }
    return true, nil
}
```

In `uploadFileToS3`, we used Go's `request.FormFile()` to retrieve the file coming from the request's `FormData` by name. This is a way to get uploaded files from `.FormFile()`. The method returns three items: the `file`, `handler` and `err`. The `file` holds the uploaded file itself while `handler` holds the file details such as name, size and so on. You can check [this article][3] for ways to handle `FormData` in Golang. Next, I don't want files with the same name to be overwritten so with each file, we prepend encoded randomly generated bytes to the filename. Therefore, the filenames have texts prepended to them. Then, we used AWS S3 API Client's `PutObject` to upload the file.

As for the `deleteFileFromS3`, we require that users supply their images' URLs. In our app, the URL will be automatically extracted from the users. Using the URL, we trimmed off its beginning until `media` is seen. For example, if an image URL is `https://bucket_name.s3.origin.amazonaws.com/media/go-auth/name_of_image.png`, after trimming, we will be left with `media/go-auth/name_of_image.png`. Natively, AWS S3 supports bulk deletion of objects, we supplied the shortened URL as the object's `objectId` and used `DeleteObjects` to delete it. That's it!

Now, the handlers will be dead simple as almost all the major things have been abstracted!

```go
// cmd/api/upload_image_s3.go
package main

import (
    "errors"
    "net/http"
)

func (app *application) uploadFileToS3Handler(w http.ResponseWriter, r *http.Request) {
    _, 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
    }

    s3URL, err := app.uploadFileToS3(r)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    env := envelope{"s3_url": s3URL}

    err = app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "Image uploaded successfully")
}
```

The upload handler should be very familiar. We only allowed authenticated users to upload files and after a successful process, returned the URL of the uploaded file.

```go
// cmd/api/delete_image_s3.go

package main

import (
    "errors"
    "net/http"
)

func (app *application) deleteFileOnS3Handler(w http.ResponseWriter, r *http.Request) {
    _, 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
    }

    _, err = app.deleteFileFromS3(r)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    app.successResponse(w, r, http.StatusNoContent, "Image deleted successfully.")
}
```

`deleteFileOnS3Handler` is almost the same aside from the fact that we didn't return the file's URL but a success message instead!

### Step 3: User profile update

Now the handler that updates users' data:

```go
// cmd/api/update_user.go

package main

import (
    "errors"
    "net/http"

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

func (app *application) updateUserHandler(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
    }

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

    var input struct {
        FirstName   *string        `json:"first_name"`
        LastName    *string        `json:"last_name"`
        Thumbnail   *string        `json:"thumbnail"`
        PhoneNumber *string        `json:"phone_number"`
        BirthDate   types.NullTime `json:"birth_date"`
        GithubLink  *string        `json:"github_link"`
    }

    err = app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    if input.FirstName != nil {
        db_user.FirstName = *input.FirstName
    }
    if input.LastName != nil {
        db_user.LastName = *input.LastName
    }
    if input.Thumbnail != nil {
        db_user.Thumbnail = input.Thumbnail
    }
    if input.PhoneNumber != nil {
        db_user.Profile.PhoneNumber = input.PhoneNumber
    }
    if input.BirthDate.Valid {
        db_user.Profile.BirthDate = input.BirthDate
    }
    if input.GithubLink != nil {
        db_user.Profile.GithubLink = input.GithubLink
    }

    v := validator.New()
    if data.ValidateUser(v, db_user); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    updated_user, err := app.models.Users.Update(db_user)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    err = app.writeJSON(w, http.StatusOK, updated_user, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "User updated successfully")
}
```

Since this handler will allow `PATCH` HTTP method, users are allowed to supply any field they want updated using the `Update` method on the UserModel:

```go
// internal/data/user_queries.go
...
func (um UserModel) Update(user *User) (*User, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    var userOut User
    var userPOut UserProfile

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

    query_user := `
    UPDATE 
        users 
    SET 
        first_name = COALESCE($1, first_name), 
        last_name = COALESCE($2, last_name), 
        thumbnail = COALESCE($3, thumbnail)
    WHERE 
        id = $4 AND is_active = true
    RETURNING id, email, password, first_name, last_name, is_active, is_staff, is_superuser, thumbnail, date_joined
    `
    args_user := []interface{}{user.FirstName, user.LastName, user.Thumbnail, user.ID}

    err = tx.QueryRowContext(ctx, query_user, args_user...).Scan(&userOut.ID,
        &userOut.Email, &userOut.Password.hash, &userOut.FirstName, &userOut.LastName, &userOut.IsActive, &userOut.IsStaff, &userOut.IsSuperuser, &userOut.Thumbnail, &userOut.DateJoined)

    if err != nil {
        log.Printf("User: %v", err)
        return nil, err
    }

    query_user_profile := `
    UPDATE 
        user_profile 
    SET 
        phone_number = NULLIF($1, ''), 
        birth_date = $2::timestamp::date, 
        github_link = NULLIF($3, '')
    WHERE 
        user_id = $4
    RETURNING id, user_id, phone_number, birth_date, github_link
    `

    args_profile_user := []interface{}{
        user.Profile.PhoneNumber,
        user.Profile.BirthDate.Time,
        user.Profile.GithubLink,
        user.ID,
    }

    err = tx.QueryRowContext(ctx, query_user_profile, args_profile_user...).Scan(&userPOut.ID, &userPOut.UserID, &userPOut.PhoneNumber, &userPOut.BirthDate, &userPOut.GithubLink)

    if err != nil {
        log.Printf("Profile: %v", err)
        return nil, err
    }

    if err = tx.Commit(); err != nil {
        return nil, err
    }

    userOut.Profile = userPOut

    return &userOut, nil
}
```

Though the method appears lengthy, it is easy to decipher considering each line's familiarity.

Let's now add these handlers to the routes:

```go
// cmd/api/routes.go
...

func (app *application) routes() http.Handler {
    ...
    router.HandlerFunc(http.MethodPatch, "/users/update-user/", app.updateUserHandler)

    // Uploads
    router.HandlerFunc(http.MethodPost, "/file/upload/", app.uploadFileToS3Handler)
    router.HandlerFunc(http.MethodDelete, "/file/delete/", app.deleteFileOnS3Handler)

    return app.recoverPanic(router)
}
```

Before we go, we need an endpoint to "instrument" our application.

### Step 4: Getting the app's metrics

To get application's metrics, we'll use primarily [`expvar`][5] and, just for recording HTTP Status codes, [`httpsnoop`][1]. To start with, this endpoint should be heavily protected as hackers can take advantage of the data it exposes to attack, using a Denial of Service attack, our application. As a result of this, we will write a middleware that only allows `superuser`(s), who in most applications should be only one person, to access the endpoint:

```go
// cmd/api/middleware.go
...
func (app *application) authenticateAndAuthorize(next http.Handler) http.Handler {
    return http.HandlerFunc(func(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
        }
        if !db_user.IsSuperuser {
            app.unauthorizedResponse(w, r, errors.New("you are not authorized to access this resource"))
            return
        }
        next.ServeHTTP(w, r)

    })
}
```

To know more about middleware, kindly go through [this article][4].

In the middleware, we only allowed authenticated users who have `is_superuser` set to `true` to access the endpoint.

Next, we will register the endpoint:

```go
// cmd/api/routes.go
...
import (
    "expvar"
    ...
)
func (app *application) routes() http.Handler {
    ...
    // Metrics
    router.Handler(http.MethodGet, "/metrics/", app.authenticateAndAuthorize(expvar.Handler()))
    ...
}
```

We simply wrapped the default metrics hander, `expvar.Handler()` with the newly created middleware. Since the default data exposed by this endpoint ain't enough, we will register more data such as database connection information in `cmd/api/main.go`:

```go
// cmd/api/main.go
...
import (
    ...
    "expvar"
    ...
)
func main() {
    ...

    expvar.NewString("version").Set(version)
    expvar.Publish("goroutines", expvar.Func(func() interface{} {
        return runtime.NumGoroutine()
    }))
    expvar.Publish("database", expvar.Func(func() interface{} {
        return db.Stats()
    }))
    expvar.Publish("timestamp", expvar.Func(func() interface{} {
        return time.Now().Unix()
    }))

    ...

}
```

The application's version, number of goroutines, database statistics, and current timestamp in Unix format were added. Next, we need to get requests and response metrics. A middleware will also help here. Using this opportunity, we can bring in [`httpsnoop`][1] just for HTTP status codes and how many times they were returned:

```go
...
import (
    ...
    "expvar"
    ...

    "github.com/felixge/httpsnoop"
)
...
func (app *application) metrics(next http.Handler) http.Handler {
    totalRequestsReceived := expvar.NewInt("total_requests_received")
    totalResponsesSent := expvar.NewInt("total_responses_sent")
    totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_μs")

    totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status")
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        totalRequestsReceived.Add(1)

        metrics := httpsnoop.CaptureMetrics(next, w, r) // Only place `httpsnoop` is needed

        totalResponsesSent.Add(1)

        totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds())

        totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1)
    })
}
...
```

Now, we can wrap our entire `routes` with this middleware:

```go
// cmd/api/routes.go
func (app *application) routes() http.Handler {
    ...

    return app.metrics(app.recoverPanic(router))
}
```

With that, all the features of our backend system have been added.

> NOTE: *The code in the repo has an additional feature which ensures that if our application is abruptly interrupted, it will wait for backend tasks and pending requests to be fulfilled before stopping. You can check that out.*

In the next one, we will build out the remaining front-end codes.

## 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/felixge/httpsnoop "httpsnoop"
[2]: https://aws.github.io/aws-sdk-go-v2/docs/getting-started/#get-your-aws-access-keys "Get your AWS access keys"
[3]: https://hackthedeveloper.com/handling-form-data-in-golang/ "How to Handle and Process Golang Form Data"
[4]: https://www.alexedwards.net/blog/making-and-using-middleware "Making and Using HTTP Middleware"
[5]: https://pkg.go.dev/expvar "expvar"