DEV Community

Cover image for How to implement a basic CRUD in Golang protected by Auth0
Denys Hromniuk
Denys Hromniuk

Posted on

How to implement a basic CRUD in Golang protected by Auth0

About

This tutorial is a step-by-step guide for implementation of a system capable of restricting access to resources for users, which doesn't have sufficient rights for these particular resources. You'll understand some key concepts and build such system from scratch by yourself.

If you already understand these mechanisms and just looking for a quick solution, feel free to fork this project.

Prerequisites

This tutorial assumes that:

๐Ÿ“Œ You are capable of writing a basic CRUD application in Go. Here we will use Echo framework for API creation and MongoDB for storage. However, it is enough to have understanding how to create such system using these technologies.

๐Ÿ“Œ You know what is JWT. Is not, please watch this amazing video.

Authorization ๐Ÿ‘ฎ

Let's say that we want to build a service, which would allow users to store their private notes. In our example, a note will be just a text string.

To control access to such a vulnerable data, we should know who is its owner. To do that, we will store an owner identifier along with each data record, so when some user tries to get a note, we could compare his identifier with the identifier that is stored along the note. If they are equal, then we could safely provide the requested data.

Authentication ๐Ÿ”

To authenticate a user, we will use Auth0 as it allows us to concentrate on features of our application instead of securing vulnerable user data such as credentials. It also provides some neat features as authentication with Google account and free tier with up to 7k users which is more than enough for your MVP ๐Ÿ˜Ž

Let's do some basic setup and discuss authentication concept.

Create Auth0 application

First things first, you need an account on the Auth0 platform, so head to their page and create one.

After that, go to the dashboard, find Applications page and click on Create Application:
New application

Name is whatever you like, select Regular Web Application and click Create. You should be redirected to the page of the newly created application.

Application page

You can find here the Domain, Client ID and Client Secret. Remember where you can find these values, as we will need them in the future.

Now we need to set it up to use HS256 signing algorithm, so we could verify incoming JWT tokens from users using our application secret. In this way, our API server could authenticate the user making a request and make sure that data provided inside the token is valid.

Go to Settings, scroll down to the Application URIs section and add http://localhost:9000/callback URL to the text field under the Allowed Callback URLs title.

Application URIs

After that, continue scrolling down to the Advanced settings button and click on it. Next, go to OAuth page and in the JSON Web Token (JWT) Signature Algorithm section, select HS256.

Advanced settings

Finally, save the changes by clicking Save Changes button at the very bottom of the page.

Save Changes

Concept

To get access to the requested resource, the user must prove that he is truly the person it claims to be. Therefore, we need a JWT signed by our application, so we could verify the data inside the provided token with our secret.

To get such token, the user should land on our application login page managed by Auth0. Then he gets authenticated using whatever way he likes and in case of success, he gets a special code. Next, our API server should use this value along with application secret and other data to generate a JWT token which belongs to our user. To make this happen, the Auth0 login page redirects the user client to the callback URL we provided with code value stored in the URL query parameters under, well, the code name. After that, the API server reads the code, sends a POST request to Auth0, gets a token and provides a JWT to the user client.

You can play around with this flow manually by following the steps on this page.

Create MongoDB database

Here we will create MongoDB Atlas database as it's easy to set up and free. You might want to skip this step if you want to use your database, but for folks that need some help, this section would be handy ๐Ÿ™Œ

As always, create an account on mongodb.com and log in to it. Next, head to Projects page and create a new project by pressing New Project button:

Projects

Provide any name:

Project name

Press Create Project button:

Create Project

Next, go to the Database page and press Build a Database button.

Build a Database

To create a free database, choose "Shared" plan by pressing Create button under its label.

Shared plan

On the next page, you can set up your free cluster if you want, but for purposes of this guide a default configuration is enough. Let's change the name and press Create Cluster.

Cluster Settings

The Security Quickstart page should open. Here, we need to set up access to the newly created cluster.

Firstly, let's create credentials an API server is going to use to exchange data with the database. You can enter any username and password you want. For the example, I am going to type admin to both fields. Please remember these values as we are going to use them later. Press Create User button.

User creation

In the section below, we should provide an IP address from which the database could be accessible. Most likely, you are going to run the API server from the same machine you're registering a cluster from your browser. Press Add My Current IP Address if this is the case. Otherwiseโ€ฆ you got the idea ๐Ÿ˜‰

Add IP address

On the Database page that will open, find created cluster and press Connect button. Next, click Connect your application.

Connect your application

Select Go as driver, choose your version and remember the mongo URI that was generated for you. API server will use it to establish connection with the database.

mongo URI

That's it! Now we set and ready to finally start coding ๐Ÿ‘จโ€๐Ÿ’ป

Create an API server

Okay, enough theory, let's move to practice ๐Ÿ˜‰

Initialization

Create a directory for our project, run go mod init to initialize it and create a main.go file with the next imports:

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)
Enter fullscreen mode Exit fullscreen mode

Define the main() function and set up a basic echo application. Let's make it neat and add a logger and recover middleware.

func main() {
    e := echo.New()

    if err := godotenv.Load(); err != nil {
        panic(fmt.Errorf("failed to load .env file: %v", err))
    }

    e.Use(middleware.Recover()).Find
    e.Use(middleware.Logger())

    e.Logger.Fatal(e.Start(":9000"))
}
Enter fullscreen mode Exit fullscreen mode

Next, create a .env file and fill it with data from previous steps of this guide. Don't forget to replace the <password> value in the mongo URI.

DATABASE_NAME='example-database'
MONGO_URI='mongodb+srv://admin:admin@example-cluster.f1wmqog.mongodb.net/?retryWrites=true&w=majority'

AUTH0_DOMAIN='dev-tslb5vli.us.auth0.com'
AUTH0_CALLBACK_URL='http://localhost:9000/callback'
AUTH0_CALLBACK_ENDPOINT='/callback'
AUTH0_CLIENT_ID='PpstQbfS9tDdQvSTjDVbzRe8QWmGplrA'
AUTH0_CLIENT_SECRET='UKRAAKSJDFAfds4v1FXGGGDx66j343asd24FSASAMLhAEecJdasdkKJK3k3_V7CkdlksdtINE'
Enter fullscreen mode Exit fullscreen mode

Connect to the database

Create a configs directory with the database.go file inside. Provide package name and add the next imports:

package configs

import (
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)
Enter fullscreen mode Exit fullscreen mode

Next, we need to define a function used to establish a connection with the database.

...

func connectDB() (*mongo.Client, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    mongoURI := os.Getenv("MONGO_URI")
    client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
    if err != nil {
        return nil, fmt.Errorf("failed to create a new client: %v", err)
    }

    err = client.Ping(ctx, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to ping the database: %v", err)
    }

    return client, nil
}
Enter fullscreen mode Exit fullscreen mode

Then, add some global variables to this config for the database client and collections that will be explained later in this guide:

var DB *mongo.Client
var Collections struct {
    Accounts *mongo.Collection
    Notes *mongo.Collection
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to initialize them. Create init function:

...

func init() {
    log.Print("Initializing database connection")

    var err error
    DB, err = connectDB()
    if err != nil {
        return log.Fatalf("failed to connect to database: %v", err)
    }

    databaseName := os.Getenv(envnames.DatabaseName)
    Collections.Accounts = DB.Database(databaseName).Collection("accounts")
    Collections.Notes = DB.Database(databaseName).Collection("notes")
}
Enter fullscreen mode Exit fullscreen mode

Import this package in the main.go to call initialization:

package main

import (
    _ "<your-module-name>/configs"

    ...
Enter fullscreen mode Exit fullscreen mode

More info about that kind of import could be found in the official specification page. BTW, I find the specification page to be the best source of information regarding Golang. Seriously, invest some time and read it ๐Ÿ“–

Create a callback endpoint

Now let's add a nice callback controller with some error handling. To do so, create a controllers directory with a file callback.go inside of it and define a function to fetch JWT from the Auth0 service. Let's call it FetchJWT. It should read the code URL parameter that user client will pass after Auth0 server will redirect him to our endpoint.

func FetchJWT(c echo.Context) error {
    code := c.QueryParam("code")
    if code == "" {
        return c.String(http.StatusBadRequest, "code parameter was not provided")
    }

    ...
Enter fullscreen mode Exit fullscreen mode

Next, we need to send a POST request with this code to Auth0 and verify a response:

    ...

    tokenFetchURL = fmt.Sprintf("https://%s/oauth/token", os.Getenv("AUTH0_DOMAIN"))

    data := url.Values{
        "grant_type":    {"authorization_code"},
        "client_id":     {os.Getenv("AUTH0_CLIENT_ID")},
        "client_secret": {os.Getenv("AUTH0_CLIENT_SECRET")},
        "redirect_uri":  {os.Getenv("AUTH0_CALLBACK_URL")},
        "code":{[]string{code}},
    }

    response, err := http.PostForm(tokenFetchURL, data)
    if err != nil {
        return fmt.Errorf("failed to retrieve JWT token from Auth0 server: %v", err)
    }

    body, err := io.ReadAll(response.Body)
    if err != nil {
        return fmt.Errorf("failed to read response form Auth0 server: %v", err)
    }

    if response.StatusCode != http.StatusOK {
        if response.StatusCode == http.StatusForbidden {
            return responses.Message(c, http.StatusForbidden, "got response from auth0: unauthorized")
        } else {
            return fmt.Errorf("got bad response from auth0: %v", err)
        }
    }

    ...
Enter fullscreen mode Exit fullscreen mode

We want to make sure that every verified JWT to our API server will contain user email. We will use that value as an identifier for internal user account in our application (more about that later). For now, let's create a check to ensure that every token we return to our users contains an "email" scope, which basically implies that one of the JWT claims contain user email.

    ...

    fieldsToCheck := struct {
        Scope string `json:"scope"`
    }{}

    if err := json.Unmarshal(body, &fieldsToCheck); err != nil {
        return fmt.Errorf("failed to unmarshal body for field check: %v", err)
    }

    if !strings.Contains(fieldsToCheck.Scope, "email") {
        return responses.Message(c, http.StatusBadRequest, `"email" scope is required`)
    }

    return c.String(http.StatusOK, string(body))
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's register the callback endpoint on our server. As always, we are trying to stick to the best practices to make everything nice and readable. Create routes directory along with the file callback.go inside. Paste the code below (you would probably need to change the imports):

package routes

import (
    "mrsydar/apiserver/controllers"

    "github.com/labstack/echo/v4"
)

func ApplyCallback(e *echo.Echo) {
    e.GET(os.Getenv("AUTH0_CALLBACK_ENDPOINT"), controllers.FetchJWT)
}
Enter fullscreen mode Exit fullscreen mode

Now apply this endpoint in the main function:

    ...

    e.Use(middleware.Logger())

    routes.ApplyCallback(e)

    e.Logger.Fatal(e.Start(":9000"))

    ...
Enter fullscreen mode Exit fullscreen mode

Let's test it! Run the server with go run .. Fill in the required values and open the next URL:

https://<AUTH0_DOMAIN>/authorize?response_type=code&client_id=<AUTH0_CLIENT_ID>&redirect_uri=http://localhost:9000/callback&scope=openid%20email&state=STATE
Enter fullscreen mode Exit fullscreen mode

In my case it looks in the next way:

https://dev-tslb5vli.us.auth0.com/authorize?response_type=code&client_id=PpstQbfS9tDdQvSTjDVbzRe8QWmGplrA&redirect_uri=http://localhost:9000/callback&scope=openid%20email&state=STATE
Enter fullscreen mode Exit fullscreen mode

You should see the login page that Auth0 made for us:

Auth0 login page

Authorize yourself by clicking Accept. You will be automatically redirected to our callback endpoint http://localhost:9000/callback. Our backend will read the code, do the JWT request and return the result to you. Normally, this should be handled by frontend of our project, but it's enough as long as it works ๐Ÿ‘€

Callback page

Voilร ! We are done with the callback. Cool ๐Ÿ˜Ž

Create internal accounts

Let's have a look into our JWT token. To do that, copy the value from id_token field of the callback response and paste it into the jwt.io page. One of the token claims contains an email value. We can use this value to protect our data in the DB.

For example, let's go back to our example with notes. Each note record in our database should contain 3 fields:

  • _id of type objectId
  • data of type string
  • owner_email of type string

Now you got it ?

When user will send a request to list his notes, we will validate his token and then return all the notes which have owner_email set to email value from JWT. Genius and simple!

But there are a lot more benefits to create and store internal user accounts in your database and use their identifiers instead of just using an email field. So let's do that.

First things first, define a model for our accounts. Create a models directory and account.go inside. Define account model there:

package models

import "go.mongodb.org/mongo-driver/bson/primitive"

type Account struct {
    ID primitive.ObjectID `bson:"_id,omitempty"`

    Email string `bson:"email,omitempty" validate:"required"`
}
Enter fullscreen mode Exit fullscreen mode

We need to write our own middleware which will create and associate the internal user with the incoming request. Create a middlewares directory with accounts.go inside. Define a AssociateAccountWithRequest middleware function.

func AssociateAccountWithRequest(next echo.HandlerFunc) echo.HandlerFunc {
    func (next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            return next(c)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, read an email stored inside the JWT. To do so, paste the next piece of code inside the anonymous function we defined inside the AssociateAccountWithRequest right before the next(c) call:

        ...

        user := c.Get("user").(*jwt.Token)
        claims := user.Claims.(jwt.MapClaims)
        email := claims["email"].(string)
        if email == "" {
            return errors.New("email value is empty in the received JWT token")
        }

        ...

Enter fullscreen mode Exit fullscreen mode

Now, fetch the account from our DB by the email value, or create the new one:

        ...

        account := models.Account{}
        if err := database.Collections.Accounts.FindOne(context.Background(), bson.M{"email": email}).Decode(&account); err != nil {
            if err == mongo.ErrNoDocuments {
                account.Email = email
                result, err := database.Collections.Accounts.InsertOne(context.Background(), account)
                if err != nil {
                    return fmt.Errorf("failed to insert account resource: %v", err)
                }
                account.ID = result.InsertedID.(primitive.ObjectID)
            } else {
                return fmt.Errorf("failed to get account: %v", err)
            }
        }

        ...
Enter fullscreen mode Exit fullscreen mode

Finally, let's pass an account ID to the request context, so the controller could return only data accessible to the user sending a request.

    ...
    c.Set(contextnames.AccountID, account.ID)

    return next(c)
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's summarize. We have just created a middleware which we could use on any route for our API server. It will verify the user sending a request and create or get an already existing internal user and pass its identifier to the next steps of the request pipeline. Fabulous ๐Ÿฆ„

Create notes controller

Now, when the middleware is ready, go ahead and create two endpoints to post and list data, which needs to be protected. In our case, this is a note object.

Foremost, define a note model the note.go in the models directory. Add ID field, Owher field to tie the note to some owner (i.e. internal user) and the text field to the data itself.

package models

type Note struct {
    ID    primitive.ObjectID `bson:"_id" json:"_id"`
    Owner primitive.ObjectID `bson:"owner,omitempty" json:"owner"`

    Text string `bson:"text" json:"text"`
}
Enter fullscreen mode Exit fullscreen mode

The model is ready, now we need some controllers to put it in action. Create a note.go file inside the controllers directory.

Define a function which would allow users to store their notes. It should parse the request body and insert the received data to the appropriate documents collection:

func PostNote(c echo.Context) error {
    var note models.Note

    body, err := io.ReadAll(c.Request().Body)
    if err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusBadRequest, msg)
    }

    if len(body) == 0 {
        msg := "request body is empty"
        return responses.Message(c, http.StatusBadRequest, msg)
    }

    if err := json.Unmarshal(body, &note); err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    note.ID = primitive.NewObjectID()
    note.Owner = c.Get("accountID").(primitive.ObjectID)

    result, err := database.Collections.Notes.InsertOne(context.Background(), note)
    if err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    note.ID = result.InsertedID.(primitive.ObjectID)

    return c.JSON(http.StatusCreated, note)
}
Enter fullscreen mode Exit fullscreen mode

Now define a function to fetch notes by owner:

func GetNotes(c echo.Context) error {
    notes := []models.Note{}

    accountID := c.Get("accountID").(primitive.ObjectID)
    cur, err := database.Collections.Notes.Find(context.Background(), bson.M{"owner": accountID})
    if err != nil {
        msg := "failed to list notes"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    if err := cur.All(context.Background(), &notes); err != nil {
        msg := "failed to list notes"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    return c.JSON(http.StatusOK, notes)
}
Enter fullscreen mode Exit fullscreen mode

Finally, tie these functions to appropriate endpoints in the note.go file inside the routes directory. Don't forget to use the middleware we created. Otherwise, a request context won't contain the necessary owner identifier.

func ApplyNotes(e *echo.Echo) {
    e.GET("/notes", controllers.GetNotes,
        middleware.JWTWithConfig(middleware.JWTConfig{SigningKey: []byte(os.Getenv("AUTH0_CLIENT_SECRET"))}),
        d2middlewares.AssociateAccountWithRequest,
    )

    e.POST("/notes", controllers.PostNote,
        middleware.JWTWithConfig(middleware.JWTConfig{SigningKey: []byte(os.Getenv("AUTH0_CLIENT_SECRET"))}),
        d2middlewares.AssociateAccountWithRequest,
    )
}
Enter fullscreen mode Exit fullscreen mode

Apply these routes in the main function:

    ...

    routes.ApplyCallback(e)
    routes.ApplyNotes(e)

    e.Logger.Fatal(e.Start(":9000"))
}
Enter fullscreen mode Exit fullscreen mode

Here we are, the server is ready to be tested ๐ŸŒŸ

Test it ๐Ÿงช

Start our server with go run . command. It should connect to the database and start serving our endpoints.

Now, send a POST request to the localhost:9000/notes with the body containing any text (e.g. {"text":"vulnerable data"}). Response should contain our note populated with _id and owner data. Don't forget to add Authorization header and put the JWT we retrieved previously Bearer <id_token value>. Something like: Authorization: Bearer eyJhbGciOiJIUzI1NiIsR5cCI6IkpXVCJ9.... Note, that token has an expiration time. If it got expired, you need to repeat the authentication process we did earlier and copy a new id_token (there's also an option to refresh tokens, more on that you could find in the Auth0 docs).

POST note

Let's send 2 notes to the server.

Next, list them with the GET request to the same endpoint. Again, don't forget to set JWT as previously.

List notes

Ta-da ๐ŸŽ‰

If something doesn't work, don't worry. Just have a look into the ready project.

Homework

Sign up to our application with another account, put some notes and then list them. You are expected to see notes only for the new user. This is what we wanted to achieve.

Also, look through the database collections and make sure you understand each field there:

collections

Afterword

You did it! Congratulations ๐Ÿฅณ

Top comments (0)