JWT Authentication with Go and MongoDB

JWT Authentication with Go and MongoDB


Add Subtitle

Introduction

In this tutorial, we will build a simple web application using Go and MongoDB that implements JWT (JSON Web Token) authentication. This application will allow users to register, log in, and access a protected route. We will use the Gin framework to build the web server and the MongoDB driver to interact with the database.

Prerequisites

Before we begin, ensure you have the following installed:

  • Go (version 1.16 or later)

  • MongoDB Atlas Cluster

  • A code editor (e.g., Visual Studio Code

The source code for this project can be found on GitHub.

Project Structure

Create a new directory for your project and set up the following structure:

go-mongodb-auth/
├── controllers/
│   ├── handler.go
├── database/
│   ├── database.go
├── jwt/
│   ├── claims.go
│   ├── jwt.go
├── middleware/
│   ├── auth.go
├── models/
│   ├── user.go
├── utils/
│   ├── helper.go
│   ├── password.go
├── main.go
├── .env
├── go.mod
└── go.sum

Step 1: Initialize the Go Module

Navigate to your project directory and initialize a new Go module:

go mod init go-mongodb-auth

Step 2: Install Dependencies

Install the required packages:

go get github.com/gin-gonic/gin
go get go.mongodb.org/mongo-driver/mongo
go get github.com/golang-jwt/jwt/v5
go get github.com/google/uuid
go get github.com/joho/godotenv
go get github.com/joho/godotenv
go get golang.org/x/crypto/bcrypt

Step 3: Set Up a MongoDB Atlas Cluster

To store our data, we will use MongoDB Atlas. A database connection URL is required to connect the database. Follow the guide to set up a MongoDB Atlas cluster and get a connection URL.

Next, in the .env file, add the connection URL as an environment variable.

Step 4: Create the .env File

Create a .env file in the root of your project to store environment variables:

MONGODB_URI= <mongodb_URI>
MONGODB_DB_NAME= <db_name>
MONGODB_COLLECTION= <collection>

// jwt token
SECRET_KEY= <token>

Replace your_db_name and your_secret_key with your desired database name and a secure secret key.

Step 5: Set Up the Database Connection

In database/database.go, set up the MongoDB connection:

package database

import (
    "context"
    "log"
    "os"

    _ "github.com/joho/godotenv/autoload"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

type Config struct {
    MongoDBURI        string
    MongoDBName       string
    MongoDBCollection string
}

var config *Config
var DB *mongo.Database
var Client *mongo.Client

func init() {
    config = &Config{
        MongoDBURI:        os.Getenv("MONGODB_URI"),
        MongoDBName:       os.Getenv("MONGODB_DB_NAME"),
        MongoDBCollection: os.Getenv("MONGODB_COLLECTION"),
    }
}

func NewDBInstance() error {
    client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(config.MongoDBURI))
    if err != nil {
        log.Fatal(err)
    }
    DB = client.Database(config.MongoDBName)
    Client = client
    return nil
}

func GetDBCollection() *mongo.Collection {
    return DB.Collection(config.MongoDBCollection)
}

func Close() error {
    return Client.Disconnect(context.Background())
}

Step 6: Create User Model

In models/user.go, define the user model and functions for user operations:

package models

import (
    "context"
    "go-mongodb-auth/database"
    "go-mongodb-auth/utils"
    "time"

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

type User struct {
    ID        primitive.ObjectID `bson:"_id" json:"id"`
    UUID      string             `bson:"uuid" json:"uuid"`
    Email     string             `bson:"email" json:"email"`
    Username  string             `bson:"username" json:"username"`
    Password  string             `bson:"password" json:"-"`
    CreatedAt time.Time          `bson:"created_at" json:"created_at"`
    UpdatedAt time.Time          `bson:"updated_at" json:"updated_at"`
}

type RegisterUser  struct {
    Email    string `json:"email" binding:"required"`
    Username string `json:"username" binding:"required"`
    Password string `json:"password" binding:"required"`
}

func NewUser (user RegisterUser ) error {
    collection := database.GetDBCollection()
    _, err := collection.InsertOne(context.TODO(), &User {
        ID:        primitive.NewObjectID(),
        UUID:      utils.GetUUID(),
        Username:  user.Username,
        Email:     user.Email,
        Password:  user.Password,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    })
    return err
}

func GetUser ByEmail(email string) (*User , error) {
    collection := database.GetDBCollection()
    var user *User
    filter := bson.D{primitive.E{Key: "email", Value: email}}
    err := collection.FindOne(context.TODO(), filter).Decode(&user)
    if err != nil {
        return &User {}, err
    }
    return user, nil
}

func CheckUser (email string) bool {
    user, _ := GetUser ByEmail(email)
    return !user.ID.IsZero()
}

Step 7: Implement JWT Functionality

In jwt/claims.go, define the claims structure:

package jwt

import (
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
)

type Claims struct {
    Email string `json:"email"`
    jwt.RegisteredClaims
}

func NewClaims(email string) (*Claims, error) {
    tokenID, err := uuid.NewRandom()
    if err != nil {
        return &Claims{}, err
    }

    return &Claims{
        Email: email,
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        tokenID.String(),
            Issuer:    email,
            Subject:   email,
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 12)),
        },
    }, nil
}

In jwt/jwt.go, implement token creation and verification:

package jwt

import (
    "fmt"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

func CreateToken(email string) (string, *Claims, error) {
    claims, err := NewClaims(email)
    if err != nil {
        return "", &Claims{}, err
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString([]byte(os.Getenv("SECRET_KEY")))
    if err != nil {
        return "", &Claims{}, err
    }

    return tokenString, claims, nil
}

func VerifyToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("invalid token signing method")
        }
        return []byte(os.Getenv("SECRET_KEY")), nil
    })

    if err != nil {
        return nil, fmt.Errorf("error parsing token: %w", err)
    }

    claims, ok := token.Claims.(*Claims)
    if !ok {
        return nil, fmt.Errorf("invalid token claims")
    }

    if time.Now().After(claims.ExpiresAt.Time) {
        return nil, fmt.Errorf("token expired")
    }

    return claims, nil
}

Step 8: Create Authentication Middleware

In middleware/auth.go, implement the authentication middleware:

package middleware

import (
    "go-mongodb-auth/jwt"
    "go-mongodb-auth/models"
    "go-mongodb-auth/utils"
    "net/http"

    "github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        cookie, err := c.Cookie("Authorization")
        if err != nil || cookie == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "status":  http.StatusUnauthorized,
                "message": http.StatusText(http.StatusUnauthorized),
            })
            return
        }

        token := utils.RetrieveToken(cookie)

        claims, err := jwt.VerifyToken(token)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                "status":  http.StatusInternalServerError,
                "message": err.Error(),
            })
            return
        }

        user, err := models.GetUser ByEmail(claims.Email)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                "status":  http.StatusInternalServerError,
                "message": err.Error(),
            })
            return
        }

        c.Set("user", user)
        c.Next()
    }
}

Step 9: Create Controllers

In controllers/handler.go, implement the login and registration controllers:

package controllers

import (
    "go-mongodb-auth/jwt"
    "go-mongodb-auth/models"
    "go-mongodb-auth/utils"
    "net/http"

    "github.com/gin-gonic/gin"
)

func LoginController(c *gin.Context) {
    var user models.LoginUser
    if err := c.BindJSON(&user); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "status":  http.StatusUnprocessableEntity,
            "message": err.Error(),
        })
        return
    }

    if exists := models.CheckUser (user.Email); !exists {
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "status":  http.StatusUnprocessableEntity,
            "message": "user does not exist",
        })
        return
    }

    authUser , _ := models.GetUser ByEmail(user.Email)

    if err := utils.CheckPassword(user.Password, authUser .Password); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "status":  http.StatusUnprocessableEntity,
            "message": "Password is incorrect",
        })
        return
    }

    token, _, err := jwt.CreateToken(user.Email)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": err.Error(),
        })
        return
    }

    utils.GenerateAndStoreCookie(c, token)

    c.JSON(http.StatusOK, gin.H{
        "status": http.StatusOK,
        "data": gin.H{
            "accessToken": token,
            "user": gin.H{
                "username": authUser .Username,
                "email":    authUser .Email,
            },
        },
    })
}

func RegisterController(c *gin.Context) {
    var user models.RegisterUser
    if err := c.BindJSON(&user); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{
            "status":  http.StatusUnprocessableEntity,
            "message": err.Error(),
        })
        return
    }

    if exists := models.CheckUser (user.Email); exists {
        c.JSON(http.StatusConflict, gin.H{
            "status":  http.StatusConflict,
            "message": "user already exists",
        })
        return
    }

    user.Password, _ = utils.HashPassword(user.Password)

    err := models.NewUser (user)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": err.Error(),
        })
        return
    }

    token, _, err := jwt.CreateToken(user.Email)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": err.Error(),
        })
        return
    }

    utils.GenerateAndStoreCookie(c, token)

    c.JSON(http.StatusCreated, gin.H{
        "status": http.StatusCreated,
        "data": gin.H{
            "accessToken": token,
            "user": gin.H{
                "username": user.Username,
                "email":    user.Email,
            },
        },
    })
}

func UserAuthController(c *gin.Context) {
    data, exists := c.Get("user")
    if !exists {
        c.JSON(http.StatusBadRequest, gin.H{
            "status":  http.StatusBadRequest,
            "message": "Cannot retrieve user",
        })
        return
    }

    user := data.(*models.User)
    c.JSON(http.StatusOK, user)
}

Step 10: Implement Utility Functions

In utils/helper.go, implement helper functions:

package utils

import (
    "fmt"
    "strings"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/joho/godotenv"
)

func LoadEnv() error {
    err := godotenv.Load()
    return err
}

func GetUUID() string {
    tokenID, _ := uuid.NewRandom()
    return tokenID.String()
}

func RetrieveToken(cookie string) string {
    data := strings.Split(cookie, " ")
    return data[1]
}

func GenerateAndStoreCookie(c *gin.Context, token string) {
    token = fmt.Sprintf("Bearer %s", token)
    c.SetCookie("Authorization", token, 24*7*3600, "/", "", true, true)
}

In utils/password.go, implement password hashing functions:

package utils

import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return "", err
    }
    return string(hashed), nil
}

func CheckPassword(password, hashedPassword string) error {
    return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}

Step 11: Create the Main Function

In main.go, set up the server and routes:

package main

import (
    "context"
    "fmt"
    "go-mongodb-auth/controllers"
    "go-mongodb-auth/database"
    "go-mongodb-auth/middleware"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
)

func main() {
    database.NewDBInstance()
    done := make(chan bool)

    router := gin.Default()
    router.POST("/login", controllers.LoginController)
    router.POST("/register", controllers.RegisterController)
    router.GET("/user", middleware.AuthMiddleware(), controllers.UserAuthController)

    server := &http.Server{
        Addr:         ":8005",
        Handler:      router,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 5 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    go ShutdownServer(server, done)
    if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        fmt.Println(err)
        panic(err)
    }

    <-done
}

func ShutdownServer(server *http.Server, done chan bool) {
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Println("Server shutdown error", err)
        return
    }

    if err := database.Close(); err != nil {
        log.Println("Database shutdown error", err)
    }

    <-ctx.Done()
    log.Println("Server exiting...")
    done <- true
}

Step 12: Run the Application

To run your application, execute the following command in your terminal:

go run main.go

Your server should now be running on http://localhost:8005.

Step 13: Testing the API

You can use tools like Postman or cURL to test the API endpoints:

  1. Register a User:

    • Endpoint: POST /register

    • Body:

      JSON

        {
          "email": "user@example.com",
          "username": "username",
          "password": "password"
        }
      

  2. Login a User:

    • Endpoint: POST /login

    • Body:

      JSON

        {
          "email": "user@example.com",
          "password": "password"
        }
      

  3. Access Protected Route:

    • Endpoint: GET /user

    • Headers:

      • Authorization: Bearer token (from the login response)

Conclusion

In this tutorial, we have built a simple JWT authentication system using Go and MongoDB. We covered user registration, login, and protected routes using JWTs. You can expand this application by adding more features like user roles, password reset functionality, and more robust error handling.