Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 112 additions & 60 deletions backend/internal/auth/forgotPassword.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import (
"unicode"

"unicard-go/backend/internal/pkg/account"
jsonwrite "unicard-go/backend/internal/pkg/handler"
smtp "unicard-go/backend/internal/pkg/smtpbody"

"github.com/go-playground/validator/v10"
"gopkg.in/gomail.v2"
)

Expand All @@ -23,12 +26,17 @@ type OTPData struct {

// Forgot and Reset Password Request
type ForgotPasswordRequest struct {
Email string `json:"email"`
OTP string `json:"otp"`
NewPassword string `json:"new_password"`
Email string `json:"email" validate:"required,email" db:"email"`
OTP string `json:"otp" validate:"required,numeric,len=6"`
NewPassword string `json:"new_password" validate:"required,min=8" db:"password_hash"`
}

var otpStore = make(map[string]OTPData)
var validate *validator.Validate

func init() {
validate = validator.New()
}

// Forgot Password View
func (h *Handler) ForgotPasswordView(w http.ResponseWriter, r *http.Request) {
Expand All @@ -39,93 +47,112 @@ func (h *Handler) ForgotPasswordView(w http.ResponseWriter, r *http.Request) {
// Generate OTP Code
func generateOTP() string {
max := big.NewInt(1000000)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "123456"
}
n, _ := rand.Int(rand.Reader, max)

return fmt.Sprintf("%06d", n.Int64())
}

// Send OTP to email
func sendEmailOTP(email, otp string) error {
func sendEmailOTP(email, name, otp string) error {
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := 587
smtpEmail := os.Getenv("SMTP_EMAIL")
smtpSender := os.Getenv("SMTP_SENDER")
smtpPass := os.Getenv("SMTP_PASSWORD")

m := gomail.NewMessage()
m.SetHeader("From", smtpSender, smtpEmail)
m.SetHeader("From", smtpSender+" <"+smtpEmail+">")
m.SetHeader("To", email)
m.SetHeader("Subject", "Your Password Reset OTP")
m.SetBody("text/plain", "Your OTP for password reset is: "+otp)
m.SetHeader("Subject", "Unicard Password Reset OTP")

d := gomail.NewDialer(smtpHost, smtpPort, smtpSender, smtpPass)
if err := d.DialAndSend(m); err != nil {
log.Fatal(err)
}
fmt.Println("OTP SENT")
htmlBody := fmt.Sprintf(smtp.OTPCode(), name, otp)

// Always print the OTP to the terminal so we can test locally even if email fails
fmt.Printf("\n======================================================\n")
fmt.Printf("=> [LOCAL TEST] OTP for %s is: %s\n", email, otp)
fmt.Printf("======================================================\n\n")
m.SetBody("text/html", htmlBody)

if os.Getenv("SMTP_HOST") == "" {
fmt.Printf("SMTP credentials not set. Simulating email success.\n")
return nil
}
d := gomail.NewDialer(
smtpHost,
smtpPort,
smtpEmail,
smtpPass,
)

err := d.DialAndSend(m)
if err != nil {
fmt.Println("Warning: Failed to send email via SMTP, but continuing for local testing. Error:", err)
// Return nil instead of err so the frontend flow continues seamlessly
return nil
return err
}

log.Printf("OTP sent successfully to %s", email)
return nil
}

// Forgot Password Send OTP
func (h *Handler) ForgotPasswordSendOTP(w http.ResponseWriter, r *http.Request) {
// get context from request
ctx := r.Context()

var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
log.Println("Error decoding request:", err)
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid input",
})
return
}

exists, err := account.IsEmailExist(h.DB, req.Email)
if err != nil {
http.Error(w, "System error", http.StatusInternalServerError)
log.Println("Error checking email existence:", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "System error",
})
return
}
if !exists {
// Even if not exists, return success to prevent email enumeration
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "If the email is found, an OTP has been sent."})
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "If the email is found, an OTP has been sent.",
})
return
}

// Fetch the user's name
var fullName string
err = h.DB.QueryRowContext(ctx, "SELECT full_name FROM users WHERE email = ?", req.Email).Scan(&fullName)
if err != nil {
fullName = "there" // Fallback if name is not found
}

// Generate OTP that valid for 5 minutes
otp := generateOTP()
otpStore[req.Email] = OTPData{
OTP: otp,
Expiry: time.Now().Add(10 * time.Minute),
Expiry: time.Now().Add(5 * time.Minute),
}

if err := sendEmailOTP(req.Email, otp); err != nil {
fmt.Println("Error sending email:", err)
if err := sendEmailOTP(req.Email, fullName, otp); err != nil {
log.Println("Error sending email:", err)
http.Error(w, "Failed to send OTP", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "OTP sent successfully"})
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "OTP sent successfully",
})
}

// Forgot Password Verify OTP
func (h *Handler) ForgotPasswordVerifyOTP(w http.ResponseWriter, r *http.Request) {
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
log.Println("Error decoding request:", err)
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid input",
})
return
}

Expand All @@ -141,22 +168,31 @@ func (h *Handler) ForgotPasswordVerifyOTP(w http.ResponseWriter, r *http.Request
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "OTP verified"})
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "OTP verified",
})
}

// Reset Password Handler
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
log.Println("Error decoding request:", err)
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid input",
})
return
}

// Verify OTP again
data, ok := otpStore[req.Email]
if !ok || data.OTP != req.OTP {
http.Error(w, "Invalid or expired OTP", http.StatusUnauthorized)
jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{
Success: false,
Message: "Invalid or expired OTP",
})
return
}

Expand All @@ -182,49 +218,65 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
// Clean up OTP
delete(otpStore, req.Email)

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Password updated successfully"})
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "Password updated successfully",
})
}

// Validate password helper function
func validatePassword(password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
var hasUpper, hasLower, hasNumber, hasSpecial bool

var (
hasUpper bool
hasLower bool
hasNumber bool
hasSpecial bool
)

for _, c := range password {
switch {
case unicode.IsNumber(c):
hasNumber = true
case unicode.IsUpper(c):
hasUpper = true

case unicode.IsLower(c):
hasLower = true
case unicode.IsPunct(c) || unicode.IsSymbol(c):

case unicode.IsNumber(c):
hasNumber = true

case unicode.IsPunct(c), unicode.IsSymbol(c):
hasSpecial = true
}
}
if !hasUpper {
return fmt.Errorf("password must contain an uppercase letter")
}
if !hasLower {
return fmt.Errorf("password must contain a lowercase letter")
}
if !hasNumber {
return fmt.Errorf("password must contain a number")
}
if !hasSpecial {
return fmt.Errorf("password must contain a special character")

switch {
case !hasUpper:
return fmt.Errorf("password must contain at least one uppercase letter")

case !hasLower:
return fmt.Errorf("password must contain at least one lowercase letter")

case !hasNumber:
return fmt.Errorf("password must contain at least one number")

case !hasSpecial:
return fmt.Errorf("password must contain at least one special character")
}

return nil
}

// Update Password Handler
func (h *Handler) updatePassword(email, hashedPassword string) error {
query := "UPDATE users SET password = ? WHERE email = ?"
query := "UPDATE users SET password_hash = ? WHERE email = ?"
_, err := h.DB.Exec(query, hashedPassword, email)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
log.Printf("failed to update password: %v", err)
return err
}
return nil
}
50 changes: 50 additions & 0 deletions backend/internal/pkg/smtpbody/smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package smtp

import (
"fmt"
"time"
)

func OTPCode() string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Password Reset OTP</title>
<style>
body { font-family: 'Inter', Arial, sans-serif; background-color: #f4f4f5; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); overflow: hidden; }
.header { background-color: #0f172a; color: #ffffff; padding: 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; font-weight: 600; }
.content { padding: 30px; color: #334155; line-height: 1.6; }
.content p { margin: 0 0 15px; }
.otp-container { text-align: center; margin: 30px 0; }
.otp-code { display: inline-block; font-size: 32px; font-weight: 700; letter-spacing: 5px; color: #2563eb; background-color: #eff6ff; padding: 15px 25px; border-radius: 6px; border: 1px dashed #bfdbfe; }
.footer { background-color: #f8fafc; padding: 15px; text-align: center; font-size: 12px; color: #64748b; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Unicard</h1>
</div>
<div class="content">
<p>Hello %s,</p>
<p>We received a request to reset your password. Please use the following One-Time Password (OTP) to proceed. This OTP is valid for <strong>5 minutes</strong>.</p>
<div class="otp-container">
<div class="otp-code">%s</div>
</div>
<p>If you did not request a password reset, please ignore this email or contact support if you have concerns.</p>
<p>Thank you,<br>The Unicard Team</p>
</div>
<div class="footer">
&copy; ` + Year() + ` Unicard. All rights reserved.
</div>
</div>
</body>
</html>`
}

func Year() string {
return fmt.Sprintf("%d", time.Now().Year())
}
Loading