--- name: password-recovery description: Complete dual-channel password recovery system (Email SMTP + WhatsApp Evolution API v2) with token generation and expiration. --- # Dual-Channel Password Recovery System (Email SMTP + WhatsApp Evolution API v2) This skill documents how to build a complete, production-ready, dual-channel password recovery flow using **Email (SMTP)** and **WhatsApp (Evolution API v2)**. --- ## 1. Database Schema (Prisma ORM) Add fields for tracking reset tokens in the `User` model, and fields to store SMTP configuration in the `SystemSettings` (SaaS tenant settings) model. ```prisma model User { id String @id @default(cuid()) email String @unique whatsapp String? password String // Password Recovery Fields resetPasswordToken String? @db.Text resetPasswordExpires DateTime? } model SystemSettings { id String @id @default("default") // SMTP Email Server Configurations smtpHost String? smtpPort Int? @default(587) smtpUser String? smtpPass String? smtpFrom String? @default("no-reply@yourdomain.com") smtpSecure Boolean @default(false) // Evolution API Configurations (for WhatsApp notifications) evolutionApiUrl String @default("https://evolution.yourdomain.com") evolutionApiKey String @default("") evolutionInstance String @default("default_instance") whatsappConnected Boolean @default(false) } ``` --- ## 2. Backend Implementation (Node.js & Express) ### SMTP Transporter Setup (using `nodemailer`) Dynamically create the SMTP transporter using the configurations fetched from the database: ```typescript import nodemailer from 'nodemailer'; async function getMailTransporter(settings: any) { if (!settings || !settings.smtpHost || !settings.smtpUser || !settings.smtpPass) { throw new Error('Configurações SMTP não preenchidas no banco de dados.'); } return nodemailer.createTransport({ host: settings.smtpHost, port: settings.smtpPort || 587, secure: settings.smtpSecure, // true for 465, false for other ports auth: { user: settings.smtpUser, pass: settings.smtpPass, }, tls: { rejectUnauthorized: false // Avoid SSL handshake errors on custom/VPS SMTP servers } }); } ``` ### Endpoint: Request Reset Token (`/api/auth/forgot-password`) Generates a cryptographically secure token, saves it with a 1-hour expiration time, and triggers the delivery channel (Email or WhatsApp): ```typescript import crypto from 'crypto'; import Express, { Request, Response } from 'express'; const app = Express(); app.post('/api/auth/forgot-password', async (req: Request, res: Response) => { const { identifier, channel } = req.body; // channel: 'email' | 'whatsapp' if (!identifier || !channel) { return res.status(400).json({ error: 'Identificador e canal são obrigatórios.' }); } try { let user; if (channel === 'whatsapp') { const cleanPhone = identifier.replace(/\D/g, ''); const withoutDDI = cleanPhone.replace(/^55/, ''); const withDDI = '55' + withoutDDI; // Robust phone matching: matches any format of the phone stored in the database user = await prisma.user.findFirst({ where: { OR: [ { whatsapp: cleanPhone }, { whatsapp: withoutDDI }, { whatsapp: withDDI } ] } }); } else { user = await prisma.user.findFirst({ where: { email: identifier.toLowerCase() } }); } // Always return success even if user doesn't exist to prevent enumeration attacks if (!user) { return res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' }); } // Generate secure 32-byte hex token valid for 1 hour const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 3600000); // +1 hour await prisma.user.update({ where: { id: user.id }, data: { resetPasswordToken: token, resetPasswordExpires: expires } }); const resetUrl = `${req.headers.origin}/reset-password?token=${token}`; if (channel === 'email') { const settings = await prisma.systemSettings.findFirst(); const transporter = await getMailTransporter(settings); const mailOptions = { from: settings?.smtpFrom || '"Recuperação de Senha" ', to: user.email, subject: 'Redefinição de Senha', html: `

Recuperação de Senha

Olá, ${user.name || 'Aluno'}.

Recebemos uma solicitação para redefinir a senha da sua conta.

Redefinir Minha Senha

Se você não fez essa solicitação, pode ignorar este e-mail com segurança.

Link válido por 1 hora.

` }; await transporter.sendMail(mailOptions); } else if (channel === 'whatsapp') { const text = `*Recuperação de Senha*\n\nOlá, você solicitou a recuperação de sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Válido por 1 hora._`; // Send message via sendWhatsappNotification (explained in Section 4) await sendWhatsappNotification(user.whatsapp || identifier, text); } res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' }); } catch (err: any) { res.status(500).json({ error: 'Erro ao processar solicitação.' }); } }); ``` ### Endpoint: Save New Password (`/api/auth/reset-password`) Validates the token, checks if it has expired, hashes the new password using `bcrypt`, updates the database, and voids the token. ```typescript import bcrypt from 'bcrypt'; app.post('/api/auth/reset-password', async (req: Request, res: Response) => { const { token, newPassword } = req.body; if (!token || !newPassword) { return res.status(400).json({ error: 'Token e nova senha são obrigatórios.' }); } try { const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, resetPasswordExpires: { gt: new Date() } // Check if expiration date is greater than now } }); if (!user) { return res.status(400).json({ error: 'Token inválido ou expirado.' }); } // Hash the password and clear the reset fields const hashedPassword = bcrypt.hashSync(newPassword, 10); await prisma.user.update({ where: { id: user.id }, data: { password: hashedPassword, resetPasswordToken: null, resetPasswordExpires: null } }); res.json({ success: true, message: 'Senha redefinida com sucesso!' }); } catch (err: any) { res.status(500).json({ error: 'Erro ao redefinir a senha.' }); } }); ``` --- ## 3. WhatsApp Dispatch System (Evolution API v2) Always format phone numbers correctly (prepending Brazil's DDI `55` if it only has 10 or 11 digits) and dispatch with the correct Evolution API v2 JSON payload. ```typescript import axios from 'axios'; async function sendWhatsappNotification(phone: string, text: string) { try { const settings = await prisma.systemSettings.findFirst(); if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) { return false; // WhatsApp configuration missing or inactive } // Clean and validate digits let cleanPhone = phone.replace(/\D/g, ''); if (!cleanPhone) return false; // Inject country code (DDI) 55 if missing (standard Brazilian number with DDD) if (cleanPhone.length === 10 || cleanPhone.length === 11) { cleanPhone = '55' + cleanPhone; } const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); const instance = settings.evolutionInstance || 'default_instance'; // MUST use the Evolution API v2 body schema: { number, text, delay } await axios.post(`${apiUrl}/message/sendText/${instance}`, { number: cleanPhone, text: text, delay: 1200 }, { headers: { apikey: settings.evolutionApiKey } }); return true; } catch (err: any) { console.error('Falha ao enviar mensagem de WhatsApp:', err.message); return false; } } ``` --- ## 4. Frontend Integration (React) ### Parsing Token from URL Read the token parameter in a React page/component on load: ```tsx import React, { useEffect, useState } from 'react'; export const App = () => { const [resetToken, setResetToken] = useState(null); const [isForgotPassword, setIsForgotPassword] = useState(false); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const tokenParam = urlParams.get('token'); // Check if path matches /reset-password and token is present if (window.location.pathname === '/reset-password' && tokenParam) { setResetToken(tokenParam); setIsForgotPassword(true); // Open password modal automatically } }, []); // Render forgot password modal or main login here... }; ```