Saltar al contenido principal

Proyecto D: Sistema de Autenticación con Validación por Correo Electrónico

Descripción​

Desarrollar una API RESTful completa de gestión de usuarios con validación por correo electrónico, gestión de roles y recuperación de contraseña.


Funcionalidades Requeridas​

1. Registro con Validación por Correo Electrónico​

POST /api/auth/register     - Registro (envía correo de validación)
GET /api/auth/verify - Validar la cuenta vía token de correo
POST /api/auth/resend - Reenviar el correo de validación

2. Inicio de Sesión y JWT​

POST /api/auth/login        - Inicio de sesión (cuenta válida requerida)
POST /api/auth/refresh - Refrescar el token
POST /api/auth/logout - Cierre de sesión

3. Recuperación de Contraseña​

POST /api/auth/forgot-password  - Solicitar restablecimiento (envía correo)
POST /api/auth/reset-password - Cambiar la contraseña vía token

4. Gestión de Usuarios​

GET    /api/users/me            - Mi perfil
PUT /api/users/me - Modificar mi perfil
PUT /api/users/me/password - Cambiar mi contraseña
GET /api/admin/users - Lista de usuarios (ADMIN)
PUT /api/admin/users/{id}/role - Cambiar el rol (ADMIN)
DELETE /api/admin/users/{id} - Eliminar usuario (ADMIN)

Esquema de Base de Datos​

-- Table 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
);

-- Table verification_tokens (validación de correo)
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
);

-- Table 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 hasta validación de correo

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 restablecer

@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 = "Correo electrónico requerido")
@Email(message = "Correo electrónico inválido")
private String email;

@NotBlank(message = "Contraseña requerida")
@Size(min = 8, message = "Mínimo 8 caracteres")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Debe contener mayúscula, minúscula y número"
)
private String password;

@NotBlank(message = "Nombre requerido")
private String firstName;

@NotBlank(message = "Apellido requerido")
private String lastName;
}
// LoginRequest.java
@Data
public class LoginRequest {

@NotBlank(message = "Correo electrónico requerido")
@Email
private String email;

@NotBlank(message = "Contraseña requerida")
private String password;
}
// ForgotPasswordRequest.java
@Data
public class ForgotPasswordRequest {

@NotBlank(message = "Correo electrónico requerido")
@Email
private String email;
}
// ResetPasswordRequest.java
@Data
public class ResetPasswordRequest {

@NotBlank(message = "Token requerido")
private String token;

@NotBlank(message = "Nueva contraseña requerida")
@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;
}

Servicios​

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 su cuenta");
message.setText(
"¡Bienvenido!\n\n" +
"Haga clic en el siguiente enlace para validar su cuenta:\n\n" +
verifyUrl + "\n\n" +
"Este enlace expira en 24 horas.\n\n" +
"Si usted no creó una cuenta, ignore este correo."
);

try {
mailSender.send(message);
log.info("Correo de verificación enviado a: {}", to);
} catch (Exception e) {
log.error("Error enviando correo a {}: {}", 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("Restablecimiento de su contraseña");
message.setText(
"Usted ha solicitado un restablecimiento de contraseña.\n\n" +
"Haga clic en el siguiente enlace para elegir una nueva contraseña:\n\n" +
resetUrl + "\n\n" +
"Este enlace expira en 1 hora.\n\n" +
"Si usted no realizó esta solicitud, ignore este correo."
);

try {
mailSender.send(message);
log.info("Correo de restablecimiento enviado a: {}", to);
} catch (Exception e) {
log.error("Error enviando correo de restablecimiento a {}: {}", 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 si el correo ya existe
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("Correo electrónico ya en uso");
}

// Crear el usuario (no activado)
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);

// Crear el token de verificación
VerificationToken verificationToken = new VerificationToken(user);
verificationTokenRepository.save(verificationToken);

// Enviar el correo
emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());

log.info("Usuario registrado: {}", user.getEmail());

return new MessageResponse(
"¡Registro exitoso! Verifique su correo electrónico para activar su cuenta."
);
}

@Transactional
public MessageResponse verifyEmail(String token) {
VerificationToken verificationToken = verificationTokenRepository
.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Token inválido"));

if (verificationToken.isUsed()) {
throw new InvalidTokenException("Token ya utilizado");
}

if (verificationToken.isExpired()) {
throw new InvalidTokenException("Token expirado");
}

// Activar el usuario
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);

// Marcar el token como utilizado
verificationToken.setUsed(true);
verificationTokenRepository.save(verificationToken);

log.info("Cuenta activada: {}", user.getEmail());

return new MessageResponse("¡Cuenta activada con éxito! Ahora puede iniciar sesión.");
}

@Transactional
public MessageResponse resendVerificationEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("Usuario no encontrado"));

if (user.isEnabled()) {
throw new BadRequestException("Cuenta ya activada");
}

// Invalidar los tokens antiguos
verificationTokenRepository.deleteByUser(user);

// Crear nuevo token
VerificationToken newToken = new VerificationToken(user);
verificationTokenRepository.save(newToken);

// Enviar correo
emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());

return new MessageResponse("Correo de verificación reenviado");
}

public AuthResponse login(LoginRequest request) {
// Verificar si la cuenta está activada
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("Correo electrónico o contraseña incorrectos"));

if (!user.isEnabled()) {
throw new AccountNotVerifiedException("Cuenta no verificada. Verifique su correo electrónico.");
}

// Autenticar
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);

// Generar JWT
String token = jwtService.generateToken(user);

log.info("Inicio de sesión exitoso: {}", 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);

// Siempre retornar el mismo mensaje (seguridad)
if (user != null && user.isEnabled()) {
// Invalidar los tokens antiguos
passwordResetTokenRepository.deleteByUser(user);

// Crear nuevo token
PasswordResetToken resetToken = new PasswordResetToken(user);
passwordResetTokenRepository.save(resetToken);

// Enviar correo
emailService.sendPasswordResetEmail(user.getEmail(), resetToken.getToken());
}

return new MessageResponse(
"Si el correo existe, se ha enviado un enlace de restablecimiento."
);
}

@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 ya utilizado");
}

if (resetToken.isExpired()) {
throw new InvalidTokenException("Token expirado");
}

// Cambiar la contraseña
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);

// Marcar el token como utilizado
resetToken.setUsed(true);
passwordResetTokenRepository.save(resetToken);

log.info("Contraseña restablecida: {}", user.getEmail());

return new MessageResponse("Contraseña restablecida con éxito");
}

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("Usuario no 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("Usuario no 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();
}
}

Controladores​

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();
}
}

Configuración de Correo Electrónico​

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. Activar "Verificación en 2 pasos"
  2. Crear una "Contraseña de aplicación" en la configuración de Google
  3. Utilizar esta contraseña en MAIL_PASSWORD

Alternativa: MailHog (Dev)​

# Iniciar MailHog (intercepta los correos)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

# Configuración application-dev.yml
spring:
mail:
host: localhost
port: 1025
# No se necesita username/password

# Ver los correos: http://localhost:8025

Flujo Completo​

1. Registro​

POST /api/auth/register
{
"email": "john@example.com",
"password": "Password123",
"firstName": "John",
"lastName": "Doe"
}

Response: { "message": "¡Registro exitoso! Verifique su correo..." }

2. Validación de Correo​

GET /api/auth/verify?token=abc123-def456-...

Response: { "message": "¡Cuenta activada con éxito!" }

3. Inicio de Sesión​

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. Olvidé mi Contraseña​

POST /api/auth/forgot-password
{
"email": "john@example.com"
}

Response: { "message": "Si el correo existe, se ha enviado un enlace..." }

5. Restablecer Contraseña​

POST /api/auth/reset-password
{
"token": "xyz789-...",
"newPassword": "NewPassword456"
}

Response: { "message": "Contraseña restablecida con éxito" }

6. Cambiar Rol (Admin)​

PUT /api/admin/users/1/role
Authorization: Bearer <admin-token>
{
"role": "ADMIN"
}

Response: {
"id": 1,
"email": "john@example.com",
"role": "ADMIN"
}

Pruebas Requeridas​

TestDescripción
RegistroCorreo enviado, usuario no activado
ValidaciónToken válido activa la cuenta
Token expiradoError si el token está expirado
LoginFalla si no está verificado
Forgot passwordCorreo enviado
Reset passwordContraseña cambiada
Change roleAdmin puede cambiar rol

Bonus​

FuncionalidadPuntos
Rate limiting en auth+3
Historial de inicios de sesión+3
Bloqueo tras X intentos+5
2FA (TOTP)+10
OAuth2 (Google)+10

Volver al Proyecto Final