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 Password?' button
- Display a modal to user asking for email
- Check if email exists, and send an 10 character long OTP to email
- Modal now asks for OTP and new password
- 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("/reset-password", 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/reset-password' \
--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
Top comments (0)