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:
- Solicitação de Reset: Usuário informa o email
- Geração de Código: Sistema gera código numérico único de 6 dígitos
- Envio de Email: Código é enviado por email
- Validação: Usuário usa o código para definir nova senha
- 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
- Não Revelar Informações: O endpoint de solicitação retorna sucesso mesmo se o email não existir
- Limpeza de Tokens: Remove tokens anteriores antes de criar novos
- Rollback em Falhas: Se o email falhar, o token é removido
- Logs Detalhados: Todas as operações são logadas para auditoria
- 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
- Usuário esquece senha e clica em "Esqueci minha senha"
- Informa email via POST
/api/auth/forgot-password - Recebe código de 6 dígitos por email
- Opcionalmente valida código via POST
/api/auth/validate-reset-code - Define nova senha via POST
/api/auth/reset-passwordcom 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:3000Configuraçã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=trueConfiguraçã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=trueBoas 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 minutes2. 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
- Rate Limiting: Limitar tentativas por IP/email (máximo 3 por hora)
- Captcha: Adicionar captcha no formulário de solicitação
- Notificações: Enviar email informando sobre mudança de senha
- Auditoria Completa: Log de IP, user agent, timestamp de todas as operações
- 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
PasswordResetTokencriada - 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
- Spring Security 6 Documentation
- Spring Mail Reference
- OWASP Password Reset Best Practices
- JWT Authentication Guide
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!