Arquitetando o Fluxo de Recuperação de Senhas
Fluxo completo de recuperação de senha com 4 etapas: código numérico com timing-safe comparison, JWT de propósito específico, rate limiting por IP e por email, hash do token no banco e invalidação de sessões após reset.
Recuperação de senha é um dos fluxos mais sensíveis de qualquer sistema — é a porta de entrada para quem não tem a senha. Implementações ingênuas expõem tokens longos em URLs (logados em proxies e analytics), não limitam tentativas (brute force de código OTP), e não invalidam sessões ativas após o reset (um atacante que sequestrou a conta ainda fica logado).
Este artigo implementa o fluxo completo em 4 etapas com as medidas de segurança que um sistema de produção exige: enumeração de usuários prevenida, código de 6 dígitos com timing-safe comparison, JWT com purpose específico, rate limiting em múltiplas camadas e invalidação de todas as sessões após a troca de senha.
Modelo do Banco de Dados
A modelagem do banco é a primeira decisão de segurança: nunca armazenar o código OTP em texto plano. Se o banco vazar, um atacante não deve conseguir usar os códigos de reset. Por isso armazenamos o SHA-256(codigo) — um hash irreversível. A coluna attempts limita o brute force a nível de banco (independente do rate limiting da API): após 5 tentativas erradas com um mesmo token, ele é invalidado mesmo que ainda esteja no prazo de validade.
import { Entity, PrimaryColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { UserModel } from './UserModel';
@Entity('password_resets')
export class PasswordResetModel {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => UserModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserModel;
// Armazenamos o HASH do código, não o código em si
// Se o banco vazar, o atacante não consegue usar os códigos
@Column({ name: 'code_hash' })
codeHash: string;
// Número de tentativas de verificação com este código
// Limita brute force mesmo sem rate limiting na API
@Column({ name: 'attempts', default: 0 })
attempts: number;
@Column({ name: 'expires_at', type: 'timestamp with time zone' })
expiresAt: Date;
// Marca como usado — impede reutilização mesmo dentro do prazo
@Column({ default: false })
used: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
}Etapa 1: Solicitar Recuperação
import { randomInt, createHash } from 'crypto';
import { addMinutes } from 'date-fns';
export class RequestPasswordResetUseCase {
constructor(
private usersRepo: IUsersRepository,
private passwordResetsRepo: IPasswordResetsRepository,
private mailQueue: IQueue
) {}
async execute(email: string): Promise<void> {
// SEMPRE retorna sem erro, mesmo se o e-mail não existir
// Previne enumeração de usuários (saber quais e-mails estão cadastrados)
const user = await this.usersRepo.findByEmail(email);
if (!user) {
// Aguarda o mesmo tempo que aguardaria se encontrasse o usuário
// Previne timing attack por diferença de latência de resposta
await new Promise((resolve) => setTimeout(resolve, 200));
return;
}
// Invalida códigos anteriores do mesmo usuário (apenas um ativo por vez)
await this.passwordResetsRepo.invalidateByUserId(user.id);
// Código numérico de 6 dígitos — mais amigável que UUID para digitar
const code = randomInt(100_000, 999_999);
// Armazena o HASH do código no banco, não o código em si
const codeHash = createHash('sha256').update(code.toString()).digest('hex');
await this.passwordResetsRepo.create({
id: crypto.randomUUID(),
userId: user.id,
codeHash,
attempts: 0,
expiresAt: addMinutes(new Date(), 15), // 15 minutos de validade
used: false,
});
// E-mail enfileirado — não bloqueia a resposta HTTP
await this.mailQueue.add('send-recovery-email', {
to: user.email,
name: user.name,
code, // O código em texto puro vai APENAS no e-mail, nunca no banco
});
}
}Etapa 2: Rate Limiting na Rota
Um código OTP de 6 dígitos tem apenas 900.000 combinações — o atacante poderia tentar todas em minutos sem rate limiting. Configure limites em duas camadas:
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { getRedisClient } from '../infra/redis/RedisClient';
const router = Router();
// Rate limit por IP: máximo 5 solicitações de reset por 15 minutos
const requestResetLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Muitas tentativas. Aguarde 15 minutos.' },
standardHeaders: true,
store: new RedisStore({ sendCommand: (...args) => getRedisClient().call(...args) }),
keyGenerator: (req) => `rate:reset:request:${req.ip}`,
});
// Rate limit por IP: máximo 10 tentativas de verificação de código por 15 minutos
const verifyCodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Muitas tentativas de verificação. Aguarde.' },
store: new RedisStore({ sendCommand: (...args) => getRedisClient().call(...args) }),
keyGenerator: (req) => `rate:reset:verify:${req.ip}`,
});
router.post('/request', requestResetLimiter, controller.request);
router.post('/verify', verifyCodeLimiter, controller.verify);
router.post('/reset', controller.reset); // O JWT já protege esta rota
export { router as passwordResetRoutes };Etapa 3: Verificar Código e Emitir JWT de Propósito
import { createHash, timingSafeEqual } from 'crypto';
import { sign } from 'jsonwebtoken';
import { isBefore } from 'date-fns';
export class VerifyPasswordResetCodeUseCase {
constructor(private passwordResetsRepo: IPasswordResetsRepository) {}
async execute(email: string, code: string): Promise<{ resetToken: string }> {
const reset = await this.passwordResetsRepo.findActiveByEmail(email);
// Usa a mesma mensagem de erro para código inválido e código expirado
// Não dá informação ao atacante sobre o estado do código
const genericError = new AppError('Código inválido ou expirado.', 400);
if (!reset || reset.used) throw genericError;
// Verifica expiração
if (isBefore(reset.expiresAt, new Date())) {
await this.passwordResetsRepo.markAsUsed(reset.id);
throw genericError;
}
// Verifica limite de tentativas (brute force protection no banco)
if (reset.attempts >= 5) {
throw new AppError('Código bloqueado por muitas tentativas.', 429);
}
// Incrementa tentativas ANTES de verificar o código
// (mesmo em tentativas inválidas, o contador sobe)
await this.passwordResetsRepo.incrementAttempts(reset.id);
// Timing-safe comparison: evita timing attacks
// compare() leva o mesmo tempo independente de onde os bytes diferem
const inputHash = Buffer.from(
createHash('sha256').update(code).digest('hex')
);
const storedHash = Buffer.from(reset.codeHash);
const isValid = inputHash.length === storedHash.length &&
timingSafeEqual(inputHash, storedHash);
if (!isValid) throw genericError;
// Código válido: marca como usado imediatamente (one-time use)
await this.passwordResetsRepo.markAsUsed(reset.id);
// Emite JWT com purpose específico — não pode ser usado para outras operações
const resetToken = sign(
{ purpose: 'password_reset', email },
process.env.JWT_SECRET as string,
{ subject: reset.userId, expiresIn: '15m' }
);
return { resetToken };
}
}Etapa 4: Alterar a Senha e Invalidar Sessões
import { verify, type JwtPayload } from 'jsonwebtoken';
import { hash } from 'bcrypt';
interface ResetTokenPayload extends JwtPayload {
purpose: string;
email: string;
sub: string;
}
export class ResetPasswordUseCase {
constructor(
private usersRepo: IUsersRepository,
private refreshTokensRepo: IRefreshTokensRepository
) {}
async execute(resetToken: string, newPassword: string): Promise<void> {
let payload: ResetTokenPayload;
try {
payload = verify(resetToken, process.env.JWT_SECRET as string) as ResetTokenPayload;
} catch {
throw new AppError('Token inválido ou expirado.', 401);
}
// Verifica que este JWT foi emitido especificamente para reset de senha
// Um JWT de autenticação normal não pode ser usado aqui
if (payload.purpose !== 'password_reset') {
throw new AppError('Token com propósito inválido.', 401);
}
const user = await this.usersRepo.findById(payload.sub);
if (!user) throw new AppError('Usuário não encontrado.', 404);
// Hash da nova senha (Argon2 ou bcrypt)
user.passwordHash = await hash(newPassword, 12);
await this.usersRepo.save(user);
// CRÍTICO: invalida TODOS os refresh tokens do usuário
// Um atacante que tinha sessão ativa perde o acesso imediatamente
await this.refreshTokensRepo.revokeAllByUserId(user.id);
// Notifica o usuário da troca de senha (e-mail de segurança)
// NÃO enfileire — envie de forma síncrona ou com alta prioridade
// O usuário precisa saber IMEDIATAMENTE se não foi ele quem trocou
await this.mailProvider.send({
to: user.email,
subject: 'Sua senha foi alterada',
html: `<p>Sua senha foi alterada em ${new Date().toLocaleString('pt-BR')}. Se não foi você, entre em contato imediatamente.</p>`,
});
}
}Nunca armazene o código OTP em texto puro no banco. Se um atacante obtiver acesso de leitura ao banco, poderia usar os códigos ativos para resetar senhas de qualquer usuário. Armazene sempre o SHA-256 do código — como fizemos com codeHash. E use timingSafeEqual para comparação, não ===, que vaza informação por diferença de tempo.
Checklist de Segurança
- Anti-enumeração — retorna sucesso mesmo quando o e-mail não existe
- Timing attack —
setTimeoutartificial etimingSafeEqualna comparação - One-time use — código marcado como
usedimediatamente após verificação - Expiração — 15 minutos no banco + JWT de 15 minutos
- Limite de tentativas — máximo 5 por código + rate limiting por IP no Redis
- JWT de propósito —
purpose: 'password_reset'impede uso do token em outras rotas - Invalidação de sessões — todos os refresh tokens revogados após o reset
- Notificação — e-mail de segurança enviado após a troca com alta prioridade
Conclusão
Recuperação de senha é um fluxo onde cada detalhe importa. A separação em 4 etapas (solicitar → verificar código → obter JWT → trocar senha) permite aplicar controles de segurança específicos em cada fase. O hash do código OTP no banco, o timingSafeEqual na comparação, o JWT com purpose e a invalidação das sessões são as medidas que transformam um fluxo funcional em um fluxo seguro.