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
:
Name is whatever you like, select Regular Web Application
and click Create
. You should be redirected to the page of the newly created application.
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.
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
.
Finally, save the changes by clicking Save Changes
button at the very bottom of the page.
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:
Provide any name:
Press Create Project
button:
Next, go to the Database
page and press Build a Database
button.
To create a free database, choose "Shared" plan by pressing Create
button under its label.
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
.
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.
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 ๐
On the Database
page that will open, find created cluster and press Connect
button. Next, click 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.
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"
)
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"))
}
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'
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"
)
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
}
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
}
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")
}
Import this package in the main.go
to call initialization:
package main
import (
_ "<your-module-name>/configs"
...
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")
}
...
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)
}
}
...
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))
}
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)
}
Now apply this endpoint in the main
function:
...
e.Use(middleware.Logger())
routes.ApplyCallback(e)
e.Logger.Fatal(e.Start(":9000"))
...
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
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
You should see the login page that Auth0 made for us:
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 ๐
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 typeobjectId
-
data
of typestring
-
owner_email
of typestring
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"`
}
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)
}
}
}
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")
}
...
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)
}
}
...
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)
}
}
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"`
}
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, ¬e); 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)
}
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(), ¬es); 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)
}
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,
)
}
Apply these routes in the main
function:
...
routes.ApplyCallback(e)
routes.ApplyNotes(e)
e.Logger.Fatal(e.Start(":9000"))
}
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).
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.
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:
Afterword
You did it! Congratulations ๐ฅณ
Top comments (0)