Password-less Auth in Go – Hands-on Part 1

abhikbanerjee99

Abhik Banerjee

Posted on February 24, 2024

Password-less Auth in Go – Hands-on Part 1

Picking up right where we left off, we will start with the packages before we come to the main package. This will mean taking a bottom-up approach to coding our Go server. So, without further ado…

Utility Package

Why are we choosing this one first? For the simple reason that the utils package in a Go project contains most of the independent code. Other packages typically use this package. In our utils folder inside pkg folder, we will create a utils.go which will look like the code below:

package utils

import (
    "crypto/rand"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "regexp"
    "time"

    "github.com/go-playground/validator/v10"
    "github.com/golang-jwt/jwt/v5"
    "github.com/joho/godotenv"
    "github.com/sendgrid/sendgrid-go"
    "github.com/sendgrid/sendgrid-go/helpers/mail"
    "github.com/twilio/twilio-go"
    api "github.com/twilio/twilio-go/rest/api/v2010"
    "go.mongodb.org/mongo-driver/bson/primitive"
)

var validate = validator.New()

type GenericJsonResponseDTO struct {
    Message string `json:"message"`
}

// DecodeJSONRequest decodes the JSON request body into the provided interface and validates it.
func DecodeJSONRequest(r *http.Request, v interface{}) error {
    err := json.NewDecoder(r.Body).Decode(v)
    if err != nil {
        return err
    }

    // Validate the decoded struct
    return validate.Struct(v)
}

// EncodeJSONResponse encodes the provided interface as JSON and writes it to the response writer.
func EncodeJSONResponse(w http.ResponseWriter, status int, v interface{}) error {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    return json.NewEncoder(w).Encode(v)
}

func GetENV(name string) string {
    err := godotenv.Load(".env.local")
    if err != nil {
        log.Fatal("ERROR while loading the env file")
        log.Fatal(err)
    }
    return os.Getenv(name)
}

func IsValidPhoneNumber(phoneNo string) bool {
    e164Regex := `^\+[1-9]\d{1,14}$`
    re := regexp.MustCompile(e164Regex)

    return re.Find([]byte(phoneNo)) != nil
}

func GenerateJWT(id string) (string, error) {
    claims := jwt.RegisteredClaims{
        // Also fixed dates can be used for the NumericDate
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 2)),
        Issuer:    GetENV("JWT_ISSUER"),
        Subject:   id,
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(GetENV("JWT_SECRET")))
}

func ValidateJWT(tokenString string) (jwt.MapClaims, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {

        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("Invalid Token Signing Method: %v", token.Header["alg"])
        }

        return []byte(GetENV("JWT_SECRET")), nil
    })
    if err != nil {
        return nil, err
    }

    if !token.Valid {
        return nil, fmt.Errorf("Invalid Token")
    }
    if claims, ok := token.Claims.(jwt.MapClaims); ok {
        return claims, nil
    } else {
        return nil, fmt.Errorf("Invalid Token Claim")
    }

}

func OTPGenerator() (string, error) {
    const otpChars = "0123456789"
    buffer := make([]byte, 8)
    _, err := rand.Read(buffer)
    if err != nil {
        return "", err
    }

    otpCharsLength := len(otpChars)
    for i := 0; i < 8; i++ {
        buffer[i] = otpChars[int(buffer[i])%otpCharsLength]
    }

    return string(buffer), nil
}

func SendOTPMail(receipientEmail string, otp string) error {
    from := mail.NewEmail(GetENV("SENDER_NAME"), GetENV("SENDER_EMAIL")) // Change to your verified sender
    subject := "Your Login OTP"
    to := mail.NewEmail(receipientEmail, receipientEmail)
    plainTextContent := fmt.Sprintf("Your OTP is %s", otp)
    htmlContent := fmt.Sprintf("Your OTP is <strong>%s</string>", otp)
    message := mail.NewSingleEmail(from, subject, to, plainTextContent, htmlContent)
    client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY"))

    if _, err := client.Send(message); err != nil {
        log.Println("[ERROR]", err)
        return err
    }
    return nil

}

func SendOTPSms(receipientNo string, otp string) error {
    client := twilio.NewRestClient()

    params := &api.CreateMessageParams{}
    params.SetBody(fmt.Sprintf("Your OTP for Login is %s.", otp))
    params.SetFrom(GetENV("TWILLIO_PHONE_NO"))
    params.SetTo(receipientNo)

    _, err := client.Api.CreateMessage(params)
    return err
}

func IsValidObjectID(id string) bool {
    // Use a regular expression to check for the correct format (hexadecimal, 24 characters).
    objectIDRegex := regexp.MustCompile(`^[0-9a-fA-F]{24}$`)
    if !objectIDRegex.MatchString(id) {
        return false
    }

    // Use the MongoDB Go driver to try parsing the string as an ObjectID.
    _, err := primitive.ObjectIDFromHex(id)
    return err == nil
}

Enter fullscreen mode Exit fullscreen mode

We will use the Go-Validator module to validate the type of our request body. We initialize the validator at line 23. The GenericJsonResponseDTO contains a generic structure of a response body. We can use it by itself or by embedding it inside another DTO as we will see later. The DecodeJSONRequest() function takes in the request and then unmarshals the request body into the struct passed in the second argument. Because we are going to use it with a variety of DTOs, we use the open-ended interface{} type in Go. This will allow us to pass any struct.

Next, we have the EncodeJSONRequest() function. This function takes the ResponseWriter interface that is used by a Go HTTP handler to construct an HTTP response, an HTTP response status code, and an interface (typically our response struct DTO) and marshals the data in the struct DTO to the response body.

The GetENV() function at line 47 is a wrapper to help us get any environment variable in a single line using the godotenv package. The point to note here is that we will pick our environment variables from the .env.local file as shown in line 48. After that we have a simple function which validates if a given string is a valid phone number using RegExp.

We need to generate JWT for access control. For this, we will use the golang-jwt module. At line 63, we create a wrapper called GenerateJWT() which takes the Mongo DB ID and then creates a JWT. We put the ID into the subject field of the JWT. This helps us in getting back the user ID easily using the getSubject() function of golang-jwt package. The GenerateJWT() function returns a signed JWT and an error. If everything is all right, then the error is nil.

At line 74, we have the ValidateJWT() function. This takes a JWT and then decodes the JWT using jwt.Parse(). The jwt.Parse() takes the encoded JWT token and a function that returns the secret used to sign the JWT. Inside the function, at line 77, we also check if the token is signed using the algorithm of our choice. If a token has expired, then even if the token is decoded for its data, it stands as invalid. At line 87, we check if the token is “valid”. We take out the JWT claims and return them with the error as nil at line 91. This completes the happy flow of JWT token validation.

The OTPGenerator() function returns a random 8-digit OTP for our use. It’s the SendOTPMail() that’s more interesting. It is a wrapper around SendGrid’s Mail SDK. It takes the email of the person to send the email to and the OTP. It then formats a mail and sends it to the recipient. It uses the API key obtained from SendGrid’s Dashboard. You also need to make sure that you have verified sender to complete this step.

You can start by having a Single Sender Verification done for SendGrid account by following the steps listed here.

The SendOTPSms() function uses Twilio’s REST Client for sending an SMS. This means you need to have a registered phone number beforehand on Twilio’s Dashboard. Note how, unlike the Mail SDK, this one does not need any API key. The helpers under the hood consume the API key you have kept in the .env file. It provides a better DevX.

Lastly, the IsValidObjectID() takes in a string and then returns if the string is a valid MongoDB object ID. This is used for request and JWT-related validation purposes. Because the subject of our JWT will have the user ID, this utility function will help us validate the JWT in more ways than one.

Config Package

The config package in our Golang web server, as you might have guessed, will contain the configuration files. More specifically, database connections and contexts will be exported from this file. But it won’t function quite the same way as you might have guessed if you are coming from another language. The code given below sums up what we will have in our app.go in our config package:

package config

import (
    "context"
    "fmt"
    "log"

    "github.com/abhik-99/passwordless-login/pkg/utils"

    "github.com/go-redis/redis/v8"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "go.mongodb.org/mongo-driver/mongo/readpref"
)

var (
    Db       *mongo.Database
    MongoCtx context.Context

    client *mongo.Client

    Rdb      *redis.Client
    RedisCtx context.Context
)

func init() {
    MongoCtx = context.Background()
    client, err := mongo.Connect(MongoCtx, options.Client().ApplyURI(fmt.Sprintf("mongodb://%s:%s@localhost:27017/?retryWrites=true&w=majority", utils.GetENV("DBUSER"), utils.GetENV("DBPASS"))))
    if err != nil {
        // panic(err)
        log.Println(err)
    }

    if err = client.Ping(MongoCtx, readpref.Primary()); err == nil {
        log.Print("Connection to DB Successful")
    } else {
        log.Println("ERROR while pinging DB")
        log.Panic(err)
        return
    }

    Db = client.Database("passwordless-auth")
    Db.CreateCollection(MongoCtx, "user-collection")

    Rdb = redis.NewClient(&redis.Options{
        Addr:     utils.GetENV("REDISADDR"),
        Password: utils.GetENV("REDISPASS"),
        DB:       0, // use default DB
    })

    RedisCtx = context.Background()
}

func Disconnect() {
    client.Disconnect(MongoCtx)

    Rdb.Close()
}


Enter fullscreen mode Exit fullscreen mode

Between lines 16 and 24, we define the variables we mean to export to other packages. As is apparent, we are exposing the Db and MongoCtx variables. The former is the connection to our specific Mongo database (which we will call passwordless-auth). We are not going to export our Mongo Client though. The last two variables are connection to our Redis Database instance and the respective context.

Notice how we are using the GetENV() function from the utility package to get the DB username and password for the connection string.

Next, we have a special function called init(). What makes the init() function special in Go? Well, the init() function in Golang is a lifecycle function which is called even before the main() function is called. It initializes the application. You can have an init() function in every package and be assured that those will be executed even before the main() function is. This assures that some states are set before the app runs.

Inside our init() function, we first create a context and assign it to MongoCtx. We then create a client with the connection URL which helps us connect to our Docker Container where Mongo is running (refer to the previous article in the series). At line 42, we create a pointer to the passwordless-auth database. A new DB will not be created in this case until we create a collection in it. So, we create a MongoDB collection called user-collection.

The createCollection() function will create our collection if it does not exist. Otherwise, it will quietly send an error. We won’t concern ourselves with the error in this case as this step ensures that our collection is created before we start using it and we don’t end up with a null pointer error.

Lastly, we initialize the connection to our Redis instance running in a Docker container (again, refer to the previous article in the series). The Rdb and RedisCtx are Redis equivalents of the Mongo connection and context.

The Disconnect() function being exported here is going to be invoked when the application is stopped. It is meant to close all the connections and gracefully shut down our application. It will be used in the main package.

Main Package

At this point, we have crossed the 1k-word limit. So, I am a bit wary. But I feel that this article will not be complete without a discussion of the main package. If you are new to Go, the main package is meant to house our main() function which is run right after any init() function by Go when the application starts execution. In our case, we will keep the main.go inside the cmd folder.

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/abhik-99/passwordless-login/pkg/config"
    "github.com/abhik-99/passwordless-login/pkg/routes"

    "github.com/gorilla/mux"
)

func main() {
    defer config.Disconnect()
    router := mux.NewRouter()
    router.StrictSlash(true)

    authRouter := router.PathPrefix("/auth").Subrouter()
    routes.RegisterAuthRoutes(authRouter)

    userRouter := router.PathPrefix("/user").Subrouter()
    routes.RegisterUserRoutes(userRouter)

    fmt.Println("Started on PORT 3000")
    http.Handle("/", router)

    log.Fatal(http.ListenAndServe(":3000", router))
}

Enter fullscreen mode Exit fullscreen mode

As shown in the code above, the first thing we do is start by deferring the execution of the Disconnect() function which we defined in the config package in the previous section. This is convenient as it ensures “we don’t forget about closing DB connections”.

Next, we create our main router. We set StrictSlash() to true on our router. To understand the effect of this with an example, this means that the paths trailing with /user and /user/ are evaluated to the same controller.

At line 19, we create our authentication subrouter called authRouter. Any routes registered to this router will have the prefix /auth in them. We then pass this Gorilla Mux subrouter to the RegisterAuthRoutes() function in the routes package.

We are yet to define the routes and controllers package.

Similarly, we create our user subrouter and pass it to RegisterUserRoutes() function which we will define in the next article. This shows the finesse of Gorilla Mux. It allows easy grouping of routes through this system. One can similarly group our protected and public routes in their project when using Gorilla Mux.

After this, we use the net/http module from the Standard Go library to start listening with our Gorilla Mux router which we created in line 16. This completes three of the packages of our application.

Conclusion

In this article, we took a hands-on approach to writing our Go web server in Gorilla Mux. I am way past the 1k-ish word limit so I am going to end this article at this point. In the next articles in this series, we will define our controllers and models for our specific routes. Until then, keep building awesome things and WAGMI!

💖 💪 🙅 🚩
abhikbanerjee99
Abhik Banerjee

Posted on February 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related