Implementando Redefinição de Senha com Spring Security 6

Imagem de capa: Implementando Redefinição de Senha com Spring Security 6

Se você já implementou autenticação básica com Spring Security 6, um dos próximos passos é adicionar a funcionalidade de redefinição de senha. Essa função é essencial para qualquer aplicação, permitindo que usuários recuperem o acesso às suas contas de forma segura.

Neste guia, vamos implementar a funcionalidade completa de redefinição de senha usando Spring Boot, Spring Security 6 e Spring Mail.

Pré-requisitos

  • Spring Boot 4.0+
  • Spring Security 6
  • Spring Data JPA
  • Spring Mail
  • PostgreSQL
  • Autenticação JWT já implementada

Visão Geral da Arquitetura

O fluxo de reset de senha seguirá este padrão:

  1. Solicitação de Reset: Usuário informa o email
  2. Geração de Código: Sistema gera código numérico único de 6 dígitos
  3. Envio de Email: Código é enviado por email
  4. Validação: Usuário usa o código para definir nova senha
  5. Atualização: Senha é atualizada e código invalidado

1. Estrutura da Entidade PasswordResetToken

Diferentemente de adicionar campos à entidade User, criamos uma entidade separada para maior segurança e organização:

@Entity
@Getter
@NoArgsConstructor
@Table(name = "password_reset_tokens")
public class PasswordResetToken extends AbstractEntity {

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    private static final int EXPIRY_MINUTES = 15;

    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
    private User user;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(nullable = false, name = "expiry_date")
    private LocalDateTime expiryDate;

    @Column(nullable = false)
    private boolean used = false;

    public PasswordResetToken(User user) {
        this.user = user;
        this.token = generateCode();
        this.expiryDate = LocalDateTime.now().plusMinutes(EXPIRY_MINUTES);
    }

    private String generateCode() {
        return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000));
    }

    public boolean isValid() {
        return !used && !isExpired();
    }

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expiryDate);
    }

    public void markUsed() {
        this.used = true;
    }
}

Vantagens dessa abordagem

  • Separação de responsabilidades
  • Facilita auditoria e limpeza de tokens expirados
  • Permite múltiplos tokens simultâneos se necessário
  • Histórico de tentativas de reset
  • Validação de estado centralizada

2. Migration do Banco de Dados

Crie uma nova migration para a tabela de tokens:

CREATE TABLE password_reset_tokens (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    token VARCHAR(6) NOT NULL UNIQUE,
    expiry_date TIMESTAMP NOT NULL,
    used BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_password_reset_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE CASCADE
);

-- Índices para otimização
CREATE INDEX idx_password_reset_token ON password_reset_tokens(token);
CREATE INDEX idx_password_reset_user_id ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_expiry ON password_reset_tokens(expiry_date);
CREATE INDEX idx_password_reset_used ON password_reset_tokens(used);

Propósito dos Índices

  • token: Busca rápida durante validação
  • user_id: Limpar tokens antigos de um usuário
  • expiry_date: Job de limpeza de tokens expirados
  • used: Filtrar tokens já utilizados

3. DTOs de Request e Response

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;

public record ForgotPasswordRequest(
    @NotEmpty(message = "Email is required")
    @Email(message = "Invalid email format")
    String email
) {}
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;

public record ValidateResetCodeRequest(
    @NotEmpty(message = "Code is required")
    @Pattern(regexp = "\\d{6}", message = "Code must be 6 digits")
    String code
) {}
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

public record ResetPasswordRequest(
    @NotEmpty(message = "Code is required")
    String code,
    
    @NotEmpty(message = "New password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String newPassword
) {}
public record PasswordResetResponse(String message) {}

4. Repositório

@Repository
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {
    
    PasswordResetToken findByToken(String token);
    
    PasswordResetToken findByUser(User user);
    
    int deleteByExpiryDateBefore(LocalDateTime date);
}

5. Serviço de Email

@Service
public class EmailService {

    private final JavaMailSender mailSender;

    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void sendPasswordResetEmail(String toEmail, String resetCode) {
        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true);

            helper.setTo(toEmail);
            helper.setSubject("Reset Your Password");
            helper.setText(buildPasswordResetEmailContent(resetCode), true);

            mailSender.send(message);
        } catch (MessagingException e) {
            throw new EmailSendException("Failed to send password reset email", e);
        }
    }

    private String buildPasswordResetEmailContent(String resetCode) {
        return """
            <html>
            <body style="font-family: Arial, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto;">
                <div style="background-color: #f8f9fa; padding: 30px; border-radius: 10px;">
                    <h2 style="color: #333; margin-top: 0;">Reset Your Password</h2>
                    <p style="color: #666; font-size: 16px;">You requested to reset your password. Use the code below to continue:</p>
                    
                    <div style="background-color: white; padding: 20px; border-radius: 5px; text-align: center; margin: 30px 0;">
                        <p style="color: #999; font-size: 14px; margin: 0 0 10px 0;">Your Reset Code</p>
                        <h1 style="font-size: 36px; letter-spacing: 8px; color: #007bff; margin: 0; font-weight: bold;">
                            %s
                        </h1>
                    </div>
                    
                    <p style="color: #666; font-size: 16px;">Enter this code on the password reset page to continue.</p>
                    
                    <div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0;">
                        <p style="color: #856404; margin: 0; font-weight: bold;">This code expires in 15 minutes</p>
                    </div>
                    
                    <p style="color: #999; font-size: 14px; margin-top: 30px; border-top: 1px solid #ddd; padding-top: 20px;">
                        If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
                    </p>
                </div>
            </body>
            </html>
            """.formatted(resetCode);
    }
}

6. Serviço de Reset de Senha

Implemente a lógica de negócio completa:

@Service
@Transactional
public class PasswordResetService {
    private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class);

    private final UserRepository userRepository;
    private final PasswordResetTokenRepository tokenRepository;
    private final PasswordEncoder passwordEncoder;
    private final EmailService emailService;

    public PasswordResetService(UserRepository userRepository, 
                                PasswordResetTokenRepository tokenRepository,
                                PasswordEncoder passwordEncoder, 
                                EmailService emailService) {
        this.userRepository = userRepository;
        this.tokenRepository = tokenRepository;
        this.passwordEncoder = passwordEncoder;
        this.emailService = emailService;
    }

    /**
     * Solicita reset de senha enviando código por email
     * Não revela se o email existe por questões de segurança
     */
    public void requestPasswordReset(String email) {
        User user = userRepository.findByEmail(email).orElse(null);

        if (user == null) {
            log.warn("Password reset requested for non-existent email: {}", email);
            // Retorna sem erro para não revelar que email não existe
            return;
        }

        // Limpar token anterior se existir
        PasswordResetToken existing = tokenRepository.findByUser(user);
        if (existing != null) {
            tokenRepository.delete(existing);
        }

        // Criar e salvar novo token
        PasswordResetToken token = new PasswordResetToken(user);
        tokenRepository.save(token);

        // Enviar email com tratamento de erro
        try {
            emailService.sendPasswordResetEmail(user.getEmail(), token.getToken());
            log.info("Password reset code sent for user: {}", user.getId());
        } catch (Exception e) {
            log.error("Failed to send password reset email for user: {}", user.getId(), e);
            // Limpar token se email falhar
            tokenRepository.delete(token);
            throw e;
        }
    }

    /**
     * Valida código de reset (opcional - para melhor UX)
     * Permite verificar código antes de submeter nova senha
     */
    public void validateCode(String code) {
        PasswordResetToken resetToken = tokenRepository.findByToken(code);

        if (resetToken == null || !resetToken.isValid()) {
            throw new PasswordResetTokenExpiredException(
                "Invalid or expired password reset code"
            );
        }
    }

    /**
     * Reseta a senha usando código válido
     * Marca o código como usado para prevenir reutilização
     */
    public void resetPassword(String code, String newPassword) {
        PasswordResetToken resetToken = tokenRepository.findByToken(code);

        if (resetToken == null || !resetToken.isValid()) {
            throw new PasswordResetTokenExpiredException(
                "Invalid or expired password reset code"
            );
        }

        // Atualizar senha
        User user = resetToken.getUser();
        user.setPassword(passwordEncoder.encode(newPassword));
        userRepository.save(user);

        // Marcar token como usado
        resetToken.markUsed();
        tokenRepository.save(resetToken);

        log.info("Password reset successful for user: {}", user.getId());
    }
}

Boas Práticas de Segurança Implementadas

  1. Não Revelar Informações: O endpoint de solicitação retorna sucesso mesmo se o email não existir
  2. Limpeza de Tokens: Remove tokens anteriores antes de criar novos
  3. Rollback em Falhas: Se o email falhar, o token é removido
  4. Logs Detalhados: Todas as operações são logadas para auditoria
  5. Validação de Estado: Verifica se token está expirado e se já foi usado

7. Controller de Reset

Adicione os endpoints ao AuthController:

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    private static final Logger log = LoggerFactory.getLogger(AuthController.class);
    
    private final PasswordResetService passwordResetService;
    
    public AuthController(PasswordResetService passwordResetService) {
        this.passwordResetService = passwordResetService;
    }
    
    /**
     * Endpoint 1: Solicitar código de reset
     * POST /api/auth/forgot-password
     */
    @PostMapping("/forgot-password")
    public ResponseEntity<PasswordResetResponse> requestPasswordReset(
            @Valid @RequestBody ForgotPasswordRequest request) {
        
        log.info("Password reset requested for email: {}", request.email());
        passwordResetService.requestPasswordReset(request.email());
        
        return ResponseEntity.ok(
            new PasswordResetResponse(
                "If the email exists, a reset code has been sent"
            )
        );
    }
    
    /**
     * Endpoint 2: Validar código (opcional - para melhor UX)
     * POST /api/auth/validate-reset-code
     */
    @PostMapping("/validate-reset-code")
    public ResponseEntity<PasswordResetResponse> validateCode(
            @Valid @RequestBody ValidateResetCodeRequest request) {
        
        log.info("Validating reset code");
        passwordResetService.validateCode(request.code());
        
        return ResponseEntity.ok(
            new PasswordResetResponse("Code is valid")
        );
    }
    
    /**
     * Endpoint 3: Resetar senha com código
     * POST /api/auth/reset-password
     */
    @PostMapping("/reset-password")
    public ResponseEntity<PasswordResetResponse> resetPassword(
            @Valid @RequestBody ResetPasswordRequest request) {
        
        log.info("Password reset attempt with code");
        passwordResetService.resetPassword(request.code(), request.newPassword());
        
        return ResponseEntity.ok(
            new PasswordResetResponse("Password successfully reset")
        );
    }
}

Fluxo do Usuário

  1. Usuário esquece senha e clica em "Esqueci minha senha"
  2. Informa email via POST /api/auth/forgot-password
  3. Recebe código de 6 dígitos por email
  4. Opcionalmente valida código via POST /api/auth/validate-reset-code
  5. Define nova senha via POST /api/auth/reset-password com código e nova senha

8. Configuração de Email

Configuração de Email com MailHog (Desenvolvimento)

spring.mail.host=localhost
spring.mail.port=1025
spring.mail.username=
spring.mail.password=
spring.mail.properties.mail.smtp.auth=false
spring.mail.properties.mail.smtp.starttls.enable=false

# URL do frontend
app.frontend.url=http://localhost:3000

Configuração para SendGrid (Produção Recomendado)

spring.mail.host=smtp.sendgrid.net
spring.mail.port=587
spring.mail.username=apikey
spring.mail.password=${SENDGRID_API_KEY}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

Configuração para AWS SES (Produção)

spring.mail.host=email-smtp.us-east-1.amazonaws.com
spring.mail.port=587
spring.mail.username=${AWS_SES_USERNAME}
spring.mail.password=${AWS_SES_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

Boas Práticas de Segurança

  • NUNCA commite credenciais no código
  • Use variáveis de ambiente: ${AWS_SES_PASSWORD}
  • Considere usar Spring Cloud Config ou AWS Secrets Manager para produção
  • Use diferentes provedores para dev e prod

9. Tratamento de Exceções

Exception Customizada

public class PasswordResetTokenExpiredException extends RuntimeException {
    public PasswordResetTokenExpiredException(String message) {
        super(message);
    }
}

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(PasswordResetTokenExpiredException.class)
    public ResponseEntity<ErrorResponse> handlePasswordResetTokenExpired(
            PasswordResetTokenExpiredException ex) {
        
        ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
    
    @ExceptionHandler(EmailSendException.class)
    public ResponseEntity<ErrorResponse> handleEmailSendException(
            EmailSendException ex) {
        
        ErrorResponse error = new ErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "Failed to send reset email. Please try again later.",
            LocalDateTime.now()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

ErrorResponse DTO

public record ErrorResponse(
    int status,
    String message,
    LocalDateTime timestamp
) {}

10. Configuração de Segurança

Atualize o SecurityConfig para permitir acesso público aos endpoints de reset:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthFilter;
    
    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                // Endpoints públicos de autenticação
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers("/api/auth/register").permitAll()
                
                // Endpoints públicos de reset de senha
                .requestMatchers("/api/auth/forgot-password").permitAll()
                .requestMatchers("/api/auth/validate-reset-code").permitAll()
                .requestMatchers("/api/auth/reset-password").permitAll()
                
                // Todos os outros endpoints precisam autenticação
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

Importante: Esses endpoints DEVEM ser públicos, pois o usuário não está autenticado quando esquece a senha.

11. Testando a Implementação

1. Solicitar Reset de Senha

curl -X POST http://localhost:8080/api/auth/forgot-password \
  -H "Content-Type: application/json" \
  -d '{
    "email": "usuario@exemplo.com"
  }'

Resposta:

{
  "message": "If the email exists, a reset code has been sent"
}

Email recebido:

Subject: Reset Your Password

Your Reset Code
━━━━━━━━━━━━━━━━━━━━
   1 2 3 4 5 6
━━━━━━━━━━━━━━━━━━━━

Enter this code on the password reset page to continue.

This code expires in 15 minutes

2. Validar Código

curl -X POST http://localhost:8080/api/auth/validate-reset-code \
  -H "Content-Type: application/json" \
  -d '{
    "code": "123456"
  }'

Resposta (Sucesso):

{
  "message": "Code is valid"
}

Resposta (Erro):

{
  "status": 400,
  "message": "Invalid or expired password reset code",
  "timestamp": "2024-02-04T10:30:00"
}

3. Resetar Senha

curl -X POST http://localhost:8080/api/auth/reset-password \
  -H "Content-Type: application/json" \
  -d '{
    "code": "123456",
    "newPassword": "NovaSenha@2024"
  }'

Resposta (Sucesso):

{
  "message": "Password successfully reset"
}

Considerações de Segurança

Implementadas

  • Códigos Numéricos Seguros: Sistema gera códigos aleatórios criptograficamente seguros de 6 dígitos
  • Expiração Rápida: Códigos expiram em 15 minutos para máxima segurança
  • Uso Único: Cada código pode ser usado apenas uma vez através da flag used
  • Não Revelar Emails: Endpoint não confirma se email existe ou não
  • Logs de Segurança: Sistema registra todas as tentativas de reset para auditoria
  • Limpeza Automática: Tokens antigos são removidos antes de criar novos

Recomendadas para Produção

  1. Rate Limiting: Limitar tentativas por IP/email (máximo 3 por hora)
  2. Captcha: Adicionar captcha no formulário de solicitação
  3. Notificações: Enviar email informando sobre mudança de senha
  4. Auditoria Completa: Log de IP, user agent, timestamp de todas as operações
  5. Monitoramento: Alertas para padrões suspeitos de solicitações

Melhorias Futuras

1. Limpeza Automática de Tokens Expirados

@Component
public class TokenCleanupScheduler {
    
    private final PasswordResetTokenRepository tokenRepository;
    private static final Logger log = LoggerFactory.getLogger(TokenCleanupScheduler.class);
    
    @Scheduled(cron = "0 0 * * * *")  // A cada hora
    public void cleanupExpiredTokens() {
        LocalDateTime now = LocalDateTime.now();
        int deleted = tokenRepository.deleteByExpiryDateBefore(now);
        log.info("Cleaned up {} expired password reset tokens", deleted);
    }
}

Não esqueça de adicionar @EnableScheduling na classe principal:

@SpringBootApplication
@EnableScheduling
public class BackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }
}

2. Notificação de Mudança de Senha

public void resetPassword(String code, String newPassword) {
    // ... código existente ...
    
    // Notificar usuário sobre mudança de senha
    emailService.sendPasswordChangedNotification(user.getEmail());
}


public void sendPasswordChangedNotification(String toEmail) {
    try {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        
        helper.setTo(toEmail);
        helper.setSubject("Password Changed Successfully");
        helper.setText(buildPasswordChangedEmailContent(), true);
        
        mailSender.send(message);
    } catch (MessagingException e) {
        log.error("Failed to send password changed notification", e);
    }
}

private String buildPasswordChangedEmailContent() {
    return """
        <html>
        <body style="font-family: Arial, sans-serif; padding: 20px;">
            <h2>Password Changed</h2>
            <p>Your password was successfully changed.</p>
            <p>If you did not make this change, please contact support immediately.</p>
            <p style="color: #666; font-size: 12px; margin-top: 30px;">
                This is an automated notification. Please do not reply to this email.
            </p>
        </body>
        </html>
        """;
}

3. Templates de Email com Thymeleaf

Para emails mais profissionais, considere usar Thymeleaf:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
    <h2>Reset Your Password</h2>
    <p>Hi <span th:text="${userName}">User</span>,</p>
    <p>Your reset code is: <strong th:text="${resetCode}">123456</strong></p>
    <p>Expires in <span th:text="${expiryMinutes}">15</span> minutes</p>
</body>
</html>


@Service
public class EmailService {
    
    private final JavaMailSender mailSender;
    private final TemplateEngine templateEngine;
    
    public void sendPasswordResetEmail(String toEmail, String resetCode) {
        Context context = new Context();
        context.setVariable("resetCode", resetCode);
        context.setVariable("expiryMinutes", 15);
        
        String htmlContent = templateEngine.process("email/password-reset", context);
        
        // ... enviar email com htmlContent
    }
}

4. Auditoria Completa

@Entity
@Table(name = "password_reset_audit")
public class PasswordResetAudit {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private Long userId;
    private String ipAddress;
    private String userAgent;
    private LocalDateTime timestamp;
    
    @Enumerated(EnumType.STRING)
    private AuditAction action;  // REQUEST, VALIDATE, RESET, FAILED
    
    private boolean success;
    private String errorMessage;
}

Checklist de Implementação

Antes de considerar a feature completa, verifique:

Backend

  • Entidade PasswordResetToken criada
  • Migration executada no banco
  • DTOs de request criados e validados
  • Repository com métodos necessários
  • Service com lógica de reset implementado
  • Controller com 3 endpoints criado
  • Exception handling configurado
  • SecurityConfig atualizado
  • Email service configurado
  • Testes unitários escritos

Segurança

  • Tokens expiram em 15 minutos
  • Códigos são criptograficamente seguros
  • Não revela se email existe
  • Token usado apenas uma vez
  • Logs de auditoria implementados
  • Rate limiting considerado

Produção

  • Variáveis de ambiente configuradas
  • Credenciais de email seguras
  • Monitoramento de emails configurado
  • Job de limpeza de tokens agendado
  • Testes de integração passando
  • Documentação API atualizada

UX

  • Email com design profissional
  • Mensagens de erro claras
  • Tempo de expiração comunicado
  • Instruções de uso no email
  • Feedback visual no frontend

Recursos Adicionais

Conclusão

Com esta implementação, você tem um sistema robusto e seguro de reset de senha que segue as melhores práticas de segurança. O sistema utiliza:

  • Códigos numéricos de 6 dígitos criptograficamente seguros
  • Expiração rápida de 15 minutos
  • Validação completa de estado (expirado/usado)
  • Separação de responsabilidades com entidade dedicada
  • Logs detalhados para auditoria
  • Tratamento de exceções apropriado
  • Endpoints RESTful bem definidos

O código é minimalista mas completo, focando apenas no essencial para o funcionamento correto da feature. Lembre-se de sempre testar em ambiente de desenvolvimento antes de fazer deploy em produção, e considere implementar as melhorias sugeridas para um sistema ainda mais robusto.

Gostou deste artigo? Compartilhe!