9.5 KiB
| name | description |
|---|---|
| password-recovery | 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.
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:
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):
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" <no-reply@yourdomain.com>',
to: user.email,
subject: 'Redefinição de Senha',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #141414; color: #ffffff; border-radius: 8px;">
<h2 style="color: #E50914;">Recuperação de Senha</h2>
<p>Olá, ${user.name || 'Aluno'}.</p>
<p>Recebemos uma solicitação para redefinir a senha da sua conta.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #E50914; color: #ffffff; padding: 12px 24px; text-decoration: none; font-weight: bold; border-radius: 4px;">Redefinir Minha Senha</a>
</div>
<p>Se você não fez essa solicitação, pode ignorar este e-mail com segurança.</p>
<p style="font-size: 12px; color: #666666; margin-top: 30px; border-t: 1px solid #333333; padding-top: 10px;">Link válido por 1 hora.</p>
</div>
`
};
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.
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.
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:
import React, { useEffect, useState } from 'react';
export const App = () => {
const [resetToken, setResetToken] = useState<string | null>(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...
};