Quickstart on Authentication and Authorization with JWT, Golang and MongoDB

Quickstart on Authentication and Authorization with JWT, Golang and MongoDB

Introduction

The most modern system uses REST API architecture to communicate between systems over the internet. REST API has some key principles that must be adhered to, and these principles pose significant threats to our system. Thus, making it vulnerable for attackers to hijack data during exchange over the internet.

Security and access control control has always been a major concern in system and security architecture. We'll dive into the implementation of Authentication and Role-Based access control (RBAC) using Go, JWT and MongoDB for persistence. JWT is a security tool we use to secure our applications that are sent over the internet.

REST API is Stateless

The server does not store the client's state between requests. Each request from a client to a server must contain all the information needed to understand and fulfil the request. This stateless approach of RESTful APIs is widely used in web browsers, mobile applications, and other servers. They provide a scalable and flexible way for different software systems to communicate with each other over the Internet.

Understanding How JSON Web Token(JWT) Handles Stateless Requests

JSON Web Tokens (JWT) is a compact means of representing claims between two parties. It is often used for authentication and information exchange. JWTs are commonly employed for securing communication between the client and the server.

Authentication & Authorization

The client initiates a sign-in request to the server for authorization to protected resources. The server issues unique and ephemeral Base64-encoded values of the Header.Payload.Signature

  • Header: The header is a JSON object consisting of alg(algorithm) and the typ(type) of token used.

  • Payload: The payload contains claims or user's data, roles and also iat(issued date), exp(expiring date), sub(subject)and other user details such as ID, email and other details

  • Signature: The signature verifies a token's integrity and authenticity is not altered during exchange. Thus, if the signature is valid, the server proceeds to decode the claims within the token.

In conclusion, it's important to note that while the header is visible to anyone with the JWT, the signature is not, as it requires a secret key. The server uses the header to determine how to verify the signature, and the payload contains the actual user's data that the token represents. These create a self-contained and secure token for performing authorization within stateless Restful APIs.

Jump to Git Repo

github.com/xsyro/quickstart-go-jwt-mongodb

Creating our Authentication & Authorization Project with Golang


1. Initialize the Project

The commands below are to create directories required by the project and also initialise a new Go project.

mkdir go-jwt-auth && cd go-jwt-auth
mkdir controllers
mkdir internal
mkdir middleware
mkdir models
mkdir repositories
mkdir route
mkdir server
mkdir services
go mod init go-jwt-auth

1.1. Install MongoDB Server

It's mandatory to have MongoDB installed on your local machine or as a remotely accessible server. Use the official MongoDB website to find the installation guide. For the tutorial, I'd recommend the Community Edition.

1.2. Download Required Dependencies

Please ensure pwd is go-jwt-auth to download the dependencies.

go get go.mongodb.org/mongo-driver
go get github.com/gorilla/mux
go get github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
go get github.com/go-playground/validator/v10
go get github.com/sirupsen/logrus
go get golang.org/x/crypto

The purpose of the dependencies above is as follows:

  1. mongo-driver Official Go library for MongoDB driver

  2. gorilla/mux A rich, friendly and high-performance HTTP web framework for Go programming.

  3. golang-jwt/jwt Go library for encoding and decoding JWT claims

  4. godotenv Go library for environment variables used within the scope of our application

  5. sirupsen/logrus Logging library for optimal visibility

  6. crypto A Go library for encrypting password hashing function

1.3. Defining the Packages and Directories

Organising our directories likewise organising our server with packages is essential to helping us with better code management. The directories are as follows:

  • controllers - This directory has functions that will handle the incoming requests based on the HTTP URI, Method, and Authorization, interact with the models and serve response

  • routes - Within this directory lies a routes.go file, responsible for invoking all the public functions defined in the controllers directory. Invocation of this file must be explicitly done

  • internal - This encompasses all external resource handlers, including database connections, and if needed, distributed caching system, message broker connections and others

  • middleware - This folder houses middleware functions that intercept HTTP requests for purposes such as authentication, logging, or any other required actions

  • models - This directory is designated for storing the data models relevant to all aspects of the application's business logic

  • repositories - This manages the Create-Read-Update-Delete (CRUD) actions for the models in the database. It acts as a middleman among the controllers, services, and models, ensuring that we refrain from directly accessing the database within the controller or service functions

  • services - The services manage all the application's business logic, interacting with the repositories and the internal database connection, and furnishing responses to the HTTP controllers.

  • server - Initiate the HTTP server connection and port allocation

1.4. Create.envFile and Add Environment Variables

The env variables are mandatory to ensure the application starts appropriately. However, BASE_URI_PREFIX is optional as it is only required if you want a URL versioning within your application

BASE_URI_PREFIX=/v1/api
HTTP_PORT=9000
MONGO_DB_URI=mongodb://localhost:27017
MONGO_DB_NAME=sample_app

2. Building the Project

After initializing our Go project with its required libraries, packages and environment variables. We can get started with building the project and in the end, get it running and run tests with Postman

2.1. Defining Models

To keep things simple, we've created types.go a which defines all our business logic struct. The file types.go is defined as follows:

package models

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

type (
    BaseModel struct {
        ID        primitive.ObjectID `json:"id"` //ID Generated by Mongo driver
        CreatedAt time.Time          `bson:"created_at" json:"created_at"`
        UpdatedAt time.Time          `bson:"updated_at" json:"updated_at"`
        DeletedAt time.Time          `bson:"deleted_at" json:"deleted_at,omitempty"`
    }
    Address struct {
        Address string
    }
    User struct {
        BaseModel           `bson:"-,inline"`
        FirstName           string    `bson:"first_name,omitempty" json:"first_name" validate:"required"`
        LastName            string    `bson:"last_name,omitempty" json:"last_name" validate:"required"`
        Email               string    `bson:"email,omitempty" json:"email,omitempty" validate:"required,email"`
        Phone               string    `bson:"phone,omitempty" json:"phone,omitempty" validate:"required"`
        Password            string    `bson:"password" json:"-"`
        PasswordRequestBody string    `bson:"-" json:"password,omitempty"`
        DateOfBirth         time.Time `bson:"date_of_birth,omitempty" json:"date_of_birth,omitempty"`
        Roles               []string  `bson:"roles,omitempty" json:"roles"`
        Address             Address   `bson:"address,inline,omitempty" json:"address,omitempty"`
    }

    Token struct {
        BaseModel    `bson:"-,inline"`
        AccessToken  string `bson:"access_token" json:"access_token"`
        RefreshToken string `bson:"refresh_token,omitempty" json:"-"` //Refresh token shouldn't be viewed on the client-side
    }
)

func NewBaseModel() BaseModel {
    return BaseModel{
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
}

type EnvVar struct {
    HttpPort,
    MongoDbUri,
    MongoDbName,
    BaseUrlPrefix,
    JwtSecret,
    Value string
}

func LoadEnvironmentVariables() EnvVar {
    return EnvVar{
        HttpPort:      os.Getenv("HTTP_PORT"),
        MongoDbUri:    os.Getenv("MONGO_DB_URI"),
        MongoDbName:   os.Getenv("MONGO_DB_NAME"),
        BaseUrlPrefix: os.Getenv("BASE_URI_PREFIX"),
    }
}

2.2. Initializing Database Connection in Internals

It's a good practice to keep a single instance of database connection within the application. As such, this's invoked in a singleton manner within the main.go file in our application. The file mongodb.go is defined as follows:

package internal

import (
    "context"
    "errors"
    "fmt"
    "github.com/joho/godotenv"
    log "github.com/sirupsen/logrus"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "quickstart-go-jwt-mongodb/models"
    "time"
)

type (
    MongoClient struct {
        client   *mongo.Client
        Database *mongo.Database
        ctx      context.Context
        timeOut  time.Duration
    }

    // MongoDatabase Implicitly import mongo.Client functions
    MongoDatabase interface {
        Client() *mongo.Client
        CreateCollection(ctx context.Context, name string, opts ...*options.CreateCollectionOptions) error
        Collection(name string, opts ...*options.CollectionOptions) *mongo.Collection
        RunCommand(ctx context.Context, runCommand interface{}, opts ...*options.RunCmdOptions) *mongo.SingleResult
    }
)

const (
    maxRetryCount = 5
    appName       = "go-jwt-mongo"
)

var (
    currentRetryCount = 1
)

// NewMongoDbConn Singleton instance
// Reuse this client. You can use this same client instance to perform multiple tasks, instead of creating a new one each time.
// The client type is safe for concurrent use by multiple goroutines
func NewMongoDbConn(envVar models.EnvVar) *MongoClient {
    var (
        err         error
        mongoUri    = envVar.MongoDbUri
        timeout     = 6 * time.Second
        client      *mongo.Client
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    )
    defer func() {
        cancel()
    }()
    if err = godotenv.Load(); err != nil {
        log.Warn("No .env file found")
    }

    if mongoUri == "" {
        err = errors.New("no `MONGO_DB_URI` found in the environment variable. Please export your connection string to var MONGO_DB_URI to connect to the db")
        //panic(err)
    }

    mongoOption := options.Client()
    mongoOption.SetAppName(appName)
    mongoOption.ApplyURI(mongoUri)
    client, err = mongo.Connect(ctx, mongoOption)
    err = client.Ping(ctx, nil)

    if err != nil && currentRetryCount <= maxRetryCount {
        log.Errorf("[MongoDB] connect attempt failed. ActiveRequest retry %d of %d.\n%v", currentRetryCount, maxRetryCount, err)
        currentRetryCount++
        NewMongoDbConn(envVar)
    }
    if currentRetryCount >= maxRetryCount {
        //Throw panic if retries exceeded without any successful connection
        panic(fmt.Sprintf("Connection retries exceeded limit"))
    }

    if errors.Is(ctx.Err(), context.DeadlineExceeded) {
        panic(fmt.Sprintf("Unable to connect to MongoDB Server with the context timeout %v", timeout))
    }
    log.Debugf("successfully connected to MongoDB %s", mongoUri)

    var database = client.Database(envVar.MongoDbName)
    return &MongoClient{
        client:   client,
        Database: database,
        ctx:      ctx,
        timeOut:  timeout,
    }
}

func (c *MongoClient) CloseClient() {
    defer func() {
        if r := recover(); r != nil {
            log.Warn("No active MongoDB connection to close.")
        }
    }()
    _ = c.client.Disconnect(c.ctx)
}

2.3. Repositories for CRUD Operations in MongoDB

package repositories

import (
    "context"
    "errors"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "quickstart-go-jwt-mongodb/internal"
)


type Filter struct {
    Key   string
    Value interface{}
}
type CrudOperation interface {
    CreateOne(context context.Context, model interface{}) (primitive.ObjectID, error)
    CreateMany(context context.Context, model []interface{}) ([]primitive.ObjectID, error)
    FindOne(context context.Context, model interface{}, filters ...Filter) bool
    FindAll(context context.Context, results interface{}, filters ...Filter) error
    FindPaginate(context context.Context, currentPage, perPage int, results interface{}, filters ...Filter) error
}

func filterToBsonFilter(filters ...Filter) bson.D {
    f := bson.D{}
    for i := range filters {
        f = append(f, bson.E{Key: filters[i].Key, Value: filters[i].Value})
    }
    return f
}


type userRepo struct {
    mongoDb    internal.MongoDatabase
    collection string
    timeout    context.Context
    cancelFun  context.CancelFunc
}

func (u *userRepo) FindPaginate(context context.Context, currentPage, perPage int, results interface{}, filters ...Filter) error {
    //TODO implement me
    panic("implement me")
}

func (u *userRepo) FindAll(context context.Context, results interface{}, filters ...Filter) error {
    find, err := u.mongoDb.Collection(u.collection).Find(context, filterToBsonFilter(filters...))
    if err != nil {
        return err
    }
    return find.All(context, results)
}

func (u *userRepo) FindOne(context context.Context, model interface{}, filters ...Filter) bool {
    singleResult := u.mongoDb.Collection(u.collection).FindOne(context, filterToBsonFilter(filters...))
    err := singleResult.Decode(model)
    if err != nil {
        return false
    }
    return !errors.Is(singleResult.Err(), mongo.ErrNoDocuments)
}

func (u *userRepo) CreateOne(context context.Context, model interface{}) (primitive.ObjectID, error) {
    id, err := u.mongoDb.Collection(u.collection).InsertOne(context, model)
    if err != nil {
        return primitive.NilObjectID, err
    }
    return id.InsertedID.(primitive.ObjectID), nil
}

func (u *userRepo) CreateMany(context context.Context, model []interface{}) ([]primitive.ObjectID, error) {

    return nil, nil
}

func NewUserRepository(mongoDb internal.MongoDatabase) CrudOperation {
    return &userRepo{
        mongoDb:    mongoDb,
        collection: "users",
    }
}

2.4. JWT Services Generates and Claims a JWT Token

The jwt_service.go handle generating ephemeral JWT tokens upon login request. This token shall therefore be passed to the HTTP header of every request.

package services

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "quickstart-go-jwt-mongodb/models"
    "time"

    "github.com/golang-jwt/jwt/v5"
    log "github.com/sirupsen/logrus"
)

type jwtService struct {
    ctx           context.Context
    jwtSigningKey string
}

type JwtClaim struct {
    ExpiringAt  time.Duration
    Subject     string
    Payload     []byte //JSON bytes
    ExtraClaims map[string]string
}

var (
    JwtSigningAlg = jwt.SigningMethodHS256
)

type JwtService interface {
    GenerateJWT(jwtClaim JwtClaim) (string, error)
    ClaimToken(tokenizedString string, obj interface{}) (jwt.Claims, error)
}

func NewJwtService(ctx context.Context, envVar models.EnvVar) JwtService {
    return &jwtService{
        ctx:           ctx,
        jwtSigningKey: envVar.JwtSecret,
    }
}

// GenerateJWT to generate a JWT Payload
// data must not be a JSON string. This would be done by the GenerateJWT method
func (j *jwtService) GenerateJWT(jwtClaim JwtClaim) (string, error) {
    token := jwt.New(JwtSigningAlg)
    claims := token.Claims.(jwt.MapClaims)
    if jwtClaim.Subject != "" {
        claims["sub"] = jwtClaim.Subject
    }
    if len(jwtClaim.Payload) > 0 {
        claims["obj"] = string(jwtClaim.Payload)
    }
    claims["exp"] = jwt.NewNumericDate(time.Now().Add(jwtClaim.ExpiringAt))
    claims["iat"] = jwt.NewNumericDate(time.Now())
    for k, v := range jwtClaim.ExtraClaims {
        claims[k] = v
    }
    tokenStr, err := token.SignedString([]byte(j.jwtSigningKey))

    if err != nil {
        log.Error("error creating jwt token", err)
        return "", errors.New("error creating jwt token")
    }
    return tokenStr, nil
}

func (j *jwtService) ClaimToken(tokenizedString string, obj interface{}) (jwt.Claims, error) {
    jwtToken, err := jwt.Parse(tokenizedString, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("there was an error in parsing")
        }
        return j.jwtSigningKey, nil
    })
    claims, ok := jwtToken.Claims.(jwt.MapClaims)
    objClaim, isObj := claims["obj"]

    if isObj {
        err = json.Unmarshal([]byte(objClaim.(string)), obj)
    }
    if ok && jwtToken.Valid {
        return claims, nil
    }
    return claims, err
}

2.5. Connect the dots Between the Controllers and Route Function

For this example, we've created a few examples of controllers to handle signup, authentication, refresh token and attempts to access secured URLs likewise non-secured URLs and static pages.

  • Signup and Authentication Controller

      package controllers
    
      import (
          "context"
          "encoding/json"
          "errors"
          "fmt"
          log "github.com/sirupsen/logrus"
          "golang.org/x/crypto/bcrypt"
          "net/http"
          "quickstart-go-jwt-mongodb/internal"
          "quickstart-go-jwt-mongodb/models"
          "quickstart-go-jwt-mongodb/repositories"
          "quickstart-go-jwt-mongodb/server"
          "quickstart-go-jwt-mongodb/services"
          "sync"
          "time"
      )
    
      type auth struct {
          Username string `json:"username" validate:"required,email"`
          Password string `json:"password"`
      }
    
      func CreateAccount(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:    "/account/create",
              Method: server.POST,
              Secure: false,
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
                  defer cancel()
                  var user = models.User{
                      BaseModel: models.NewBaseModel(),
                  }
                  err := server.ParseReqToJson(req, &user)
                  if err != nil {
                      server.HttpError(w, err)
                      return
                  }
    
                  userRepository := repositories.NewUserRepository(database)
                  if userRepository.FindOne(ctx, &user, repositories.Filter{Key: "email", Value: user.Email}) {
                      server.HttpError(w, errors.New(fmt.Sprintf("%s already exists", user.Email)))
                      return
                  }
                  password, err := generateHashPassword(user.PasswordRequestBody)
                  if err != nil {
                      server.HttpError(w, errors.New("unable to hash password. Please try again later"))
                      return
                  }
                  user.Password = password
                  objectId, err := userRepository.CreateOne(ctx, &user)
                  if err != nil {
                      server.HttpError(w, err)
                      return
                  }
                  user.ID = objectId
                  user.PasswordRequestBody = ""
                  server.HttpResponse(w, http.StatusCreated, user)
                  return
              },
          }
      }
    
      func Authenticate(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:    "/account/auth",
              Method: server.POST,
              Secure: false,
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
                  defer cancel()
                  var user models.User
                  var auth auth
                  err := server.ParseReqToJson(req, &auth)
                  if err != nil {
                      server.HttpError(w, err)
                      return
                  }
    
                  userRepository := repositories.NewUserRepository(database)
                  tokenRepository := repositories.NewTokenRepository(database)
                  findOne := userRepository.FindOne(ctx, &user, repositories.Filter{Key: "email", Value: auth.Username})
                  if !findOne {
                      server.HttpError(w, errors.New(fmt.Sprintf("Invalid Username. %s does not exists", auth.Username)))
                      return
                  }
    
                  if !checkPasswordHash(auth.Password, user.Password) {
                      server.HttpError(w, errors.New("invalid Credential supplied. Please check username/password"))
                      return
                  }
    
                  jwtService := services.NewJwtService(ctx, models.LoadEnvironmentVariables())
                  var accessTokenStr string
                  var refreshTokenStr string
                  wg := sync.WaitGroup{}
                  wg.Add(2)
                  go func() {
                      defer wg.Done()
                      bytesUser, _ := json.Marshal(user)
                      accessTokenStr, err = jwtService.GenerateJWT(services.JwtClaim{
                          ExpiringAt: 2 * time.Hour,
                          Subject:    user.Email,
                          Payload:    bytesUser,
                          ExtraClaims: map[string]string{
                              "iss": req.Host,
                          },
                      })
                  }()
                  go func() {
                      defer wg.Done()
                      refreshTokenStr, err = jwtService.GenerateJWT(services.JwtClaim{
                          ExpiringAt: 24 * time.Hour,
                          Subject:    user.Email,
                          ExtraClaims: map[string]string{
                              "iss": req.Host,
                          },
                      })
                  }()
                  wg.Wait()
    
                  token := models.Token{
                      BaseModel:    models.NewBaseModel(),
                      AccessToken:  accessTokenStr,
                      RefreshToken: refreshTokenStr,
                  }
                  http.SetCookie(w, &http.Cookie{
                      Name:     "jwt",
                      Value:    refreshTokenStr,
                      Expires:  time.Now().Add(24 * time.Hour),
                      MaxAge:   60 * 60 * 24,
                      Secure:   true,
                      HttpOnly: true,
                      SameSite: http.SameSiteNoneMode,
                  })
                  tokenId, err := tokenRepository.CreateOne(ctx, token)
                  if err != nil {
                      log.Error("Unable to persist token generated", err)
                  }
                  token.ID = tokenId
    
                  if err != nil {
                      server.HttpError(w, errors.New("unable to generated token. Please try again later"))
                      return
                  }
    
                  server.HttpResponse(w, http.StatusCreated, token)
                  return
              },
          }
      }
    
      func RefreshToken(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:    "/account/refresh-token",
              Method: server.GET,
              Secure: false,
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
                  defer cancel()
                  cookie, err := req.Cookie("jwt")
                  if err != nil {
                      server.AccessDenied(w, errors.New("'jwt' cookie do not exist in cookie-header"))
                      return
                  }
                  jwtService := services.NewJwtService(ctx, models.LoadEnvironmentVariables())
                  jwt, err := jwtService.ClaimToken(cookie.Value, nil)
                  if err != nil {
                      server.AccessDenied(w, errors.New("invalid jwt token supplied"))
                      return
                  }
                  expirationTime, _ := jwt.GetExpirationTime()
                  if err != nil {
                      return
                  }
                  if time.Now().After(expirationTime.Time) {
                      server.AccessDenied(w, errors.New("unauthorized. Token expired"))
                      return
                  }
    
                  userRepository := repositories.NewUserRepository(database)
                  var user models.User
                  subject, _ := jwt.GetSubject()
                  userRepository.FindOne(ctx, &user, repositories.Filter{Key: "email", Value: subject})
    
                  //Create a one-time only access token again
                  bytesUser, _ := json.Marshal(user)
                  accessTokenStr, err := jwtService.GenerateJWT(services.JwtClaim{
                      ExpiringAt: 10 * time.Hour,
                      Payload:    bytesUser,
                      ExtraClaims: map[string]string{
                          "iss": req.Host,
                      },
                  })
                  token := models.Token{
                      BaseModel:   models.NewBaseModel(),
                      AccessToken: accessTokenStr,
                  }
    
                  tokenRepository := repositories.NewTokenRepository(database)
                  id, err := tokenRepository.CreateOne(ctx, token)
                  if err != nil {
                      return
                  }
                  token.ID = id
                  server.HttpResponse(w, http.StatusCreated, token)
                  return
              },
          }
      }
    
      // ListCustomers - Empty '[]PermitRoles' is wildcard access to all users.
      // By simply excluding the PermitRole filed from the Controller struct, it permits all secured users to
      // access the page
      func ListCustomers(database internal.MongoDatabase) server.Controller {
          return server.Controller{
              Uri:    "/user/customer-records",
              Method: server.GET,
              Secure: true,
              Callback: func(responseWriter http.ResponseWriter, req *http.Request) {
                  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
                  defer cancel()
                  userRepository := repositories.NewUserRepository(database)
                  var users []models.User
                  err := userRepository.FindAll(ctx, &users)
                  if err != nil {
                      server.HttpError(responseWriter, err)
                      return
                  }
                  server.HttpResponse(responseWriter, http.StatusOK, users)
              },
          }
      }
    
      func checkPasswordHash(password string, hash string) bool {
          err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
          return err == nil
      }
    
      func generateHashPassword(plainText string) (string, error) {
          bytes, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost)
          return string(bytes), err
      }
    
  • Secured Page Controller

      package controllers
    
      import (
          "context"
          "net/http"
          "quickstart-go-jwt-mongodb/internal"
          "quickstart-go-jwt-mongodb/server"
      )
    
      // Provide personal preferences to roles
      const (
          Role1 = "SUPERVISOR"
          Role2 = "SALES_PERSON"
      )
    
      func SecuredRole1Only(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:         "/secured/role-1",
              Method:      server.GET,
              Secure:      true,
              PermitRoles: []string{Role1},
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  _, _ = w.Write([]byte("Access granted!"))
              },
          }
      }
    
      func SecuredRole2Only(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:         "/secured/role-2",
              Method:      server.GET,
              Secure:      true,
              PermitRoles: []string{Role2},
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  _, _ = w.Write([]byte("Access granted!"))
              },
          }
      }
    
      func SecuredRole1And2Only(database internal.MongoDatabase, ctx context.Context) server.Controller {
          return server.Controller{
              Uri:         "/secured/role-1-and-2",
              Method:      server.GET,
              Secure:      true,
              PermitRoles: []string{Role1, Role2},
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  _, _ = w.Write([]byte("Access granted!"))
              },
          }
      }
    
  • Static/Non-Secured Page Controler

    
      func Homepage(ctx context.Context) server.Controller {
          return server.Controller{
              Uri:    "/",
              Secure: false,
              Method: server.GET,
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  _, _ = w.Write([]byte("Hello!"))
                  return
              },
          }
      }
    
      func HealthCheck(ctx context.Context) server.Controller {
          return server.Controller{
              Uri:    "/status",
              Secure: false,
              Method: server.GET,
              Callback: func(w http.ResponseWriter, req *http.Request) {
                  _, _ = w.Write([]byte("Good!"))
                  return
              },
          }
      }
    
  • Using routes.go to connect the functions in controllers package and server/server.go which enables the requests to be within the *mux.Router.HandleFunc

      package route
    
      import (
          "context"
          "quickstart-go-jwt-mongodb/controllers"
          "quickstart-go-jwt-mongodb/internal"
          "quickstart-go-jwt-mongodb/server"
      )
    
      func Routes(httpHandler server.RequestHandler, database internal.MongoDatabase, ctx context.Context) {
    
          httpHandler.ControllerRegistry(controllers.Homepage(ctx))
          httpHandler.ControllerRegistry(controllers.HealthCheck(ctx))
    
          httpHandler.ControllerRegistry(controllers.Authenticate(database, ctx))
          httpHandler.ControllerRegistry(controllers.RefreshToken(database, ctx))
          httpHandler.ControllerRegistry(controllers.CreateAccount(database, ctx))
    
          httpHandler.ControllerRegistry(controllers.ListCustomers(database))
          httpHandler.ControllerRegistry(controllers.ListCustomers(database))
    
          httpHandler.ControllerRegistry(controllers.SecuredRole1Only(database, ctx))
          httpHandler.ControllerRegistry(controllers.SecuredRole2Only(database, ctx))
          httpHandler.ControllerRegistry(controllers.SecuredRole1And2Only(database, ctx))
      }
    

2.6. Registering ourroutes.goto Gorillamux.HandleFunc with our server.go

We've meticulously segregated responsibilities within our application and constructed a route facade for our Gorilla Mux library. Therefore, we need to ensure our server responds to an HTTP URL within the port it serves.

package server

import (
    "context"
    "encoding/json"
    "fmt"
    "github.com/go-playground/validator/v10"
    "github.com/gorilla/mux"
    log "github.com/sirupsen/logrus"
    "net/http"
    "quickstart-go-jwt-mongodb/models"
    "time"
)

type (
    Verb    string
    Request = http.Request
    // Controller Declare how HTTP needs to be handled
    Controller struct {
        Uri         string
        Method      Verb
        Secure      bool
        PermitRoles []string
        Callback    func(w http.ResponseWriter, req *http.Request)
    }
    ResponseBody struct {
        IsError bool        `json:"is_error"`
        Message string      `json:"message"`
        Data    interface{} `json:"data,omitempty"`
    }
    Handler struct {
        router          *mux.Router
        port            string
        httpTimeout     context.Context
        requestRegistry []Controller
    }
    RequestHandler interface {
        HandleMiddlewares(middlewares ...mux.MiddlewareFunc)
        ControllerRegistry(handler ...Controller)
        GetControllers() []Controller
        Serve()
    }
)

func (appRouter *Handler) GetControllers() []Controller {
    return appRouter.requestRegistry
}

const (
    GET    Verb = "GET"
    POST   Verb = "POST"
    PUT    Verb = "PUT"
    DELETE Verb = "DELETE"
)

func NewHttpRequestHandler(httpTimeoutCtx context.Context, envVar models.EnvVar) RequestHandler {
    return &Handler{
        router:          mux.NewRouter().PathPrefix(envVar.BaseUrlPrefix).Subrouter(),
        port:            envVar.HttpPort,
        httpTimeout:     httpTimeoutCtx,
        requestRegistry: []Controller{},
    }
}

func (appRouter *Handler) HandleMiddlewares(middlewares ...mux.MiddlewareFunc) {
    appRouter.router.Use(middlewares...)
}

func (appRouter *Handler) ControllerRegistry(controllers ...Controller) {
    for i := range controllers {
        appRouter.requestRegistry = append(appRouter.requestRegistry, controllers[i])
    }
}

func (appRouter *Handler) Serve() {

    for _, request := range appRouter.requestRegistry {
        appRouter.router.HandleFunc(request.Uri, request.Callback).Methods(string(request.Method), http.MethodOptions)
    }

    server := &http.Server{
        Addr:         fmt.Sprintf("%s:%s", "0.0.0.0", appRouter.port),
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
        Handler:      appRouter.router,
    }
    log.Infof("serving HTTP Request on port %s", appRouter.port)
    err := server.ListenAndServe()
    if err != nil {
        log.Error(err)
        return
    }
}

func ParseReqToJson(req *Request, obj interface{}) error {
    defer req.Body.Close()
    err := json.NewDecoder(req.Body).Decode(&obj)
    if err != nil {
        return err
    }
    return validator.New().Struct(obj)
}

func HttpResponse(w http.ResponseWriter, statusCode int, obj interface{}) {
    err := json.NewEncoder(w).Encode(ResponseBody{
        IsError: false,
        Message: "Request Completed",
        Data:    obj,
    })
    if err != nil {
        log.Error("error sending server response")
    }
    w.WriteHeader(statusCode)
}

func HttpError(w http.ResponseWriter, err error) {
    w.WriteHeader(http.StatusUnauthorized)
    if json.NewEncoder(w).Encode(ResponseBody{
        IsError: true,
        Message: fmt.Sprintf("%s", err),
    }) != nil {
        log.Error("error sending server response")
    }
    w.WriteHeader(http.StatusBadRequest)
}

func AccessDenied(w http.ResponseWriter, err error) {
    w.WriteHeader(http.StatusUnauthorized)
    if json.NewEncoder(w).Encode(ResponseBody{
        IsError: true,
        Message: fmt.Sprintf("%s", err),
    }) != nil {
        log.Error("error sending server response")
    }
}

Conclusion

Implementing authentication and authorization in Golang can be easily achieved. By adhering to the guidelines laid out in this article, you can establish a robust and effective authentication and authorization framework for your Golang application. Whether you're developing a basic web application or a sophisticated enterprise system, Golang furnishes the necessary resources to safeguard your users' data. Employing JWT tokens and cryptographic hashing methods, you can guarantee the dependability, scalability, and manageability of your authentication and authorization mechanism. Armed with the appropriate tools and expertise, you can transform your Golang application into a secure and reliable platform for your users.

GitHub Repository of the Project: https://github.com/xsyro/quickstart-go-jwt-mongodb

Thank you