Create local password-recovery workspace skill for code reuse and documentation
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details

This commit is contained in:
Sidney Gomes 2026-07-23 23:10:31 -03:00
parent 4ce618c318
commit c3bffcbc65
1 changed files with 284 additions and 0 deletions

View File

@ -0,0 +1,284 @@
---
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...
};
```