Pular para o conteúdo principal

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​

  1. Ative a "Verificação em duas etapas"
  2. Crie uma "Senha de app" nas configurações do Google
  3. 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​

TesteDescrição
CadastroEmail enviado, usuário não ativado
ValidaçãoToken válido ativa a conta
Token expiradoErro se token expirado
LoginFalha se não verificado
Forgot passwordEmail enviado
Reset passwordSenha alterada
Change roleAdmin pode alterar papel

Bônus​

FuncionalidadePontos
Rate limiting em auth+3
Histórico de logins+3
Bloqueio após X tentativas+5
2FA (TOTP)+10
OAuth2 (Google)+10

Voltar ao Projeto Final