Mobile OTP based authentication in golang

Harsh MangalamHarsh Mangalam
12 min read

In this post, we will develop a mobile OTP-based authentication API in Golang.

Tech stack

Golang

Go (or Golang) is an open-source programming language developed by Google. It emphasizes simplicity, efficiency, and concurrency support. Go is widely used for web development, system programming, and cloud-native applications.

https://go.dev/

Gofiber

GoFiber is a lightweight and fast web framework written in Go (Golang). It is built on top of Fasthttp, making it an efficient choice for building high-performance APIs and web applications. GoFiber offers features like middleware support, routing, and context handling.

https://gofiber.io/

MongoDB

MongoDB is a NoSQL, document-oriented database, known for its flexibility and scalability. It stores data in JSON-like BSON format, enabling easy manipulation and indexing. MongoDB is widely used for handling large-scale, unstructured, and real-time data in modern applications.

https://www.mongodb.com/

Twilio

Twilio is a cloud communications platform that enables developers to integrate messaging, voice, and video functionalities into their applications using APIs. It simplifies the process of building communication features such as SMS, MMS, phone calls, and more, allowing businesses to engage with their customers programmatically.

https://www.twilio.com/

Create a new directory auth and init go modules inside the project.

go mod init auth

Install required packages

go get github.com/gofiber/fiber/v2
go get -u github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
go get github.com/twilio/twilio-go
go get go.mongodb.org/mongo-driver/mongo

Create folder structure for our project

Project
├── README.md
├── config
│   └── config.go
├── database
│   ├── connection.go
│   └── database.go
├── go.mod
├── go.sum
├── handler
│   └── auth.go
├── main.go
├── middleware
│   └── auth.go
├── model
│   └── user.go
├── router
│   └── router.go
├── schema
│   ├── auth.go
│   └── response.go
└── util
    ├── twilio.go
    └── user.go
  • config folder contains configurations i.e. env variable configuration.

  • database folder contains database connections and types related to db i.e. mongodb driver setup.

  • handler folder contains route handler i.e. register handler, login handler etc...

  • middleware folder contains route middleware that runs before or after any handler executes i.e. auth jwt validation middleware.

  • model contains MongoDB schema i.e. User schema.

  • router contains route initialization and maps API routes to specific route handlers i.e. auth routes.

  • schema contains application schema i.e. incoming request body schema.

  • util contains reusable database query and application helper function i.e. Find user by phone, send sms etc...

  • .env contains environment variables i.e. Twilio api-key, mongodb-uri etc...

  • copy all .env.example and paste it inside .env.

  • main.go is the entry point of the Go application.

Next, I will show you the required routes for this project.

- /api/auth
    - /register
    - /login
    - /verify_otp
    - /resend_otp
    - /me

main.go

package main

import (
    "auth/database"
    "auth/router"
    "log"
)

func main() {
    app := router.New()
    err := database.Connect()
    if err != nil {
        panic(err)
    }
    log.Fatal(app.Listen(":3000"))
}

If you are familiar with Express then you will find gofiber syntax is similar to Express (Node web framework).

Here we have done 3 things:-

  • Initialized our router.

  • Connected with MongoDB database.

  • And started our server to listen on port 3000

Let`s go through each step one by one

database/database.go

package database

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

const Users = "users"

type MongoInstance struct {
    Client *mongo.Client
    Db     *mongo.Database
}

Users cons store collection name so that we can reuse this variable where we required users collection for query users data.

We have also created one struct to store MongoDB client and database pointers.

database/connection.go

package database

import (
    "auth/config"
    "context"
    "log"

    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

var Mg MongoInstance

func Connect() error {
    dbName := config.Config("DATABASE_NAME")
    uri := config.Config("DATABASE_URI") + dbName
    client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI(uri))
    if err != nil {
        return err
    }
    log.Printf("Connected with databse %s", dbName)
    db := client.Database(dbName)
    Mg = MongoInstance{
        Client: client,
        Db:     db,
    }

    return nil
}

We have defined package level variable Mg and store database info here so that we can use this variable to query our database.

DATABASE_NAME and DATABASE_URI is an environment variable whose value is loaded from the .env file.

The context package is commonly used in Go to pass context information between different function calls in a chain or across concurrent goroutines. This package provides the ability to propagate cancellation signals and deadlines through the call stack.

The context.TODO() function in Go's context package is used to create a new, empty context when you don't have a more specific context available or when you are not sure which context to use.

config/config.go

package config

import (
    "fmt"
    "os"

    "github.com/joho/godotenv"
)

// Config func to get env value
func Config(key string) string {
    // load .env file
    err := godotenv.Load(".env")
    if err != nil {
        fmt.Print("Error loading .env file")
    }
    return os.Getenv(key)
}

Here we have used the github.com/joho/godotenv package to load the env variables from the .env file.

router/router.go

package router

import (
    "auth/handler"
    "auth/middleware"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/compress"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/monitor"
)

func New() *fiber.App {
    app := fiber.New()
    api := app.Group("/api")
    auth := api.Group("/auth")

    auth.Post("/register", handler.Register)
    auth.Post("/login", handler.Login)
    auth.Post("/verify_otp", handler.VerifyOTP)
    auth.Post("/resend_otp", handler.ResendOTP)
    auth.Get("/me", middleware.Protected(), handler.GetCurrentUser)

    return app
}

We have initialized fiber and added Groups for api and auth so that we do need to type each path again and again. It also provides a better way to apply middleware to a group of routes instead of applying the same middleware to each route one by one.

model/user.go

package model

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

type User struct {
    ID    primitive.ObjectID `json:"id" bson:"_id"`
    Name  string             `json:"name"`
    Phone string             `json:"phone"`
    Otp   string             `json:"otp,omitempty"`
}

We have created a User model to store user data consistently in MongoDB database.

schema/response.go

package schema

type ResponseHTTP struct {
    Success bool   `json:"success"`
    Data    any    `json:"data"`
    Message string `json:"message"`
}

We will use this schema to send HTTP responses in a consistent way.

schema/auth.go

package schema

type RegisterBody struct {
    Name  string `json:"name"`
    Phone string `json:"phone"`
}

type LoginSchema struct {
    Phone string `json:"phone"`
}

type VerifyOTPSchema struct {
    Phone string `json:"phone"`
    Otp   string `json:"otp"`
}

We will use this schema to parse the incoming request body data.

handler/auth.go

package handler

import (
    "auth/model"
    "auth/schema"
    "auth/util"

    "github.com/gofiber/fiber/v2"
)

We have added the required package imports later we will add many utility functions in util.

// ...
// ...
func Register(c *fiber.Ctx) error {
    // request body data
    body := new(schema.RegisterBody)
    if err := c.BodyParser(body); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    // validate duplicate mobile number

    user, err := util.FindUserByPhone(body.Phone)

    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    if user != nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: "Phone number already in use",
        })
    }

    // create new user

    id, err := util.InsertUser(body)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
        Success: true,
        Data: fiber.Map{
            "id": id,
        },
        Message: "Account registered successfully",
    })
}

Next in auth handler, we have added Register function.

  • First, it will parse request body data and store it in the body.

  • Next, it will check phone number duplication.

  • If everything will fine then it will create a new user in the database.

func Login(c *fiber.Ctx) error {
    // request body data
    body := new(schema.LoginSchema)
    if err := c.BodyParser(body); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }
    // find phone in database
    user, err := util.FindUserByPhone(body.Phone)

    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    if user == nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: "Phone number not exists",
        })
    }

    otp := util.GenerateRandomNumber()

    // save otp in database
    util.UpdateUser(user.ID, map[string]any{
        "otp": otp,
    })
    // send otp to user phone

    err = util.SendOTP(user.Phone, otp)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
        Success: true,
        Data:    nil,
        Message: "Otp sent to registered mobile number",
    })

}

Here we have added a login handler it will send OTP to a mobile number if the specific user is already registered.

  • First, it will parse request body data and store it in the body.

  • Next, it will verify the user from the database using the mobile number.

  • Generate OTP and update otp field of users collection in the database.

  • And send generated OTP to the user's mobile number.

func VerifyOTP(c *fiber.Ctx) error {
    // request body data
    body := new(schema.VerifyOTPSchema)
    if err := c.BodyParser(body); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    // find phone in database
    user, err := util.FindUserByPhone(body.Phone)

    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    if user == nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: "Phone number not exists",
        })
    }

    if user.Otp != body.Otp {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: "Incorrect Otp",
        })
    }

    // generate jwt token
    token, err := util.GenerateJWT(user.ID.Hex())
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    // remove old otp from db
    util.UpdateUser(user.ID, map[string]any{
        "otp": "",
    })

    return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
        Success: true,
        Data: fiber.Map{
            "token": "Bearer " + token,
        },
        Message: "Account login successfully",
    })
}

This handler will verify the user's OTP and return the JWT Bearer token in response if OTP will be correct.

  • First, it will parse request body data and store it in the body.

  • Verify provided phone number.

  • Verify provided OTP.

  • Create a JWT token with userId as a payload.

  • Remove old OTP from the user collection.

func ResendOTP(c *fiber.Ctx) error {
    // request body data
    body := new(schema.VerifyOTPSchema)
    if err := c.Status(fiber.StatusBadRequest).BodyParser(body); err != nil {
        return c.JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    // find phone in database
    user, err := util.FindUserByPhone(body.Phone)

    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    if user == nil {
        return c.Status(fiber.StatusBadRequest).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: "Phone number not exists",
        })
    }

    otp := util.GenerateRandomNumber()

    // save otp in database
    util.UpdateUser(user.ID, map[string]any{
        "otp": otp,
    })
    // send otp to user phone

    err = util.SendOTP(user.Phone, otp)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(schema.ResponseHTTP{
            Success: false,
            Data:    nil,
            Message: err.Error(),
        })
    }

    return c.Status(fiber.StatusCreated).JSON(schema.ResponseHTTP{
        Success: true,
        Data:    nil,
        Message: "Sent otp to registered mobile number",
    })
}

This handler will handle resend of OTP to the specific mobile number.

func GetCurrentUser(c *fiber.Ctx) error {
    user := c.Locals("user").(*model.User)
    user.Otp = ""
    return c.Status(fiber.StatusOK).JSON(schema.ResponseHTTP{
        Success: true,
        Data:    user,
        Message: "Get current user",
    })
}

This handler will return the currently logged-in user. We have added user value in locals from auth middleware. We removed the OTP value so that the user can't get otp in the response.

util/user.go

package util

import (
    "auth/config"
    "auth/database"
    "auth/model"
    "auth/schema"
    "context"
    "math/rand"
    "strconv"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
)

func FindUserByPhone(phone string) (*model.User, error) {
    // Create a context and a collection instance
    ctx := context.TODO()
    collection := database.Mg.Db.Collection(database.Users)

    // Create a filter to find the user by phone number
    filter := bson.M{"phone": phone}

    // Create a variable to store the result
    var result model.User

    // Find the user with the given phone number
    err := collection.FindOne(ctx, filter).Decode(&result)

    if err != nil {
        if err == mongo.ErrNoDocuments {
            // If the error is ErrNoDocuments, it means no user was found
            return nil, nil
        }
        // Handle other potential errors
        return nil, err
    }

    return &result, nil
}

func InsertUser(user *schema.RegisterBody) (any, error) {
    // Create a context and a collection instance
    ctx := context.TODO()
    collection := database.Mg.Db.Collection(database.Users)

    // Insert the user into the collection
    result, err := collection.InsertOne(ctx, user)
    return result.InsertedID, err
}

func UpdateUser(userID primitive.ObjectID, updatedFields map[string]any) error {
    // Create a context and a collection instance
    ctx := context.TODO()
    collection := database.Mg.Db.Collection(database.Users)

    // Create a filter to find the user by ID
    filter := bson.M{"_id": userID}

    // Create an update with the provided fields
    update := bson.M{"$set": updatedFields}

    // Update the user document in the collection
    _, err := collection.UpdateOne(ctx, filter, update)
    return err

}

func FindUserById(userId string) (*model.User, error) {
    // Create a context and a collection instance
    id, err := primitive.ObjectIDFromHex(userId)
    if err != nil {
        return nil, err
    }
    ctx := context.TODO()
    collection := database.Mg.Db.Collection(database.Users)

    // Create a filter to find the user by phone number
    filter := bson.M{"_id": id}

    // Create a variable to store the result
    var result model.User

    // Find the user with the given phone number
    err = collection.FindOne(ctx, filter).Decode(&result)

    if err != nil {
        if err == mongo.ErrNoDocuments {
            // If the error is ErrNoDocuments, it means no user was found
            return nil, nil
        }
        // Handle other potential errors
        return nil, err
    }

    return &result, nil
}

func GenerateRandomNumber() string {
    // Generate a random number between 1000 and 9999 (inclusive)
    num := rand.Intn(9000) + 1000
    return strconv.Itoa(num)
}

func GenerateJWT(id string) (string, error) {
    token := jwt.New(jwt.SigningMethodHS256)
    claims := token.Claims.(jwt.MapClaims)
    claims["userId"] = id
    claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
    return token.SignedString([]byte(config.Config("SECRET")))

}

Here we have added all utility helper function to query our database and auth-related helper functions.

util/twilio.go

package util

import (
    "auth/config"
    "fmt"
    "log"

    "github.com/twilio/twilio-go"

    openapi "github.com/twilio/twilio-go/rest/api/v2010"
)

func SendOTP(to string, otp string) error {
    accountSid := config.Config("TWILIO_ACCOUNT_SID")
    authToken := config.Config("TWILIO_AUTH_TOKEN")

    client := twilio.NewRestClientWithParams(twilio.ClientParams{
        Username: accountSid,
        Password: authToken,
    })

    params := &openapi.CreateMessageParams{}

    params.SetTo(to)
    params.SetFrom(config.Config("TWILIO_PHONE_NUMBER"))

    msg := fmt.Sprintf("Your OTP is %s", otp)
    params.SetBody(msg)

    _, err := client.Api.CreateMessage(params)
    if err != nil {
        log.Println(err.Error())
        return err
    }
    log.Println("SMS sent successfully!")

    return nil
}

Here we have used the Twilio sms service to send OTP to mobile numbers. You need to create an account in Twilio to use their services.

You can generate your virtual mobile number from the Twilio dashboard and also copy the account sid and auth token from the dashboard.

middleware/auth.go

package middleware

import (
    "auth/config"
    "auth/schema"
    "auth/util"

    jwtware "github.com/gofiber/contrib/jwt"
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v5"
)

// Protected protect routes
func Protected() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningKey:     jwtware.SigningKey{Key: []byte(config.Config("SECRET"))},
        ErrorHandler:   jwtError,
        SuccessHandler: jwtSuccess,
        ContextKey:     "payload",
    })
}

func jwtSuccess(c *fiber.Ctx) error {
    payload := c.Locals("payload").(*jwt.Token)
    claims := payload.Claims.(jwt.MapClaims)
    userId := claims["userId"].(string)
    user, err := util.FindUserById(userId)
    if err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(schema.ResponseHTTP{
            Success: false,
            Message: "User not exists",
            Data:    nil,
        })
    }
    c.Locals("user", user)
    return c.Next()
}
func jwtError(c *fiber.Ctx, err error) error {
    if err.Error() == "Missing or malformed JWT" {
        return c.Status(fiber.StatusBadRequest).
            JSON(schema.ResponseHTTP{Success: false, Message: "Missing or malformed JWT", Data: nil})
    }
    return c.Status(fiber.StatusUnauthorized).
        JSON(schema.ResponseHTTP{Success: false, Message: "Invalid or expired JWT", Data: nil})
}

Protected middleware will do the following thing:-

  • Verify and parse the JWT token and get a payload from it.

  • Handle error using jwtError handler function in case of wrong or expired JWT token.

  • If there is no error then it will execute jwtSuccess.

  • We find the user by userId.

  • Add user info in locals to access from any hander.

  • c.Next() will execute the next handler function.

By default ContextKey was user but we have changed it to payload .

You can explore complete codebase in github

https://github.com/harshmangalam/golang-mobile-otp-auth

0
Subscribe to my newsletter

Read articles from Harsh Mangalam directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Harsh Mangalam
Harsh Mangalam

Open source developer and blogger