Implement 2FA in Go Web Apps With Twilio Verify and Gin-Gonic

May 10, 2024
Written by
Jesuleye Marvellous Oreoluwa
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Implement 2fa in Go Web Apps With Twilio Verify and Gin-Gonic

Two-factor verification (2FA) is a security feature that protects your online accounts. For example, it could require you to enter a code sent to your phone or email, in addition to your username and password, when you log in. This way, even if someone knows your password they cannot access your account without the code.

In this tutorial, you will learn how to implement 2FA in your Go web applications using Twilio Verify and Gin-Gonic.

Prerequisites

To follow along with this tutorial, you will need the following:

Two-factor verification in Go and its importance

2FA is important for web applications because in addition to helping prevent unauthorized access, it helps prevent data breaches and identity theft. By using 2FA, you can enhance the security of your users and your application, and comply with best practices and standards in the industry. 2FA also improves user trust and confidence in your service, as they know their accounts are better protected.

What is Gin-Gonic?

Gin-Gonic is a web framework for the Go programming language. Its powerful, streamlined architecture provides a lightweight HTTP router that is fast and has robust middleware compatibility. Gin-Gonic is a popular option for creating web applications and APIs in Go due to its efficiency, simplicity, and user-friendliness. Gin-Gonic allows developers to quickly and effectively construct scalable web services with minimal boilerplate code.

What is Twilio Verify?

Twilio Verify is a service that simplifies sending and verifying one-time passcodes (OTPs) for Multi-Factor Authentication (MFA). Twilio Verify can send OTPs via SMS, voice, email, or WhatsApp, and verify them with a simple API call. Twilio Verify also handles global compliance , routing, and formatting complexities.

Twilio Verify offers several compelling features that make it an excellent choice for developers looking to integrate MFA into their applications. Here are some of its standout features:

  • Multi-Channel Verification: Twilio Verify supports various channels, including SMS, voice, and email, giving users flexibility in receiving their verification codes.
  • Global Reach: With Twilio’s extensive network, you can reach users anywhere worldwide, ensuring your application is globally accessible.
  • Simplified Compliance: Twilio Verify helps adhere to various compliance requirements, making it easier to manage the complexities of global communications laws.

Create the Go app

Retrieve your Twilio credentials

To get started, sign in to your Twilio Console , scroll to the bottom of the page, and you should see your Account SID and Auth token, as shown in the image below, in the Account Info panel. Copy these values and keep them in a safe space for now.

Next, navigate to the Explore Products > Verify (under User Authentication & Identity) > Services page, via the left-hand side menu bar of the Twilio Console.

There, you should see a list of the Twilio Verify applications, if you’ve created any in the past. As a first-time user, a demo application is also created for you by default. Select this demo application (or create a new one) and then copy the Service SID and keep it somewhere as well.

Implement 2FA with Twilio Verify and Gi-Gonic

Let's proceed with creating a new Go application, by running the following command:

mkdir go-mfa-twilio
cd go-mfa-twilio
go mod init go-mfa-twilio

This command sets up a new Go module for us. Next, we’ll need to install Twilio's Go Helper Library, gin-gonic, gorilla/sessions for session management, and godotenv to process environment variables, by running the following command.

go get github.com/twilio/twilio-go github.com/gin-gonic/gin github.com/gorilla/sessions github.com/joho/godotenv

Next, create a new .env file in your project root directory and update it with the Twilio credentials you retrieved in the previous step, as shown below.

TWILIOAUTHTOKEN  = "<<YOUR_TWILIO_AUTH_TOKEN>>"
TWILIOSERVICESID = "<<YOUR_TWILIO_VERIFY_SERVICESID>>"

With this basic setup complete, you can proceed with creating the application.

Create HTML templates

Let’s start with creating the static HTML pages for the app. In your project's root directory, create a new folder called templates and create three new files inside it:

  • signin.html: to display a form for the user to complete the first level of authentication.
  • enter-code.html: to display another form for the user to enter the OTP code sent to them.
  • success.html
: a success page that welcomes the user after they've completed the authentication process.

Inside the templates/signin.html page, paste the code below.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Sign In</title>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <style>
      .form-control {
        width: 25vw;
        height: 50px;
      }
    </style>
  </head>
  <body>
    <div
      class="container d-flex justify-content-center align-items-center min-vh-100"
    >
      <div class="border p-4">
        <h2 class="mb-3">Sign In</h2>
        <form action="/signin" method="post">
          <div class="mb-3">
            <label for="username" class="form-label">Username:</label>
            <input
              type="text"
              id="username"
              name="username"
              required
              class="form-control"
              autocomplete="off"
            />
          </div>
          <div class="mb-3">
            <label for="password" class="form-label">Password:</label>
            <input
              type="password"
              id="password"
              name="password"
              required
              class="form-control"
              autocomplete="current-password"
            />
          </div>
          <button class="btn btn-primary w-100" type="submit">Sign In</button>
        </form>
      </div>
    </div>
  </body>
</html>

The code above creates a basic sign-in form that let’s users enter their username and password, and sends the form request to /signin using the POST method.

Then, in enter-code.html paste the following code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Enter Verification Code</title>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <style>
      .form-control {
        width: 25vw;
        height: 50px;
      }
    </style>
  </head>
  <body>
    <div
      class="container d-flex justify-content-center align-items-center min-vh-100"
    >
      <div class="border p-4">
        <h2>Enter Verification Code</h2>
        <form action="/verify-code" method="post">
          <label for="code">Code:</label>
          <input
            type="text"
            id="code"
            name="code"
            required
            class="form-control"
          />
          <button type="submit" class="btn btn-success w-100 mt-3">
            Verify
          </button>
        </form>
      </div>
    </div>
  </body>
</html>

The code above is the markup for the page where users will enter the OTP code sent to them by Twilio Verify. In addition to the code verification form, we also receive a message from the server and display it on this page. This message is set to display a custom message indicating the phone number that the code was sent to.

Finally, in success.html, paste the code below.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Success</title>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
  </head>
  <body>
    <div
      class="container d-flex justify-content-center align-items-center min-vh-100"
    >
      <div class="border p-4">
        <h1>Welcome, {{.Username}}!</h1>
        <p>You have successfully logged in.</p>
      </div>
    </div>
  </body>
</html>

This code displays the user's username along with a friendly welcome message.

Now, let’s proceed with registering the template directory as well as creating the necessary routes with Gin-Gonic. To simplify things, we’ll be mocking an array of users instead of using a database.

Create static users and routes

To proceed, create a new main.go file in your project root directory and paste the following code inside it.

package main

import (
	"encoding/gob"
	"log"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"github.com/twilio/twilio-go"
	verify "github.com/twilio/twilio-go/rest/verify/v2"
)

var (
	store            = sessions.NewCookieStore([]byte("Th!s1s_a_s3cr3t_k3y"))
	twilioAccountSID string
	twilioAuthToken  string
	twilioServiceSID string
)

type User struct {
	Username     string
	Email        string
	PhoneNumber  string
	Password     string
	Preferred2FA string
}

var users = []User{
	{
		Username:     "john_doe",
		Email:        "john@example.com",
		PhoneNumber:  "+2347011809051",
		Password:     "password123",
		Preferred2FA: "sms",
	},
	{
		Username:     "jane_doe",
		Email:        "jane@example.com",
		PhoneNumber:  "+109876XXXXX",
		Password:     "password123",
		Preferred2FA: "email",
	},
}

func init() {
	gob.Register(User{})
	if err := godotenv.Load(); err != nil {
		log.Fatal("Error loading .env file")
	}
	twilioAccountSID = os.Getenv("TWILIOACCOUNTSID")
	twilioAuthToken = os.Getenv("TWILIOAUTHTOKEN")
	twilioServiceSID = os.Getenv("TWILIOSERVICESID")
}

func main() {
	router := gin.Default()
	router.LoadHTMLGlob("templates/*")

	router.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "signin.html", nil)
	})
	
	router.GET("/enter-code", func(c *gin.Context) {
		c.HTML(http.StatusOK, "enter-code.html", nil)
	})

	router.POST("/signin", func(c *gin.Context) {
		var loginDetails struct {
			Username string `form:"username"`
			Password string `form:"password"`
		}

		if err := c.ShouldBind(&loginDetails); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form submission"})
			return
		}

		user, authenticated := authenticateUser(loginDetails.Username, loginDetails.Password)
		if !authenticated {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
			return
		}

		session, _ := store.Get(c.Request, "session_name")
		session.Values["user"] = user
		err := session.Save(c.Request, c.Writer)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session", "details": err.Error()})
			return
		}

		if user.Preferred2FA == "sms" {
			err = sendVerificationCode(user.PhoneNumber)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send verification code", "details": err.Error()})
				return
			}
			c.Redirect(http.StatusFound, "/enter-code")

		}
	})

	router.POST("/verify-code", func(c *gin.Context) {
		session, _ := store.Get(c.Request, "session_name")
		user, ok := session.Values["user"].(User)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
			return
		}

		var form struct {
			Code string `form:"code"`
		}
		if err := c.ShouldBind(&form); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form submission"})
			return
		}

		verified, err := verifyCodeWithTwilio(user.PhoneNumber, form.Code)
		if err != nil || !verified {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
			return
		}

		c.Redirect(http.StatusFound, "/success")
	})

	router.GET("/success", func(c *gin.Context) {
		session, err := store.Get(c.Request, "session_name")
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve session"})
			return
		}

		user, ok := session.Values["user"].(User)
		if !ok {
			c.Redirect(http.StatusFound, "/")
			return
		}

		c.HTML(http.StatusOK, "success.html", gin.H{
			"Username": user.Username,
		})
	})

	router.Run(":8080")
}

func sendVerificationCode(phoneNumber string) error {
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: twilioAccountSID,
		Password: twilioAuthToken,
	})

	params := &verify.CreateVerificationParams{}
	params.SetTo(phoneNumber)
	params.SetChannel("sms")

	_, err := client.VerifyV2.CreateVerification(twilioServiceSID, params)
	return err
}

func authenticateUser(username, password string) (User, bool) {
	for _, user := range users {
		if user.Username == username && user.Password == password {
			return user, true
		}
	}
	return User{}, false
}

func verifyCodeWithTwilio(phoneNumber, code string) (bool, error) {
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: twilioAccountSID,
		Password: twilioAuthToken,
	})

	params := &verify.CreateVerificationCheckParams{}
	params.SetTo(phoneNumber)
	params.SetCode(code)

	resp, err := client.VerifyV2.CreateVerificationCheck(twilioServiceSID, params)
	if err != nil {
		return false, err
	}
	return resp.Status != nil && *resp.Status == "approved", nil
}

The code above imports the necessary packages. It then defines the required Twilio credentials and creates two static users to mock a database. Here, ensure that the phone number added for one of the users is the same as your verified Twilio phone number, if you're using a trial account.

Next, we register the template directory we created earlier, and render the signin.html template for requests to the default (/) route. Furthermore, we also register a new POST /signin route, which is triggered when users submit the initial sign-in form. Handled by a new function named sendVerificationCode(), it checks if they are one of the users defined earlier, stores necessary details in session, and sends them an OTP code.

Following the route definition, let's define the sendVerificationCode() by pasting the code below at the end of main.go:

func sendVerificationCode(phoneNumber string) error {
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: twilioAccountSID,
		Password: twilioAuthToken,
	})

	params := &verify.CreateVerificationParams{}
	params.SetTo(phoneNumber)
	params.SetChannel("sms")

	_, err := client.VerifyV2.CreateVerification(twilioServiceSID, params)
	return err
}

This code uses Twilio's Go Helper Library package to send the OTP to the user's phone number via SMS.

With this setup complete, once users sign in successfully, they should be redirected to enter-code.html to enter their OTP code.

Verify the 2FA code and redirect the user to the welcome route

The code entered by the user in the form in enter-code.html is sent to the verify-code route. So, we need to define this route as well. Update your previous routes definition in the main() function in main.go to include the two below.

router.POST("/verify-code", func(c *gin.Context) {
	session, _ := store.Get(c.Request, "session_name")
	user, ok := session.Values["user"].(User)
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
		return
	}

	var form struct {
		Code string `form:"code"`
	}
	if err := c.ShouldBind(&form); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form submission"})
		return
	}

	// Verify the code with Twilio
	verified, err := verifyCodeWithTwilio(user.PhoneNumber, form.Code)
	if err != nil || !verified {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
		return
	}

	// Redirect to success page or log in the user
	c.Redirect(http.StatusFound, "/success")
})

router.GET("/success", func(c *gin.Context) {
	session, err := store.Get(c.Request, "session_name")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve session"})
		return
	}

	user, ok := session.Values["user"].(User)
	if !ok {
		// Handle the case where the session does not exist or the user is not found
		c.Redirect(http.StatusFound, "/")
		return
	}

	c.HTML(http.StatusOK, "success.html", gin.H{
		"Username": user.Username,
	})
})

The code above handles POST requests sent to /verify-code and GET requests sent to /success. For the /success endpoint, it retrieves the signed-in user’s username and assigns it to the success.html template, displaying a custom welcome message with their username.

For /verify-code, it checks the user’s session data to see if they’re a valid user, and calls verifyCodeWithTwilio() to verify the code which they provide. So, next, add the verifyCodeWithTwilio() function, below to main.go:

func verifyCodeWithTwilio(phoneNumber, code string) (bool, error) {
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: twilioAccountSID,
		Password: twilioAuthToken,
	})

	params := &verify.CreateVerificationCheckParams{}
	params.SetTo(phoneNumber)
	params.SetCode(code)

	resp, err := client.VerifyV2.CreateVerificationCheck(twilioServiceSID, params)
	if err != nil {
		return false, err
	}

	return resp.Status != nil && *resp.Status == "approved", nil
}

With all of these updates, your main.go file should be the same as the entire content in this GitHub Gist. The entire code for this tutorial is also hosted here.

Test the app

Now, let’s start the application with the following command:

go run main.go

Open http://localhost:8080 in your preferred browser and you should see the sign-in page as shown below.

Enter a valid username and password, choosing from the ones we defined earlier, click Sign in, and an OTP should be sent to the mobile number you added for the user. You will also be redirected to a page to enter the OTP as shown below.

Enter the OTP sent to your mobile number and click Verify. You should then be redirected to the success page.

Conclusion

In this article, we looked at how to add two-factor authentication to Go apps using the Gin-Gonic framework and Twilio Verify. By adding a second layer of verification, we recognized how important two-factor authentication (2FA) is to improving online application security.

We also learned how to use the Gin-Gonic framework to integrate Twilio's Verify Service into our Go application, which sped up the development process.

I'm Jesuleye Marvellous Oreoluwa, a software engineer who is passionate about teaching technology and enjoys making music in my own time.