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
I'm thinking of a flow like this:
Frontend
Backend
I'll be starting with the backend
As stated above, we need two APIs
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:
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
While writing code I see that we need 5 constants here:
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
The above is the detailed content of Password Reset Feature: Sending Email in Golang. For more information, please follow other related articles on the PHP Chinese website!