Authentication with email and code verification

Authentication with email and code verification

Setting Up Simple Password Authentication with Email Verification using Next.js, Go Backend, and MongoDB

·

13 min read

When using passwords to authenticate our applications, we often want an extra layer of security to prevent malicious attackers from spamming our registration. As such we can try to implement an email and code verification in our app.

In the app that we are creating today, we will be using NextJs for our frontend, Go for our backend and saving our user data into MongoDB.

The full code can be found on my GitHub repository.

Prerequisites

Before diving into the implementation, make sure to have the following prerequisites:

  1. Go installed on your machine

  2. Node.js and NPM installed on your machine

  3. MongoDB operations in Go (can read the documentation here)

  4. A MongoDB database set up and accessible (I am using MongoDB Atlas which is exposed using a MONGODB_URI)

  5. An email service provider (here I am using Gmail to send my emails)

Architecture

Here is a general overview of the components we will be building and the connection between the different services. We will only be sending our crafted emails to our mail service server using SMTP protocol, which will then forward this email to the intended recipient.

software architecture

Approach

After a user registers for an account, we want to store their details in our database and then send them an email with a verification link and code.

For our approach to verification with a link, we want to generate a link such that when clicked, will make a GET request to our server and once verified, we will redirect them back to our frontend homepage for them to log in.

For our approach to verification with code, we want to generate a random 4-digit code that is sent to their email. The user can then key this code in the frontend after they registered and if the code is correct, will allow the user into the dashboard.

Configure SMTP with mail service

The email service provider I am using is Gmail and they have a guide on how to enable SMTP on the Gmail account. You can use other options like Sendgrid, Mailgun or Outlook.

For Gmail, we need to enable the IMAP Access option on our Gmail settings page.

We will also need to generate an App Password as we do not want to use the same password we use for our Google account to authenticate our SMTP access. For that, we can go to Google account settings, under Security and then 2 steps verification, add your new app password.

Setting up backend service

  1. Make backend folder

     mkdir backend
     cd backend
    
  2. Set up a new Go module and install necessary dependencies

     go mod init auth-boilerplate
    
     go get github.com/gin-gonic/gin
     go get github.com/gin-contrib/cors
    
     go get go.mongodb.org/mongo-driver/mongo
     go get github.com/joho/godotenv
    
     go get golang.org/x/crypto/bcrypt
    
  3. Set up the API server using gin and include cors settings so that we do not run into any "Blocked by CORS policy" errors.

     func main() {
         r := gin.Default()
    
         r.Use(cors.New(cors.Config{
             AllowOrigins:  []string{fmt.Sprintf("http://%s", origin)},
             AllowMethods:  []string{"POST", "GET", "PUT", "OPTIONS", "DELETE"},
             AllowHeaders:  []string{"Origin", "Content-Type", "Set-Cookie"},
             ExposeHeaders: []string{"Content-Length", "Content-Type", "Set-Cookie", "Access-Control-Allow-Credentials", "Access-Control-Expose-Headers", "Access-Control-Allow-Origin", "set-cookie"},
    
             AllowCredentials: true,
             AllowWebSockets:  true,
             MaxAge:           12 * time.Hour,
         }))
    
         r.POST("/verify", verifyCode)
         r.GET("/verify/link", verifyLink)
         r.POST("/register", register)
         r.POST("/login", login)
    
         r.Run(":3001")
     }
    
  4. Establish a connection with the Mongo database using mongo.Connect() and if successful, we can use this Mongo client to query and insert our collections. We will use godotenv.Load(".env") to load our MONGODB_URI value from a .env file in our project folder.

     var client *mongo.Client
    
     func initDatabase() error {
         uri := os.Getenv("MONGODB_URI")
         fmt.Println(uri)
         if uri == "" {
             log.Println("URI not found in environmental variables")
             return errors.New("URI not found")
         }
    
         var err error
         client, err = mongo.Connect(context.TODO(), options.Client().ApplyURI(uri))
         if err != nil {
             log.Println("Error connecting to database")
             return errors.New("cannot connect to db")
         }
    
         return nil
     }
    
     func main() {
         err := godotenv.Load(".env")
         if err != nil {
             log.Println("failed to load .env file")
             return
         }
         err = initDatabase()
         if err != nil {
             return
         }
         ///...
     }
    
  5. Declare user struct to handle user inputs from JSON and BSON. BSON, which stands for binary JSON, is used by MongoDB to store and transmit data. When omitempty is used in the ID field, it means that the ID field will not be added into MongoDB if the value of ID in our User struct is empty, MongoDB will auto-generate the _id field for us.

    We will be generating the VerifyHash and VerifyCode on Registration and compare them when they try to verify.

    ValidHash and ValidCode will be storing the deadline when the hash or code is considered invalid and will need to be regenerated.

     type User struct {
         ID          string    `bson:"_id,omitempty"`
         Name        string    `json:"name" bson:"name"`
         Email       string    `json:"email" bson:"email"`
         Password    string    `json:"password" bson:"passwordHash"`
         VerifyHash  string    `bson:"verifyhash"`
         VerifyCode  string    `bson:"verifycode"`
         IsVerified  bool      `json:"isVerified" bson:"isVerified"`
         DateCreated time.Time `bson:"dateCreated"`
         ValidHash   time.Time `bson:"validHash"`
         ValidCode   time.Time `bson:"validCode"`
     }
    
  6. Let's add our register function whenever the user registers on the frontend.

    ctx.BindJSON is used to bind the JSON data from the request body to the newUser struct.

    bcrypt.GenerateFromPassword function is used to generate a secure hash from the user's password. We then replace the current password provided by the user.

    We will then generate the verification link and code and add them to the struct, together with their expiration.

    We will insert our newUser struct into the database using the InsertOne method and add it to our auth database and users collection.

    Lastly, we will send the user their verification email with the link and code attached.

     func register(ctx *gin.Context) {
         var newUser User
    
         err := ctx.BindJSON(&newUser)
         if err != nil {
             log.Println("Error binding JSON")
             ctx.AbortWithStatusJSON(http.StatusInternalServerError, nil)
             return
         }
    
         // ensure email does not exist in database
         // ensure email and password is valid
         // left as an exercise
    
         // generate hash from user password
         hash, err := bcrypt.GenerateFromPassword([]byte(newUser.Password), 6)
         if err != nil {
             log.Println("error hashing password")
         }
         newUser.Password = string(hash)
    
         // generate verification link
         newUser.VerifyHash = generateVerifyHash()
         // generate verification code
         newUser.VerifyCode = generateVerifyCode()
    
         // add deadline for hash and code expiry
         // in the example here, we use 15mins for hash and 3 mins for code
         now := time.Now()
         newUser.DateCreated = now
         newUser.ValidHash = now.Add(15 * time.Minute)
         newUser.ValidCode = now.Add(3 * time.Minute)
    
         // add user to database
         coll := client.Database("auth").Collection("users")
         log.Println("add: ", newUser)
         result, err := coll.InsertOne(context.TODO(), newUser)
         if err != nil {
             log.Println("failed to add new user:", err)
             ctx.JSON(http.StatusOK, gin.H{
                 "success": false,
             })
             return
         }
    
         fmt.Println(result)
    
         // send the verification email
         sendVerificationEmail(newUser)
    
         ctx.JSON(http.StatusOK, gin.H{
             "success": true,
         })
     }
    
  7. Create our login function.

    We will use the user's email to query the database and fetch their document.

    Once found, use bcrypt.CompareHashAndPassword to compare the hash in the database and the user's password in the request body. If successful, then proceed to return a JSON response based on whether the user is verified. If not successful, then return a JSON response with an HTTP code unauthorized.

     func login(ctx *gin.Context) {
         var user User
    
         err := ctx.BindJSON(&user)
         if err != nil {
             log.Println("Error binding JSON")
             ctx.AbortWithStatusJSON(http.StatusInternalServerError, nil)
             return
         }
    
         var dbUser User
         coll := client.Database("auth").Collection("users")
         filter := bson.D{{"email", user.Email}}
         err = coll.FindOne(context.TODO(), filter).Decode(&dbUser)
         if err != nil {
             if err == mongo.ErrNoDocuments {
                 log.Println(fmt.Sprintf("searching for users with email: %s yield no result found", dbUser.Email))
                 ctx.JSON(http.StatusUnauthorized, gin.H{
                     "success": false,
                     "error":   "Either email or password is incorrect",
                 })
                 return
             }
             log.Println("failed to search for user")
         }
    
         err = bcrypt.CompareHashAndPassword([]byte(dbUser.Password), []byte(user.Password))
         if err != nil {
             log.Println("password is different")
             ctx.JSON(http.StatusUnauthorized, gin.H{
                 "success": false,
                 "error":   "Either email or password is incorrect",
             })
             return
         }
    
         if !user.IsVerified {
             ctx.JSON(http.StatusOK, gin.H{
                 "success":    false,
                 "isVerified": false,
                 "error":      "user not verified",
             })
         }
    
         ctx.JSON(http.StatusOK, gin.H{
             "success":    true,
             "isVerified": false,
             "error":      "",
         })
     }
    
  8. Let's create our handler function for verifyCode and verifyLink.

    We will read the link's email, hash and code from the request's query params. Compare the hash or code with the one retrieved from the database. If successful, we can then update the isVerified field in the Mongo database.

    Here we use the coll.UpdateOne to update our document based on the _id of the document.

     func verifyCode(ctx *gin.Context) {
         // read from query params
         email := ctx.Query("email")
         hash := ctx.Query("hash")
         code := ctx.Query("code")
    
         var user User
    
         coll := client.Database("auth").Collection("users")
         filter := bson.D{{"email", email}}
         err := coll.FindOne(context.TODO(), filter).Decode(&user)
         if err != nil {
             if err == mongo.ErrNoDocuments {
                 log.Println(fmt.Sprintf("searching for users with email: %s yield no result found", email))
             }
             log.Println("failed to search for user")
         }
    
         // set user to verified
         if user.VerifyCode == code && user.ValidCode.After(time.Now()) {
             id, _ := primitive.ObjectIDFromHex(user.ID)
             filter = bson.D{{"_id", id}}
             update := bson.D{{"$set", bson.D{{"isVerified", true}}}}
             result, err := coll.UpdateOne(context.TODO(), filter, update)
             if err != nil {
                 log.Println("failed to update:", err)
             }
             log.Println(result)
             ctx.JSON(http.StatusOK, gin.H{
                 "success": true,
             })
             return
         }
    
         ctx.JSON(http.StatusOK, gin.H{
             "success": false,
             "error":   "incorrect code",
         })
     }
    
  9. Let's implement the helper functions we used earlier.

    generateVerifyCode will randomly generate an Int between 0 to 9999 and then pad the value such that it is 4 characters long

    generateVerifyHash will randomly generate random bytes in our array and then change this value by encoding it into a string via base64 encoding.

     func generateVerifyCode() string {
         rand.Seed(time.Now().UnixNano())
         randomNumber := rand.Intn(10000)
         paddedNumber := fmt.Sprintf("%04d", randomNumber)
    
         return paddedNumber
     }
    
     func generateVerifyHash() string {
         charLen := 50
         randomBytes := make([]byte, charLen)
    
         _, err := rand.Read(randomBytes)
         if err != nil {
             fmt.Println("failed to generate random bytes")
         }
    
         randomString := base64.URLEncoding.EncodeToString(randomBytes)
         return randomString[:charLen]
     }
    
  10. Set up our connection with the SMTP host. We then use the net/smtp package from Go to construct and send our email.

    const (
        // configs to connect to gmail smtp server
        smtpHost = "smtp.gmail.com"
        smtpPort = 587
    )
    
    appkey = os.Getenv("APPKEY")
    hostEmail = os.Getenv("HOSTEMAIL")
    
    func getVerifyLink(email string, verifyHash string, verifyCode string) string {
        return fmt.Sprintf("http://localhost:3001/verify/link?email=%s&hash=%s&code=%s", email, verifyHash, verifyCode)
    }
    
    func sendVerificationEmail(user User) {
        verifyLink := getVerifyLink(user.Email, user.VerifyHash, user.VerifyCode)
        to := []string{user.Email}
        subject := "Email verification"
        body := fmt.Sprintf("Hello, \r\n\r\nThis email contains the verification link and verification code, please the link below to verify your account.\r\n\r\nVerification link: %s\r\n\r\nAlternatively, u can also key in the verification code to verify.\r\n\r\nVerification code: %s", verifyLink, user.VerifyCode)
    
        message := []byte("To: " + user.Email + "\r\n" +
            "From: " + hostEmail + "\r\n" +
            "Subject: " + subject + "\r\n" +
            "\r\n" +
            body + "\r\n")
    
        auth := smtp.PlainAuth("", hostEmail, appkey, smtpHost)
    
        err := smtp.SendMail("smtp.gmail.com:587", auth, hostEmail, to, message)
        if err != nil {
            log.Println("Failed to send email")
            log.Println(err)
            return
        }
        log.Println("Email sent successfully")
    }
    

Setting up frontend

  1. Initialize a new Next.js project using the command below and name the folder frontend

     npx create-next-app@latest
    
  2. In the root frontend, create a folder components that holds our styled-components for Fields, Buttons and ButtonText.

     // component.tsx
    
     export const Field = ({ title, placeholder = '', type = 'text', value, onChange }:
         { title: string, placeholder?: string, type?: string, value: string, onChange: Function }) => {
         return (
             <div>
                 <h3>{title}</h3>
                 <input className="py-2 px-4 leading-tight text-gray-700 appearance-none border-4 border-gray-200 rounded focus:outline-none focus:bg-white focus:border-teal-500"
                     placeholder={placeholder} type={type} value={value} onChange={(e) => onChange(e.target.value)} />
             </div>
         )
     }
    
     export const Button = ({ text, onClick }: { text: string, onClick?: Function }) => {
         return (
            <button onClick={() => { onClick?.() }} 
                 className="px-7 py-3 mt-10 bg-teal-500 hover:bg-teal-700 border rounded-lg font-bold">{text}</button>
         )
     }
    
     export const ButtonText = ({ text, onClick }: { text: string, onClick: Function }) => {
         return (
             <button onClick={() => { onClick() }} 
                 className="px-7 py-3 mt-10 hover:bg-gray-600 rounded-lg font-bold">{text}</button>
         )
     }
    
  3. Under /app/page.tsx, replace the boiler code with our login, register and Verify components. They provide the fields for inputting our user's login or register info and calling the backend API on submit.

     'use client'
    
     import { useState } from "react"
     import { useRouter } from "next/navigation"
     import { Button, ButtonText, Field } from "@/components/component"
    
     export default function Home() {
         const [isLogin, setLogin] = useState(true)
         const [showVerify, setVerify] = useState(false)
         const [email, setEmail] = useState("")
    
         return (
             <main className="min-h-screen flex flex-col items-center justify-center">
                 <h1 className="text-4xl font-bold mb-10">Authentication demo page</h1>
                 {
                     showVerify ? <Verify email={email}/> : isLogin 
                         ? <Login setLogin={setLogin} setVerify={setVerify}/>
                         : <Register setLogin={setLogin} setVerify={setVerify} email={email} setEmail={setEmail}/>
                 }
             </main>
         )
     }
    
     function Register({ setLogin, setVerify, email, setEmail }:
         { setLogin: Function, setVerify: Function, email: string, setEmail: Function }) {
         const [name, setName] = useState("")
         const [password, setPassword] = useState("")
         const [repassword, setRepassword] = useState("")
         const [error, setError] = useState("")
    
         const register = () => {
             if (password !== repassword) {
                 return
             }
             const user = {
                 name,
                 email,
                 password
             }
             fetch("http://localhost:3001/register", {
                 method: "POST",
                 headers: {
                     "Content-Type": "application/json",
                 },
                 body: JSON.stringify(user)
             })
                 .then((resp) => resp.json())
                 .then(({ success, error }) => {
                     if (success) {
                         setVerify(true);
                     } else {
                         // handle incorrect login 
                         setError(error)
                     }
                 })
         }
    
         return (
             <>
                 <h1 className="text-4xl font-bold mb-10">Register</h1>
                 <div className="flex flex-col gap-5">
                     <Field title={'Name'} value={name} onChange={setName} />
                     <Field value={email} onChange={setEmail}
                         title={'Email'} placeholder="example@mail.com" type="email" />
                     <Field value={password} onChange={setPassword}
                         title={'Password'} type="password" />
                     <Field value={repassword} onChange={setRepassword}
                         title={'Retype Password'} type="password" />
                 </div>
                 <Button text={"Register"} onClick={register} />
                 <ButtonText text={"Login instead"} onClick={() => setLogin(true)} />
             </>
         )
     }
    
     function Login({ setLogin, setVerify }: { setLogin: Function , setVerify: Function }) {
         const router = useRouter()
    
         const [email, setEmail] = useState("")
         const [password, setPassword] = useState("")
         const [error, setError] = useState("")
    
         const login = () => {
             const user = {
                 email,
                 password
             }
    
             fetch("http://localhost:3001/login", {
                 method: "POST",
                 headers: {
                     "Content-Type": "application/json",
                 },
                 body: JSON.stringify(user)
             })
                 .then((resp) => resp.json())
                 .then(({ success, isVerified, error }) => {
                     if (success) {
                         if (isVerified) {
                             router.push("/dashboard")
                         }
                         setVerify(true);
                     } else {
                         // handle incorrect login 
                         setError(error)
                     }
                 })
         }
    
         return (
             <>
                 <h1 className="text-4xl font-bold mb-10">Login</h1>
                 <div className="flex flex-col gap-5">
                     <Field value={email} onChange={setEmail}
                         title={'Email'} placeholder="example@mail.com" type="email" />
                     <Field value={password} onChange={setPassword}
                         title={'Password'} type="password" />
                 </div>
                 {error === "" ? <></> : <p className="my-2 text-red-800">{error}</p>}
                 <Button text={"Login"} onClick={() => { login() }} />
                 <ButtonText text={"Register instead"} onClick={() => setLogin(false)} />
             </>
         )
     }
    
     function Verify({ email }: {email: string}) {
    
         const router = useRouter()
         const [code, setCode] = useState("")
         const [error, setError] = useState("")
    
         const verify = () => {
             fetch(`http://localhost:3001/verify?email=${email}&code=${code}`, {
                 method: "POST",
                 headers: {
                     "Content-Type": "application/json",
                 },
             })
                 .then((resp) => resp.json())
                 .then(({ success, error }) => {
                     if (success) {
                         router.push("/dashboard")
                     } else {
                         // handle incorrect login 
                         setError(error)
                     }
                 })
         }
    
         return (
             <div className="flex flex-col gap-5 items-center justify-center">
                 <h1 className="uppercase text-xl" >Verify your email address</h1>
                 <div className="w-1/2 text-center">
                 <p className="">A verification code and link has been sent to *****@****mail.com</p>
                 </div>
                 <p>Please check your inbox or junk folder and proceed to verify with us. The code will expire in 2mins</p>
                 <Field title="Code" value={code} onChange={setCode} />
                 <Button text={"Verify"} onClick={() => {verify()}} />
             </div>
         )
     }
    
  4. Lastly, we create the dashboard route when the user successfully logins and also the verify route when the user clicks on the verify link which will prompt them to return to the homepage to log in again.

     // in /app/dashboard/page.tsx
     "use client"
     export default function Page() {
         return (
             <div className="min-h-screen flex flex-col items-center justify-center">
                 <h1 className="text-3xl mb-3">This is a dashboard viewable when logged in</h1>
                 <h2>Return to root page using button below</h2>
                 <Link href="/">
                     <Button text="Return"/>
                 </Link>
             </div>
         )
     }
    
     // in /app/verify/page.tsx
     "use client"
     export default function Page() {
         return (
             <div className="min-h-screen flex flex-col items-center justify-center">
                 <h1 className="text-3xl mb-3">You are now verified!</h1>
                 <h2>Click below to return back to the homepage and login</h2>
                 <Link href="/">
                     <Button text="Return"/>
                 </Link>
             </div>
         )
     }
    

Testing our app

To start our Go server, run

go run .

ensure that you have the .env file in the root of the backend folder. Be sure not to commit this into the git repository as the keys are private and should be kept hidden.

// .env file
MONGODB_URI="mongodb+srv://<USER>:<PASSWORD>@cluster23.honxmor.mongodb.net/?retryWrites=true&w=majority"
HOSTEMAIL="XXXXX@gmail.com"
APPKEY="ABCDEFGH"

To start our Next.js frontend, run:

yarn dev

Our backend will be running in localhost:3001 while our frontend will be running in localhost:3000.

If we try to register:

We will have a user entry created in MongoDB Atlas

and also received my verification email

which we can proceed to click the link or fill up the verification code on our page

Summary

We have created an application that can send verification emails for users to verify.

In the future, I will try to optimize our backend code so that the frontend side can receive the response from the server faster.