DEV Community

Yash Jaiswal
Yash Jaiswal

Posted on • Edited on

Password Reset Feature: Sending Email in Golang

I'm implementing a feature to reset password for the user in my app Task-inator 3000 as I write this post. Just logging my thought process and the steps taken


Planning

I'm thinking of a flow like this:

  1. User clicks on the 'Forgot Password?' button
  2. Display a modal to user asking for email
  3. Check if email exists, and send an 10 character long OTP to email
  4. Modal now asks for OTP and new password
  5. Password is hashed and updated for the user

Separation of Concerns

Frontend

  • Create a modal to enter email
  • The same modal then takes in OTP and new password

Backend

  • Create API for sending email
  • Create API for resetting password

I'll be starting with the backend

Backend

As stated above, we need two APIs

1. Sending Email

The API needs to take in only the email from the user, and return no content when successful. Hence, creating the controller as follows:

// controllers/passwordReset.go
func SendPasswordResetEmail(c *fiber.Ctx) error {
    type Input struct {
        Email string `json:"email"`
    }

    var input Input

    err := c.BodyParser(&input)
    if err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid data",
        })
    }

    // TODO: send email with otp to user

    return c.SendStatus(fiber.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

Now adding a route for it:

// routes/routes.go

// password reset
api.Post("/reset-password", controllers.SendPasswordResetEmail)
Enter fullscreen mode Exit fullscreen mode

I'll be using net/smtp from the standard library of Golang.

Upon reading the documentation, I think it would be best to create an SMTPClient upon initialization of the project. Hence, I would create a file smtpConnection.go in the /config directory.

Before that, I'll add the following environment variables to either my .env or to the production server.

SMTP_HOST="smtp.zoho.in"
SMTP_PORT="587"
SMTP_EMAIL="<myemail>"
SMTP_PASSWORD="<mypassword>"
Enter fullscreen mode Exit fullscreen mode

I'm using zohomail, hence their smtp host and port (for TLS) as stated here.

// config/smtpConnection.go
package config

import (
    "crypto/tls"
    "fmt"
    "net/smtp"
    "os"
)

var SMTPClient *smtp.Client

func SMTPConnect() {
    host := os.Getenv("SMTP_HOST")
    port := os.Getenv("SMTP_PORT")
    email := os.Getenv("SMTP_EMAIL")
    password := os.Getenv("SMTP_PASSWORD")

    smtpAuth := smtp.PlainAuth("", email, password, host)

    // connect to smtp server
    client, err := smtp.Dial(host + ":" + port)
    if err != nil {
        panic(err)
    }

    SMTPClient = client
    client = nil

    // initiate TLS handshake
    if ok, _ := SMTPClient.Extension("STARTTLS"); ok {
        config := &tls.Config{ServerName: host}
        if err = SMTPClient.StartTLS(config); err != nil {
            panic(err)
        }
    }

    // authenticate
    err = SMTPClient.Auth(smtpAuth)
    if err != nil {
        panic(err)
    }

    fmt.Println("SMTP Connected")
}
Enter fullscreen mode Exit fullscreen mode

For abstraction, I'll create a passwordReset.go file in /utils. This file would have the following functions for now:

  • GenerateOTP: To generate a unique alphanumeric 10 digit OTP to send in the email
  • AddOTPtoRedis: To add OTP to Redis in a key value format where
key -> password-reset:<email>
value -> hashed otp
expiry -> 10 mins
Enter fullscreen mode Exit fullscreen mode

I'm storing the hash of the OTP instead of the OTP itself for security reasons

  • SendOTP: To send the generated OTP to user's email

While writing code I see that we need 5 constants here:

  • Prefix for redis key for OTP
  • Expiry time for OTP
  • Character set for OTP generation
  • Template for the email
  • Length of OTP

I'll immediately add them to /utils/constants.go

// utils/constants.go
package utils

import "time"

const (
    authTokenExp       = time.Minute * 10
    refreshTokenExp    = time.Hour * 24 * 30 // 1 month
    blacklistKeyPrefix = "blacklisted:"
    otpKeyPrefix       = "password-reset:"
    otpExp             = time.Minute * 10
    otpCharSet         = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
    emailTemplate      = "To: %s\r\n" +
        "Subject: Task-inator 3000 Password Reset\r\n" +
        "\r\n" +
        "Your OTP for password reset is %s\r\n"

    // public because needed for testing
    OTPLength = 10
)
Enter fullscreen mode Exit fullscreen mode

(Note that we'll be importing from crypto/rand, and not math/rand, as it will provide true randomness)

// utils/passwordReset.go
package utils

import (
    "context"
    "crypto/rand"
    "fmt"
    "math/big"
    "os"
    "task-inator3000/config"

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

func GenerateOTP() string {
    result := make([]byte, OTPLength)
    charsetLength := big.NewInt(int64(len(otpCharSet)))

    for i := range result {
        // generate a secure random number in the range of the charset length
        num, _ := rand.Int(rand.Reader, charsetLength)
        result[i] = otpCharSet[num.Int64()]
    }

    return string(result)
}

func AddOTPtoRedis(otp string, email string, c context.Context) error {
    key := otpKeyPrefix + email

    // hashing the OTP
    data, _ := bcrypt.GenerateFromPassword([]byte(otp), 10)

    // storing otp with expiry
    err := config.RedisClient.Set(c, key, data, otpExp).Err()
    if err != nil {
        return err
    }

    return nil
}

func SendOTP(otp string, recipient string) error {
    sender := os.Getenv("SMTP_EMAIL")
    client := config.SMTPClient

    // setting the sender
    err := client.Mail(sender)
    if err != nil {
        return err
    }

    // set recipient
    err = client.Rcpt(recipient)
    if err != nil {
        return err
    }

    // start writing email
    writeCloser, err := client.Data()
    if err != nil {
        return err
    }

    // contents of the email
    msg := fmt.Sprintf(emailTemplate, recipient, otp)

    // write the email
    _, err = writeCloser.Write([]byte(msg))
    if err != nil {
        return err
    }

    // close writecloser and send email
    err = writeCloser.Close()
    if err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The function GenerateOTP() is testable without mocks (unit testing), hence wrote a simple test for it

package utils_test

import (
    "task-inator3000/utils"
    "testing"
)

func TestGenerateOTP(t *testing.T) {
    result := utils.GenerateOTP()

    if len(result) != utils.OTPLength {
        t.Errorf("Length of OTP was not %v. OTP: %v", utils.OTPLength, result)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we need to put it all together inside the controller. Before all of that we need to make sure the email address provided exists in the database.

The complete code for the controller is as follows:

func SendPasswordResetEmail(c *fiber.Ctx) error {
    type Input struct {
        Email string `json:"email"`
    }

    var input Input

    err := c.BodyParser(&input)
    if err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid data",
        })
    }

    // check if user with email exists
    users := config.DB.Collection("users")
    filter := bson.M{"_id": input.Email}
    err = users.FindOne(c.Context(), filter).Err()
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
                "error": "user with given email not found",
            })
        }

        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "error while finding in the database:\n" + err.Error(),
        })
    }

    // generate otp and add it to redis
    otp := utils.GenerateOTP()
    err = utils.AddOTPtoRedis(otp, input.Email, c.Context())
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": err.Error(),
        })
    }

    // send the otp to user through email
    err = utils.SendOTP(otp, input.Email)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": err.Error(),
        })
    }

    return c.SendStatus(fiber.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

We can test the API by sending a POST request to the correct URL. A cURL example would be:

curl --location 'localhost:3000/api/reset-password' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "yashjaiswal.cse@gmail.com"
}'
Enter fullscreen mode Exit fullscreen mode

We'll create the next API - for Resetting The Password - in the next part of the series

Top comments (0)