microtecflix/.agents/skills/password-recovery/SKILL.md

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...
};