Projeto D: Sistema de Autenticação com Validação por Email
Descrição
Desenvolva uma API RESTful completa de gestão de usuários com validação por email, gestão de papéis e recuperação de senha.
Funcionalidades Requeridas
1. Cadastro com Validação por Email
POST /api/auth/register - Cadastro (envia email de validação)
GET /api/auth/verify - Validar conta via token de email
POST /api/auth/resend - Reenviar email de validação
2. Login e JWT
POST /api/auth/login - Login (conta válida obrigatória)
POST /api/auth/refresh - Atualizar token
POST /api/auth/logout - Logout
3. Recuperação de Senha
POST /api/auth/forgot-password - Solicitar reset (envia email)
POST /api/auth/reset-password - Alterar senha via token
4. Gestão de Usuários
GET /api/users/me - Meu perfil
PUT /api/users/me - Editar meu perfil
PUT /api/users/me/password - Alterar minha senha
GET /api/admin/users - Listar usuários (ADMIN)
PUT /api/admin/users/{id}/role - Alterar papel (ADMIN)
DELETE /api/admin/users/{id} - Excluir usuário (ADMIN)
Esquema do Banco de Dados
-- Tabela users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'USER',
enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
-- Tabela verification_tokens (validação de email)
CREATE TABLE verification_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabela password_reset_tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Entidades JPA
User Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private String firstName;
private String lastName;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private Role role = Role.USER;
@Builder.Default
private boolean enabled = false; // false até validação por email
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public enum Role {
USER, ADMIN
}
}
VerificationToken Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "verification_tokens")
@Getter @Setter
@NoArgsConstructor
public class VerificationToken {
private static final int EXPIRATION_HOURS = 24;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String token;
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private LocalDateTime expiryDate;
@Builder.Default
private boolean used = false;
private LocalDateTime createdAt;
public VerificationToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
PasswordResetToken Entity
package com.example.auth.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "password_reset_tokens")
@Getter @Setter
@NoArgsConstructor
public class PasswordResetToken {
private static final int EXPIRATION_HOURS = 1; // 1 hora para reset
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String token;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private LocalDateTime expiryDate;
@Builder.Default
private boolean used = false;
private LocalDateTime createdAt;
public PasswordResetToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
DTOs
Request DTOs
// RegisterRequest.java
package com.example.auth.dto.request;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Email obrigatório")
@Email(message = "Email inválido")
private String email;
@NotBlank(message = "Senha obrigatória")
@Size(min = 8, message = "Mínimo 8 caracteres")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Deve conter maiúscula, minúscula e número"
)
private String password;
@NotBlank(message = "Nome obrigatório")
private String firstName;
@NotBlank(message = "Sobrenome obrigatório")
private String lastName;
}
// LoginRequest.java
@Data
public class LoginRequest {
@NotBlank(message = "Email obrigatório")
@Email
private String email;
@NotBlank(message = "Senha obrigatória")
private String password;
}
// ForgotPasswordRequest.java
@Data
public class ForgotPasswordRequest {
@NotBlank(message = "Email obrigatório")
@Email
private String email;
}
// ResetPasswordRequest.java
@Data
public class ResetPasswordRequest {
@NotBlank(message = "Token obrigatório")
private String token;
@NotBlank(message = "Nova senha obrigatória")
@Size(min = 8)
private String newPassword;
}
// ChangeRoleRequest.java
@Data
public class ChangeRoleRequest {
@NotNull
private User.Role role;
}
Response DTOs
// AuthResponse.java
@Data
@Builder
public class AuthResponse {
private String token;
private String type;
private Long expiresIn;
private UserDto user;
}
// UserDto.java
@Data
@Builder
public class UserDto {
private Long id;
private String email;
private String firstName;
private String lastName;
private String role;
private boolean enabled;
private LocalDateTime createdAt;
}
// MessageResponse.java
@Data
@AllArgsConstructor
public class MessageResponse {
private String message;
}
Serviços
EmailService
package com.example.auth.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
private final JavaMailSender mailSender;
@Value("${app.base-url}")
private String baseUrl;
@Value("${spring.mail.username}")
private String fromEmail;
public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Async
public void sendVerificationEmail(String to, String token) {
String verifyUrl = baseUrl + "/api/auth/verify?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Valide sua conta");
message.setText(
"Bem-vindo!\n\n" +
"Clique no link abaixo para validar sua conta:\n\n" +
verifyUrl + "\n\n" +
"Este link expira em 24 horas.\n\n" +
"Se você não criou uma conta, ignore este email."
);
try {
mailSender.send(message);
log.info("Email de verificação enviado para: {}", to);
} catch (Exception e) {
log.error("Erro ao enviar email para {}: {}", to, e.getMessage());
}
}
@Async
public void sendPasswordResetEmail(String to, String token) {
String resetUrl = baseUrl + "/reset-password?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("Redefinição da sua senha");
message.setText(
"Você solicitou a redefinição de senha.\n\n" +
"Clique no link abaixo para escolher uma nova senha:\n\n" +
resetUrl + "\n\n" +
"Este link expira em 1 hora.\n\n" +
"Se você não fez esta solicitação, ignore este email."
);
try {
mailSender.send(message);
log.info("Email de reset enviado para: {}", to);
} catch (Exception e) {
log.error("Erro ao enviar email de reset para {}: {}", to, e.getMessage());
}
}
}
AuthService
package com.example.auth.service;
import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.exception.*;
import com.example.auth.model.*;
import com.example.auth.repository.*;
import com.example.auth.security.JwtService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final VerificationTokenRepository verificationTokenRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final EmailService emailService;
// Constructor injection...
@Transactional
public MessageResponse register(RegisterRequest request) {
// Verificar se email existe
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("Email já utilizado");
}
// Criar usuário (não ativado)
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.role(User.Role.USER)
.enabled(false)
.build();
userRepository.save(user);
// Criar token de verificação
VerificationToken verificationToken = new VerificationToken(user);
verificationTokenRepository.save(verificationToken);
// Enviar email
emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());
log.info("Usuário cadastrado: {}", user.getEmail());
return new MessageResponse(
"Cadastro realizado com sucesso! Verifique seu email para ativar sua conta."
);
}
@Transactional
public MessageResponse verifyEmail(String token) {
VerificationToken verificationToken = verificationTokenRepository
.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Token inválido"));
if (verificationToken.isUsed()) {
throw new InvalidTokenException("Token já utilizado");
}
if (verificationToken.isExpired()) {
throw new InvalidTokenException("Token expirado");
}
// Ativar usuário
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);
// Marcar token como utilizado
verificationToken.setUsed(true);
verificationTokenRepository.save(verificationToken);
log.info("Conta ativada: {}", user.getEmail());
return new MessageResponse("Conta ativada com sucesso! Agora você pode fazer login.");
}
@Transactional
public MessageResponse resendVerificationEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("Usuário não encontrado"));
if (user.isEnabled()) {
throw new BadRequestException("Conta já ativada");
}
// Invalidar tokens antigos
verificationTokenRepository.deleteByUser(user);
// Criar novo token
VerificationToken newToken = new VerificationToken(user);
verificationTokenRepository.save(newToken);
// Enviar email
emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());
return new MessageResponse("Email de verificação reenviado");
}
public AuthResponse login(LoginRequest request) {
// Verificar se a conta está ativada
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("Email ou senha incorretos"));
if (!user.isEnabled()) {
throw new AccountNotVerifiedException("Conta não verificada. Verifique seu email.");
}
// Autenticar
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// Gerar JWT
String token = jwtService.generateToken(user);
log.info("Login realizado: {}", user.getEmail());
return AuthResponse.builder()
.token(token)
.type("Bearer")
.expiresIn(jwtService.getExpiration())
.user(mapToUserDto(user))
.build();
}
@Transactional
public MessageResponse forgotPassword(ForgotPasswordRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);
// Sempre retornar a mesma mensagem (segurança)
if (user != null && user.isEnabled()) {
// Invalidar tokens antigos
passwordResetTokenRepository.deleteByUser(user);
// Criar novo token
PasswordResetToken resetToken = new PasswordResetToken(user);
passwordResetTokenRepository.save(resetToken);
// Enviar email
emailService.sendPasswordResetEmail(user.getEmail(), resetToken.getToken());
}
return new MessageResponse(
"Se o email existir, um link de redefinição foi enviado."
);
}
@Transactional
public MessageResponse resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = passwordResetTokenRepository
.findByToken(request.getToken())
.orElseThrow(() -> new InvalidTokenException("Token inválido"));
if (resetToken.isUsed()) {
throw new InvalidTokenException("Token já utilizado");
}
if (resetToken.isExpired()) {
throw new InvalidTokenException("Token expirado");
}
// Alterar senha
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
// Marcar token como utilizado
resetToken.setUsed(true);
passwordResetTokenRepository.save(resetToken);
log.info("Senha redefinida: {}", user.getEmail());
return new MessageResponse("Senha redefinida com sucesso");
}
private UserDto mapToUserDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}
UserService (Admin)
package com.example.auth.service;
import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.model.User;
import com.example.auth.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<UserDto> getAllUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(this::mapToDto);
}
@Transactional
public UserDto changeRole(Long userId, ChangeRoleRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("Usuário não encontrado"));
user.setRole(request.getRole());
User updated = userRepository.save(user);
return mapToDto(updated);
}
@Transactional
public void deleteUser(Long userId) {
if (!userRepository.existsById(userId)) {
throw new RuntimeException("Usuário não encontrado");
}
userRepository.deleteById(userId);
}
private UserDto mapToDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}
Controllers
AuthController
package com.example.auth.controller;
import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<MessageResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@GetMapping("/verify")
public ResponseEntity<MessageResponse> verifyEmail(@RequestParam String token) {
return ResponseEntity.ok(authService.verifyEmail(token));
}
@PostMapping("/resend")
public ResponseEntity<MessageResponse> resendVerification(
@RequestParam String email) {
return ResponseEntity.ok(authService.resendVerificationEmail(email));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/forgot-password")
public ResponseEntity<MessageResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(authService.forgotPassword(request));
}
@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
return ResponseEntity.ok(authService.resetPassword(request));
}
}
AdminController
package com.example.auth.controller;
import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final UserService userService;
public AdminController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users")
public ResponseEntity<Page<UserDto>> getAllUsers(Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}
@PutMapping("/users/{id}/role")
public ResponseEntity<UserDto> changeUserRole(
@PathVariable Long id,
@Valid @RequestBody ChangeRoleRequest request) {
return ResponseEntity.ok(userService.changeRole(id, request));
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Configuração de Email
application.yml
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
app:
base-url: http://localhost:8080
jwt:
secret: ${JWT_SECRET}
expiration: 86400000 # 24 horas
Para Gmail
- Ative a "Verificação em duas etapas"
- Crie uma "Senha de app" nas configurações do Google
- Use essa senha em
MAIL_PASSWORD
Alternativa: MailHog (Dev)
# Iniciar MailHog (intercepta os emails)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# Configuração application-dev.yml
spring:
mail:
host: localhost
port: 1025
# Não precisa de username/password
# Ver emails: http://localhost:8025
Fluxo Completo
1. Cadastro
POST /api/auth/register
{
"email": "john@example.com",
"password": "Password123",
"firstName": "John",
"lastName": "Doe"
}
Response: { "message": "Cadastro realizado com sucesso! Verifique seu email..." }
2. Validação de Email
GET /api/auth/verify?token=abc123-def456-...
Response: { "message": "Conta ativada com sucesso!" }
3. Login
POST /api/auth/login
{
"email": "john@example.com",
"password": "Password123"
}
Response: {
"token": "eyJhbGciOiJIUzI1NiIs...",
"type": "Bearer",
"expiresIn": 86400000,
"user": {
"id": 1,
"email": "john@example.com",
"role": "USER"
}
}
4. Esqueci a Senha
POST /api/auth/forgot-password
{
"email": "john@example.com"
}
Response: { "message": "Se o email existir, um link..." }
5. Reset de Senha
POST /api/auth/reset-password
{
"token": "xyz789-...",
"newPassword": "NewPassword456"
}
Response: { "message": "Senha redefinida com sucesso" }
6. Alterar Papel (Admin)
PUT /api/admin/users/1/role
Authorization: Bearer <admin-token>
{
"role": "ADMIN"
}
Response: {
"id": 1,
"email": "john@example.com",
"role": "ADMIN"
}
Testes Requeridos
| Teste | Descrição |
|---|---|
| Cadastro | Email enviado, usuário não ativado |
| Validação | Token válido ativa a conta |
| Token expirado | Erro se token expirado |
| Login | Falha se não verificado |
| Forgot password | Email enviado |
| Reset password | Senha alterada |
| Change role | Admin pode alterar papel |
Bônus
| Funcionalidade | Pontos |
|---|---|
| Rate limiting em auth | +3 |
| Histórico de logins | +3 |
| Bloqueio após X tentativas | +5 |
| 2FA (TOTP) | +10 |
| OAuth2 (Google) | +10 |