From e73eab83ef87eb4313b3e619aa047df74d19c21a Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Mon, 11 May 2026 16:33:50 +0800 Subject: [PATCH 1/2] feat: implement password recovery flow with OTP verification and secure reset functionality --- backend/internal/auth/forgotPassword.go | 153 +++++++++----- frontend/assets/js/forgot-password.js | 267 ++++++++++++++++++++---- frontend/templates/forgot-password.html | 79 ++++--- 3 files changed, 369 insertions(+), 130 deletions(-) diff --git a/backend/internal/auth/forgotPassword.go b/backend/internal/auth/forgotPassword.go index ad01894..99c33e9 100644 --- a/backend/internal/auth/forgotPassword.go +++ b/backend/internal/auth/forgotPassword.go @@ -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" ) @@ -23,13 +26,19 @@ 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) { log.Println("Forgot Password View") @@ -55,33 +64,27 @@ func sendEmailOTP(email, otp string) error { 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(), 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 } @@ -89,43 +92,60 @@ func sendEmailOTP(email, otp string) error { func (h *Handler) ForgotPasswordSendOTP(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 } 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 } + // 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) + 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 } @@ -141,22 +161,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 } @@ -182,8 +211,10 @@ 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 @@ -191,40 +222,54 @@ 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 } diff --git a/frontend/assets/js/forgot-password.js b/frontend/assets/js/forgot-password.js index 2b3cf86..2b5866e 100644 --- a/frontend/assets/js/forgot-password.js +++ b/frontend/assets/js/forgot-password.js @@ -17,7 +17,85 @@ document.addEventListener("DOMContentLoaded", function () { // Password Elements const passwordInput = document.getElementById('new_password'); const confirmPasswordInput = document.getElementById('confirm_password'); - const errorMessage = document.getElementById('error-message'); + + // Input Elements + const emailInput = document.getElementById('email'); + const otpInputs = Array.from(document.querySelectorAll('.otp-digit')); + + // Error Elements + const emailError = document.getElementById('email-error'); + const otpError = document.getElementById('otp-error'); + const newPasswordError = document.getElementById('new-password-error'); + const confirmPasswordError = document.getElementById('confirm-password-error'); + + // Timer & Resend + const otpTimer = document.getElementById('otp-timer'); + const resendOtpBtn = document.getElementById('resend-otp-btn'); + let timerInterval; + let resendCount = 0; + const MAX_RESEND = 3; + + function showFieldError(input, errorEl, msg) { + if (!errorEl) return; + errorEl.textContent = msg; + errorEl.classList.remove('hidden'); + if (Array.isArray(input)) { + input.forEach(i => { + i.classList.remove('border-gray-300', 'focus:ring-blue-500', 'focus:border-blue-500'); + i.classList.add('border-red-500', 'focus:ring-red-500', 'focus:border-red-500'); + }); + } else if (input) { + input.classList.remove('border-gray-300', 'focus:ring-blue-500', 'focus:border-blue-500'); + input.classList.add('border-red-500', 'focus:ring-red-500', 'focus:border-red-500'); + } + } + + function hideFieldError(input, errorEl) { + if (!errorEl) return; + errorEl.classList.add('hidden'); + if (Array.isArray(input)) { + input.forEach(i => { + i.classList.remove('border-red-500', 'focus:ring-red-500', 'focus:border-red-500'); + i.classList.add('border-gray-300', 'focus:ring-blue-500', 'focus:border-blue-500'); + }); + } else if (input) { + input.classList.remove('border-red-500', 'focus:ring-red-500', 'focus:border-red-500'); + input.classList.add('border-gray-300', 'focus:ring-blue-500', 'focus:border-blue-500'); + } + } + + function hideAllErrors() { + hideFieldError(emailInput, emailError); + hideFieldError(otpInputs, otpError); + hideFieldError(passwordInput, newPasswordError); + hideFieldError(confirmPasswordInput, confirmPasswordError); + } + + function startTimer() { + let timeLeft = 300; // 5 minutes + if (resendOtpBtn) resendOtpBtn.disabled = true; + if (otpTimer) otpTimer.textContent = '05:00'; + + if (timerInterval) clearInterval(timerInterval); + + timerInterval = setInterval(() => { + timeLeft--; + let minutes = Math.floor(timeLeft / 60); + let seconds = timeLeft % 60; + let formattedTime = `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + if (otpTimer) otpTimer.textContent = formattedTime; + + if (timeLeft <= 0) { + clearInterval(timerInterval); + if (otpTimer) otpTimer.textContent = '00:00'; + if (resendOtpBtn && resendCount < MAX_RESEND) { + resendOtpBtn.disabled = false; + } else if (resendOtpBtn && resendCount >= MAX_RESEND) { + if (otpTimer) otpTimer.textContent = 'Max attempts'; + } + } + }, 1000); + } // Validation Elements const checklist = document.getElementById('validation-checklist'); @@ -39,13 +117,15 @@ document.addEventListener("DOMContentLoaded", function () { if (!checkElement) return; const icon = checkElement.querySelector('i'); if (isValid) { - checkElement.classList.add('valid'); - icon.classList.remove('fa-times-circle', 'text-red-500'); - icon.classList.add('fa-check-circle', 'text-green-600'); + checkElement.classList.remove('text-gray-500', 'bg-gray-100'); + checkElement.classList.add('text-green-700', 'bg-green-100'); + icon.classList.remove('fa-circle'); + icon.classList.add('fa-check'); } else { - checkElement.classList.remove('valid'); - icon.classList.remove('fa-check-circle', 'text-green-600'); - icon.classList.add('fa-times-circle', 'text-red-500'); + checkElement.classList.remove('text-green-700', 'bg-green-100'); + checkElement.classList.add('text-gray-500', 'bg-gray-100'); + icon.classList.remove('fa-check'); + icon.classList.add('fa-circle'); } } @@ -55,12 +135,6 @@ document.addEventListener("DOMContentLoaded", function () { const password = passwordInput.value; const confirmPassword = confirmPasswordInput.value; - if (password.length > 0 || confirmPassword.length > 0) { - if (checklist) checklist.classList.remove('hidden'); - } else { - if (checklist) checklist.classList.add('hidden'); - } - const isLengthValid = password.length >= 8; const isCaseValid = hasLower.test(password) && hasUpper.test(password); const isNumValid = hasNumber.test(password); @@ -78,7 +152,7 @@ document.addEventListener("DOMContentLoaded", function () { if (submitButton) { if (allValid) { submitButton.disabled = false; - if (errorMessage) errorMessage.classList.add('hidden'); + hideAllErrors(); } else { submitButton.disabled = true; } @@ -86,8 +160,66 @@ document.addEventListener("DOMContentLoaded", function () { } if (passwordInput && confirmPasswordInput) { - passwordInput.addEventListener('input', validateForm); - confirmPasswordInput.addEventListener('input', validateForm); + passwordInput.addEventListener('input', () => { hideFieldError(passwordInput, newPasswordError); validateForm(); }); + confirmPasswordInput.addEventListener('input', () => { hideFieldError(confirmPasswordInput, confirmPasswordError); validateForm(); }); + } + + if (emailInput) { + emailInput.addEventListener('input', () => hideFieldError(emailInput, emailError)); + } + + otpInputs.forEach((input, index) => { + input.addEventListener('input', (e) => { + input.value = input.value.replace(/[^0-9]/g, ''); + hideFieldError(otpInputs, otpError); + if (input.value && index < otpInputs.length - 1) { + otpInputs[index + 1].focus(); + } + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !input.value && index > 0) { + otpInputs[index - 1].focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (confirmOtpBtn) confirmOtpBtn.click(); + } + }); + + input.addEventListener('paste', (e) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData('text').replace(/[^0-9]/g, '').slice(0, 6); + for (let i = 0; i < pastedData.length; i++) { + if (otpInputs[i]) { + otpInputs[i].value = pastedData[i]; + if (i < 5) otpInputs[i + 1].focus(); + } + } + hideFieldError(otpInputs, otpError); + }); + }); + + if (forgotForm) { + forgotForm.addEventListener('submit', (e) => e.preventDefault()); + + if (emailInput) { + emailInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (sendLinkBtn) sendLinkBtn.click(); + } + }); + } + + const handlePasswordEnter = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (submitButton && !submitButton.disabled) submitButton.click(); + } + }; + + if (passwordInput) passwordInput.addEventListener('keypress', handlePasswordEnter); + if (confirmPasswordInput) confirmPasswordInput.addEventListener('keypress', handlePasswordEnter); } if (forgotForm && sendLinkBtn && confirmOtpBtn && emailStep && otpStep) { @@ -95,10 +227,14 @@ document.addEventListener("DOMContentLoaded", function () { // --- STEP 1: Handle Email submission --- sendLinkBtn.addEventListener('click', async function (event) { event.preventDefault(); + hideAllErrors(); - const emailVal = document.getElementById('email').value; - if (!emailVal) { - alert("Please enter your email"); + const emailVal = emailInput.value; + const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; + + if (!emailVal || !emailRegex.test(emailVal)) { + // validateEmailDynamically will handle the UI + if (emailInput) emailInput.dispatchEvent(new Event('input')); return; } @@ -119,12 +255,16 @@ document.addEventListener("DOMContentLoaded", function () { formTitle.textContent = "Check your email"; formSubtitle.textContent = "We sent a 6-digit code to your email."; + + // Reset resend count and start timer + resendCount = 0; + startTimer(); } else { const error = await response.text(); - alert("Error: " + error); + showFieldError(emailInput, emailError, "Error: " + error); } } catch (error) { - alert("An error occurred. Please try again."); + showFieldError(emailInput, emailError, "An error occurred. Please try again."); } finally { sendLinkBtn.disabled = false; sendLinkBtn.textContent = "Send Reset Link"; @@ -134,11 +274,12 @@ document.addEventListener("DOMContentLoaded", function () { // --- STEP 2: Handle OTP submission --- confirmOtpBtn.addEventListener('click', async function (event) { event.preventDefault(); + hideAllErrors(); - const otpVal = document.getElementById('otp').value; + const otpVal = otpInputs.map(i => i.value).join(''); - if (!otpVal) { - alert("Please enter the OTP"); + if (!otpVal || otpVal.length !== 6) { + showFieldError(otpInputs, otpError, "Please enter a valid 6-digit OTP"); return; } @@ -153,6 +294,7 @@ document.addEventListener("DOMContentLoaded", function () { }); if (response.ok) { + clearInterval(timerInterval); currentOtp = otpVal; otpStep.classList.add('hidden'); if (passwordStep) passwordStep.classList.remove('hidden'); @@ -161,34 +303,70 @@ document.addEventListener("DOMContentLoaded", function () { formSubtitle.textContent = "Please create a secure password."; } else { const error = await response.text(); - alert("Invalid OTP: " + error); + showFieldError(otpInputs, otpError, "Invalid OTP: " + error); } } catch (error) { - alert("An error occurred. Please try again."); + showFieldError(otpInputs, otpError, "An error occurred. Please try again."); } finally { confirmOtpBtn.disabled = false; confirmOtpBtn.textContent = "Confirm Code & Continue"; } }); + // --- Handle Resend OTP --- + if (resendOtpBtn) { + resendOtpBtn.addEventListener('click', async function(e) { + e.preventDefault(); + hideAllErrors(); + + if (resendCount >= MAX_RESEND) { + showFieldError(otpInputs, otpError, "Maximum resend attempts reached."); + return; + } + + resendOtpBtn.disabled = true; + resendOtpBtn.textContent = "Sending..."; + + try { + const response = await fetch('/v1/forgot-password/send-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: currentEmail }) + }); + + if (response.ok) { + resendCount++; + resendOtpBtn.textContent = "Resend"; + startTimer(); + otpInputs.forEach(i => i.value = ''); + otpInputs[0].focus(); + } else { + const error = await response.text(); + showFieldError(otpInputs, otpError, "Error: " + error); + resendOtpBtn.textContent = "Resend"; + resendOtpBtn.disabled = false; + } + } catch (error) { + showFieldError(otpInputs, otpError, "An error occurred. Please try again."); + resendOtpBtn.textContent = "Resend"; + resendOtpBtn.disabled = false; + } + }); + } + // --- STEP 3: Handle Password Reset submission --- if (submitButton) { submitButton.addEventListener('click', async function (event) { event.preventDefault(); + hideAllErrors(); validateForm(); if (submitButton.disabled) { - if (errorMessage) { - errorMessage.textContent = 'Please fix the errors in the password checklist.'; - errorMessage.classList.remove('hidden'); - } + showFieldError(passwordInput, newPasswordError, 'Please fix the errors in the password checklist.'); } else { if (!currentEmail || !currentOtp) { - if (errorMessage) { - errorMessage.textContent = 'Missing email or OTP. Please refresh and try again.'; - errorMessage.classList.remove('hidden'); - } + showFieldError(passwordInput, newPasswordError, 'Missing email or OTP. Please refresh and try again.'); return; } @@ -207,23 +385,22 @@ document.addEventListener("DOMContentLoaded", function () { }); if (response.ok) { - if (errorMessage) errorMessage.classList.add('hidden'); - alert('Password reset successfully! Redirecting to login...'); - window.location.href = "/login"; + hideAllErrors(); + formTitle.textContent = "Success!"; + formSubtitle.textContent = "Password reset successfully. Redirecting to login..."; + if (passwordStep) passwordStep.classList.add('hidden'); + + setTimeout(() => { + window.location.href = "/login"; + }, 2000); } else { const error = await response.text(); - if (errorMessage) { - errorMessage.textContent = "Error: " + error; - errorMessage.classList.remove('hidden'); - } + showFieldError(passwordInput, newPasswordError, "Error: " + error); submitButton.disabled = false; submitButton.textContent = "Set New Password"; } } catch (error) { - if (errorMessage) { - errorMessage.textContent = "An error occurred. Please try again."; - errorMessage.classList.remove('hidden'); - } + showFieldError(passwordInput, newPasswordError, "An error occurred. Please try again."); submitButton.disabled = false; submitButton.textContent = "Set New Password"; } diff --git a/frontend/templates/forgot-password.html b/frontend/templates/forgot-password.html index a949f04..777ff5a 100644 --- a/frontend/templates/forgot-password.html +++ b/frontend/templates/forgot-password.html @@ -56,6 +56,8 @@