Create local password-recovery workspace skill for code reuse and documentation
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s
Details
This commit is contained in:
parent
4ce618c318
commit
c3bffcbc65
|
|
@ -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...
|
||||
};
|
||||
```
|
||||
Loading…
Reference in New Issue