1) 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:

User clicks on the ‘Forgot Passwo…


This content originally appeared on DEV Community and was authored by Yash Jaiswal

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)
}

Now adding a route for it:

// routes/routes.go

// password reset
api.Post("/send-otp", controllers.SendPasswordResetEmail)

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>"

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")
}

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

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
)

(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
}

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)
    }
}

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)
}

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

curl --location 'localhost:3000/api/send-otp' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "yashjaiswal.cse@gmail.com"
}'

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


This content originally appeared on DEV Community and was authored by Yash Jaiswal


Print Share Comment Cite Upload Translate Updates
APA

Yash Jaiswal | Sciencx (2024-09-30T21:06:55+00:00) 1) Password Reset Feature: Sending Email in Golang. Retrieved from https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/

MLA
" » 1) Password Reset Feature: Sending Email in Golang." Yash Jaiswal | Sciencx - Monday September 30, 2024, https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/
HARVARD
Yash Jaiswal | Sciencx Monday September 30, 2024 » 1) Password Reset Feature: Sending Email in Golang., viewed ,<https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/>
VANCOUVER
Yash Jaiswal | Sciencx - » 1) Password Reset Feature: Sending Email in Golang. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/
CHICAGO
" » 1) Password Reset Feature: Sending Email in Golang." Yash Jaiswal | Sciencx - Accessed . https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/
IEEE
" » 1) Password Reset Feature: Sending Email in Golang." Yash Jaiswal | Sciencx [Online]. Available: https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/. [Accessed: ]
rf:citation
» 1) Password Reset Feature: Sending Email in Golang | Yash Jaiswal | Sciencx | https://www.scien.cx/2024/09/30/1-password-reset-feature-sending-email-in-golang/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.