285 lines
9.5 KiB
Markdown
285 lines
9.5 KiB
Markdown
---
|
|
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" <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.
|
|
|
|
```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<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...
|
|
};
|
|
```
|