audios y podcast

This commit is contained in:
Tatiana Villa 2026-05-10 14:40:50 +02:00
parent 8b1f59c7f3
commit 376fa3c5a6
14 changed files with 462 additions and 24 deletions

0
mvnw vendored Normal file → Executable file
View File

View File

@ -3,9 +3,22 @@ package es.tatvil.taiageweb;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Punto de entrada de la aplicación <strong>TAIage</strong>.
*
* <p>Plataforma web para la preparación de las oposiciones TAI (Técnico Auxiliar
* de Informática) de la Administración General del Estado (AGE). Ofrece acceso
* al temario completo, legislación y noticias relevantes, con control de acceso
* por roles y pasarela de pago integrada con Stripe.</p>
*/
@SpringBootApplication @SpringBootApplication
public class TaiageApplication { public class TaiageApplication {
/**
* Arranca el contexto Spring Boot.
*
* @param args argumentos de línea de comandos (no se utilizan)
*/
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(TaiageApplication.class, args); SpringApplication.run(TaiageApplication.class, args);
} }

View File

@ -10,8 +10,13 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
/** /**
* Crea el usuario admin la primera vez que arranca la aplicación. * Inicializa datos mínimos en base de datos al arrancar la aplicación.
* IMPORTANTE: cambia la contraseña en cuanto puedas desde el panel /admin/usuarios. *
* <p>Si no existe ningún usuario con el email {@code admin@taiage.es}, crea
* automáticamente la cuenta de administrador con credenciales predeterminadas.</p>
*
* <p><strong>IMPORTANTE:</strong> cambia la contraseña por defecto en cuanto
* sea posible desde el panel {@code /admin/usuarios}.</p>
*/ */
@Component @Component
public class DataInitializer implements CommandLineRunner { public class DataInitializer implements CommandLineRunner {
@ -19,11 +24,22 @@ public class DataInitializer implements CommandLineRunner {
private final UsuarioRepository repo; private final UsuarioRepository repo;
private final PasswordEncoder encoder; private final PasswordEncoder encoder;
/**
* Constructor con inyección de dependencias.
*
* @param repo repositorio de usuarios
* @param encoder encoder de contraseñas BCrypt
*/
public DataInitializer(UsuarioRepository repo, PasswordEncoder encoder) { public DataInitializer(UsuarioRepository repo, PasswordEncoder encoder) {
this.repo = repo; this.repo = repo;
this.encoder = encoder; this.encoder = encoder;
} }
/**
* Crea el usuario administrador si todavía no existe en la base de datos.
*
* @param args argumentos de línea de comandos (no se utilizan)
*/
@Override @Override
public void run(String... args) { public void run(String... args) {
if (repo.findByEmail("admin@taiage.es").isEmpty()) { if (repo.findByEmail("admin@taiage.es").isEmpty()) {

View File

@ -10,15 +10,41 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
/**
* Configuración de seguridad de la aplicación.
*
* <p>Define las reglas de acceso HTTP, el proveedor de autenticación basado en
* base de datos y la configuración del formulario de login/logout.</p>
*
* <ul>
* <li>Rutas públicas: {@code /}, {@code /login}, {@code /registro}, {@code /leyes},
* {@code /noticias}, {@code /webhook/stripe} y recursos estáticos.</li>
* <li>Panel admin ({@code /admin/**}): requiere {@code ROLE_ADMIN}.</li>
* <li>Contenido de pago ({@code /curso/**}, {@code /api/**}): requiere
* {@code ROLE_PAGADO} o {@code ROLE_ADMIN}.</li>
* </ul>
*/
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
/**
* Crea el {@link PasswordEncoder} BCrypt usado en toda la aplicación.
*
* @return encoder BCrypt con factor de coste por defecto (10)
*/
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
/**
* Crea el proveedor de autenticación DAO que consulta {@link UsuarioService}.
*
* @param service servicio que implementa {@link org.springframework.security.core.userdetails.UserDetailsService}
* @param encoder encoder de contraseñas
* @return proveedor configurado
*/
@Bean @Bean
public DaoAuthenticationProvider authProvider(UsuarioService service, PasswordEncoder encoder) { public DaoAuthenticationProvider authProvider(UsuarioService service, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider(service); var provider = new DaoAuthenticationProvider(service);
@ -26,6 +52,13 @@ public class SecurityConfig {
return provider; return provider;
} }
/**
* Define la cadena de filtros de seguridad HTTP.
*
* @param http objeto de configuración de Spring Security
* @return cadena de filtros construida
* @throws Exception si la configuración es inválida
*/
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@ -39,7 +72,7 @@ public class SecurityConfig {
"/", "/inicio", "/login", "/registro", "/", "/inicio", "/login", "/registro",
"/leyes", "/noticias", "/acceso-denegado", "/error", "/leyes", "/noticias", "/acceso-denegado", "/error",
"/webhook/stripe", "/webhook/stripe",
"/css/**", "/js/**", "/images/**", "/leyes/**", "/favicon.ico" "/css/**", "/js/**", "/images/**", "/leyes/**", "/audios/**", "/favicon.ico"
).permitAll() ).permitAll()
// Panel de administración // Panel de administración
.requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/admin/**").hasRole("ADMIN")

View File

@ -5,12 +5,23 @@ import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/**
* Inicializa el SDK de Stripe con la clave secreta de la aplicación.
*
* <p>La clave se obtiene de la propiedad {@code stripe.secret-key}, que debe
* definirse como variable de entorno {@code STRIPE_SECRET_KEY} en producción.</p>
*/
@Configuration @Configuration
public class StripeConfig { public class StripeConfig {
/** Clave secreta de la cuenta Stripe (inyectada desde propiedades). */
@Value("${stripe.secret-key}") @Value("${stripe.secret-key}")
private String secretKey; private String secretKey;
/**
* Asigna la clave secreta al SDK de Stripe tras la construcción del bean.
* Se ejecuta una sola vez al arrancar el contexto de Spring.
*/
@PostConstruct @PostConstruct
public void init() { public void init() {
Stripe.apiKey = secretKey; Stripe.apiKey = secretKey;

View File

@ -6,37 +6,74 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controlador MVC para el panel de administración de usuarios.
*
* <p>Todas las rutas bajo {@code /admin} requieren {@code ROLE_ADMIN}
* (configurado en {@link es.tatvil.taiageweb.config.SecurityConfig}).
* Permite listar, habilitar/deshabilitar, asignar acceso de pago,
* crear y eliminar usuarios.</p>
*/
@Controller @Controller
@RequestMapping("/admin") @RequestMapping("/admin")
public class AdminController { public class AdminController {
private final UsuarioService service; private final UsuarioService service;
/**
* Constructor con inyección de dependencias.
*
* @param service servicio de gestión de usuarios
*/
public AdminController(UsuarioService service) { public AdminController(UsuarioService service) {
this.service = service; this.service = service;
} }
/**
* Lista todos los usuarios registrados.
*
* @param model modelo Thymeleaf al que se añade el atributo {@code usuarios}
* @return nombre de la plantilla {@code admin/usuarios}
*/
@GetMapping({"", "/", "/usuarios"}) @GetMapping({"", "/", "/usuarios"})
public String listar(Model model) { public String listar(Model model) {
model.addAttribute("usuarios", service.listarTodos()); model.addAttribute("usuarios", service.listarTodos());
return "admin/usuarios"; return "admin/usuarios";
} }
/** Activa o desactiva la cuenta */ /**
* Activa o desactiva la cuenta del usuario indicado.
*
* @param id identificador del usuario
* @return redirección a {@code /admin/usuarios}
*/
@PostMapping("/usuarios/{id}/toggle-habilitado") @PostMapping("/usuarios/{id}/toggle-habilitado")
public String toggleHabilitado(@PathVariable Long id) { public String toggleHabilitado(@PathVariable Long id) {
service.toggleHabilitado(id); service.toggleHabilitado(id);
return "redirect:/admin/usuarios"; return "redirect:/admin/usuarios";
} }
/** Concede o revoca el acceso al curso */ /**
* Concede o revoca el acceso al curso ({@code ROLE_PAGADO}) del usuario indicado.
*
* @param id identificador del usuario
* @return redirección a {@code /admin/usuarios}
*/
@PostMapping("/usuarios/{id}/toggle-pagado") @PostMapping("/usuarios/{id}/toggle-pagado")
public String togglePagado(@PathVariable Long id) { public String togglePagado(@PathVariable Long id) {
service.toggleRolPagado(id); service.toggleRolPagado(id);
return "redirect:/admin/usuarios"; return "redirect:/admin/usuarios";
} }
/** El admin crea un usuario directamente activo */ /**
* Crea un usuario directamente activo desde el panel de administración.
*
* @param email email del nuevo usuario
* @param password contraseña en texto plano
* @param pagado si {@code true}, se asigna {@code ROLE_PAGADO} además de {@code ROLE_USER}
* @param ra atributos flash para mensajes de error entre redirecciones
* @return redirección a {@code /admin/usuarios}
*/
@PostMapping("/usuarios/nuevo") @PostMapping("/usuarios/nuevo")
public String crear( public String crear(
@RequestParam String email, @RequestParam String email,
@ -51,7 +88,12 @@ public class AdminController {
return "redirect:/admin/usuarios"; return "redirect:/admin/usuarios";
} }
/** Elimina un usuario */ /**
* Elimina un usuario de la base de datos.
*
* @param id identificador del usuario a eliminar
* @return redirección a {@code /admin/usuarios}
*/
@PostMapping("/usuarios/{id}/eliminar") @PostMapping("/usuarios/{id}/eliminar")
public String eliminar(@PathVariable Long id) { public String eliminar(@PathVariable Long id) {
service.eliminar(id); service.eliminar(id);

View File

@ -13,6 +13,20 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
/**
* Controlador MVC para el flujo de pago con Stripe.
*
* <p>Gestiona tres pasos del proceso de compra:</p>
* <ol>
* <li>Mostrar la página informativa con precio ({@code GET /pagar}).</li>
* <li>Crear la sesión de checkout en Stripe y redirigir al usuario
* ({@code POST /pagar/iniciar}).</li>
* <li>Mostrar páginas de éxito o cancelación tras volver de Stripe.</li>
* </ol>
*
* <p>La concesión real del acceso ocurre vía webhook en
* {@link WebhookController}, no en las páginas de retorno de Stripe.</p>
*/
@Controller @Controller
public class PagoController { public class PagoController {
@ -24,11 +38,25 @@ public class PagoController {
private final UsuarioRepository usuarioRepo; private final UsuarioRepository usuarioRepo;
/**
* Constructor con inyección de dependencias.
*
* @param usuarioRepo repositorio de usuarios
*/
public PagoController(UsuarioRepository usuarioRepo) { public PagoController(UsuarioRepository usuarioRepo) {
this.usuarioRepo = usuarioRepo; this.usuarioRepo = usuarioRepo;
} }
/** Página informativa con precio y botón "Pagar ahora" */ /**
* Muestra la página informativa de pago con el precio actual.
*
* <p>Si el usuario ya tiene {@code ROLE_PAGADO}, se le redirige directamente
* al curso sin pasar por la pasarela.</p>
*
* @param auth autenticación del usuario en sesión (puede ser {@code null} si no está autenticado)
* @param model modelo Thymeleaf para pasar el precio formateado
* @return vista {@code pagar} o redirección a {@code /curso}
*/
@GetMapping("/pagar") @GetMapping("/pagar")
public String mostrarPagina(Authentication auth, Model model) { public String mostrarPagina(Authentication auth, Model model) {
// Si ya tiene acceso, redirigir directo al curso // Si ya tiene acceso, redirigir directo al curso
@ -40,7 +68,17 @@ public class PagoController {
return "pagar"; return "pagar";
} }
/** Crea la sesión de pago en Stripe y redirige a su pasarela */ /**
* Crea una sesión de pago en Stripe y redirige al usuario a la pasarela.
*
* <p>Utiliza el id del usuario como {@code clientReferenceId} para poder
* identificarlo cuando llegue el webhook de confirmación.</p>
*
* @param auth autenticación del usuario en sesión
* @param request petición HTTP para construir las URLs de retorno
* @return redirección a la URL de checkout de Stripe
* @throws StripeException si falla la comunicación con la API de Stripe
*/
@PostMapping("/pagar/iniciar") @PostMapping("/pagar/iniciar")
public String iniciarPago(Authentication auth, HttpServletRequest request) throws StripeException { public String iniciarPago(Authentication auth, HttpServletRequest request) throws StripeException {
String baseUrl = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); String baseUrl = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort();
@ -71,11 +109,24 @@ public class PagoController {
return "redirect:" + session.getUrl(); return "redirect:" + session.getUrl();
} }
/**
* Página de confirmación mostrada tras un pago exitoso.
*
* <p>El acceso real al curso se concede mediante el webhook de Stripe,
* no en esta página.</p>
*
* @return nombre de la plantilla {@code pago-exito}
*/
@GetMapping("/pagar/exito") @GetMapping("/pagar/exito")
public String exitoPago() { public String exitoPago() {
return "pago-exito"; return "pago-exito";
} }
/**
* Página mostrada cuando el usuario cancela el pago en Stripe.
*
* @return nombre de la plantilla {@code pago-cancelado}
*/
@GetMapping("/pagar/cancelado") @GetMapping("/pagar/cancelado")
public String canceladoPago() { public String canceladoPago() {
return "pago-cancelado"; return "pago-cancelado";

View File

@ -7,20 +7,49 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
/**
* Controlador MVC para el auto-registro de nuevos usuarios.
*
* <p>Procesa el formulario de registro en {@code /registro}. La cuenta creada
* queda deshabilitada hasta que el administrador la active desde
* {@code /admin/usuarios}.</p>
*/
@Controller @Controller
public class RegistroController { public class RegistroController {
private final UsuarioService service; private final UsuarioService service;
/**
* Constructor con inyección de dependencias.
*
* @param service servicio de gestión de usuarios
*/
public RegistroController(UsuarioService service) { public RegistroController(UsuarioService service) {
this.service = service; this.service = service;
} }
/**
* Muestra el formulario de registro.
*
* @return nombre de la plantilla {@code registro}
*/
@GetMapping("/registro") @GetMapping("/registro")
public String mostrarFormulario() { public String mostrarFormulario() {
return "registro"; return "registro";
} }
/**
* Procesa el formulario de registro.
*
* <p>Valida que las contraseñas coincidan y delega en {@link UsuarioService#registrar}.
* Si el registro tiene éxito, redirige al login con el parámetro {@code ?registrado}.</p>
*
* @param email email del nuevo usuario
* @param password contraseña elegida
* @param passwordConfirm confirmación de la contraseña
* @param model modelo Thymeleaf para enviar mensajes de error
* @return redirección a {@code /login?registrado} si éxito, o la vista {@code registro} con error
*/
@PostMapping("/registro") @PostMapping("/registro")
public String registrar( public String registrar(
@RequestParam String email, @RequestParam String email,

View File

@ -11,10 +11,32 @@ import org.springframework.web.bind.annotation.RestController;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
/**
* API REST que sirve los ficheros Markdown del temario.
*
* <p>Todos los recursos están bajo {@code /api/temas/**} y requieren
* autenticación con {@code ROLE_PAGADO} o {@code ROLE_ADMIN} (configurado en
* {@link es.tatvil.taiageweb.config.SecurityConfig}).</p>
*
* <p>Los ficheros se leen desde el classpath en {@code temas/} y se devuelven
* como texto plano UTF-8. Se protege contra ataques de <em>path traversal</em>
* rechazando rutas que contengan {@code ..} o {@code //}.</p>
*/
@RestController @RestController
@RequestMapping("/api/temas") @RequestMapping("/api/temas")
public class TemaController { public class TemaController {
/**
* Devuelve el contenido Markdown de un tema dado su ruta relativa.
*
* <p>Ejemplo de petición: {@code GET /api/temas/bloque1/tema1.md}</p>
*
* @param request petición HTTP de la que se extrae la ruta del recurso
* @return {@code 200 OK} con el contenido Markdown como texto plano,
* {@code 400 Bad Request} si la ruta es inválida,
* {@code 404 Not Found} si el fichero no existe
* @throws IOException si ocurre un error leyendo el recurso del classpath
*/
@GetMapping("/**") @GetMapping("/**")
public ResponseEntity<String> getTema(HttpServletRequest request) throws IOException { public ResponseEntity<String> getTema(HttpServletRequest request) throws IOException {
String uri = request.getRequestURI(); String uri = request.getRequestURI();

View File

@ -4,26 +4,60 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
/** /**
* Sirve las páginas principales del sitio a través de Thymeleaf. * Controlador MVC que sirve las páginas principales del sitio a través de Thymeleaf.
*
* <p>Todas las rutas devuelven el nombre de la plantilla correspondiente en
* {@code src/main/resources/templates/}. Spring Security controla qué rutas
* son accesibles sin autenticación.</p>
*/ */
@Controller @Controller
public class WebController { public class WebController {
/**
* Página de inicio.
*
* @return nombre de la plantilla {@code index}
*/
@GetMapping({"/", "/inicio"}) @GetMapping({"/", "/inicio"})
public String inicio() { return "index"; } public String inicio() { return "index"; }
/**
* Página de inicio de sesión.
*
* @return nombre de la plantilla {@code login}
*/
@GetMapping("/login") @GetMapping("/login")
public String login() { return "login"; } public String login() { return "login"; }
/**
* Visor del temario del curso (requiere {@code ROLE_PAGADO} o {@code ROLE_ADMIN}).
*
* @return nombre de la plantilla {@code curso}
*/
@GetMapping("/curso") @GetMapping("/curso")
public String curso() { return "curso"; } public String curso() { return "curso"; }
/**
* Navegador de legislación; acceso público.
*
* @return nombre de la plantilla {@code leyes}
*/
@GetMapping("/leyes") @GetMapping("/leyes")
public String leyes() { return "leyes"; } public String leyes() { return "leyes"; }
/**
* Agregador de noticias INAP/BOE; acceso público.
*
* @return nombre de la plantilla {@code noticias}
*/
@GetMapping("/noticias") @GetMapping("/noticias")
public String noticias() { return "noticias"; } public String noticias() { return "noticias"; }
/**
* Página de acceso denegado (HTTP 403).
*
* @return nombre de la plantilla {@code acceso-denegado}
*/
@GetMapping("/acceso-denegado") @GetMapping("/acceso-denegado")
public String accesoDenegado() { return "acceso-denegado"; } public String accesoDenegado() { return "acceso-denegado"; }
} }

View File

@ -9,18 +9,49 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* Controlador REST que recibe y procesa webhooks de Stripe.
*
* <p>Escucha en {@code POST /webhook/stripe} (ruta excluida de CSRF en
* {@link es.tatvil.taiageweb.config.SecurityConfig}). Verifica la firma
* del payload con el secreto de webhook antes de procesar ningún evento.</p>
*
* <p>Evento gestionado:</p>
* <ul>
* <li>{@code checkout.session.completed} concede acceso al curso al usuario
* identificado por {@code clientReferenceId}.</li>
* </ul>
*/
@RestController @RestController
public class WebhookController { public class WebhookController {
/** Secreto de webhook de Stripe (inyectado desde {@code STRIPE_WEBHOOK_SECRET}). */
@Value("${stripe.webhook-secret}") @Value("${stripe.webhook-secret}")
private String webhookSecret; private String webhookSecret;
private final UsuarioService usuarioService; private final UsuarioService usuarioService;
/**
* Constructor con inyección de dependencias.
*
* @param usuarioService servicio de gestión de usuarios
*/
public WebhookController(UsuarioService usuarioService) { public WebhookController(UsuarioService usuarioService) {
this.usuarioService = usuarioService; this.usuarioService = usuarioService;
} }
/**
* Endpoint receptor de eventos de Stripe.
*
* <p>Verifica la firma {@code Stripe-Signature} del payload. Si es válida
* y el evento es {@code checkout.session.completed}, concede
* {@code ROLE_PAGADO} al usuario referenciado.</p>
*
* @param payload cuerpo crudo de la petición HTTP (necesario para verificar la firma)
* @param sigHeader valor del header {@code Stripe-Signature}
* @return {@code 200 OK} si el evento se procesa correctamente,
* {@code 400 Bad Request} si la firma es inválida
*/
@PostMapping("/webhook/stripe") @PostMapping("/webhook/stripe")
public ResponseEntity<String> stripeWebhook( public ResponseEntity<String> stripeWebhook(
@RequestBody String payload, @RequestBody String payload,

View File

@ -4,55 +4,135 @@ import jakarta.persistence.*;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
/**
* Entidad JPA que representa a un usuario registrado en la plataforma.
*
* <p>Los usuarios se almacenan en la tabla {@code usuarios}. Sus roles se guardan
* en la tabla auxiliar {@code usuario_roles} mediante una relación
* {@code @ElementCollection}.</p>
*
* <h3>Roles disponibles</h3>
* <ul>
* <li>{@code ROLE_USER} usuario registrado, sin acceso al curso.</li>
* <li>{@code ROLE_PAGADO} ha completado el pago; accede al temario completo.</li>
* <li>{@code ROLE_ADMIN} administrador; acceso total al panel de gestión.</li>
* </ul>
*/
@Entity @Entity
@Table(name = "usuarios") @Table(name = "usuarios")
public class Usuario { public class Usuario {
/** Identificador autogenerado (clave primaria). */
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/** Dirección de correo electrónico; única e indexada, se usa como nombre de usuario. */
@Column(unique = true, nullable = false, length = 100) @Column(unique = true, nullable = false, length = 100)
private String email; private String email;
/** Contraseña cifrada con BCrypt. Nunca se almacena en texto plano. */
@Column(nullable = false, length = 255) @Column(nullable = false, length = 255)
private String password; private String password;
/** Cuenta activa (admin la activa; pago automático también puede activarla) */ /**
* Indica si la cuenta está activa.
* <p>Las cuentas nuevas arrancan con {@code false}; el administrador o un pago
* completado la activan automáticamente.</p>
*/
@Column(nullable = false) @Column(nullable = false)
private boolean habilitado = false; private boolean habilitado = false;
/** /**
* Roles posibles: * Conjunto de roles asignados al usuario.
* ROLE_USER registrado (sin acceso al curso) * Los valores posibles son {@code ROLE_USER}, {@code ROLE_PAGADO} y {@code ROLE_ADMIN}.
* ROLE_PAGADO ha pagado (acceso al curso)
* ROLE_ADMIN administrador (acceso total)
*/ */
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "usuario_roles", joinColumns = @JoinColumn(name = "usuario_id")) @CollectionTable(name = "usuario_roles", joinColumns = @JoinColumn(name = "usuario_id"))
@Column(name = "rol") @Column(name = "rol")
private Set<String> roles = new HashSet<>(); private Set<String> roles = new HashSet<>();
/** Constructor sin argumentos requerido por JPA. */
public Usuario() {} public Usuario() {}
// Getters / Setters // Getters / Setters
/**
* Devuelve el identificador único del usuario.
*
* @return id generado por la base de datos
*/
public Long getId() { return id; } public Long getId() { return id; }
/**
* Devuelve el email del usuario.
*
* @return dirección de correo tal como fue registrada
*/
public String getEmail() { return email; } public String getEmail() { return email; }
/**
* Establece el email del usuario.
*
* @param email dirección de correo electrónico
*/
public void setEmail(String email) { this.email = email; } public void setEmail(String email) { this.email = email; }
/**
* Devuelve la contraseña cifrada.
*
* @return hash BCrypt de la contraseña
*/
public String getPassword() { return password; } public String getPassword() { return password; }
/**
* Establece la contraseña (debe ser el hash BCrypt, nunca texto plano).
*
* @param password hash BCrypt
*/
public void setPassword(String password) { this.password = password; } public void setPassword(String password) { this.password = password; }
/**
* Indica si la cuenta está habilitada para iniciar sesión.
*
* @return {@code true} si la cuenta está activa
*/
public boolean isHabilitado() { return habilitado; } public boolean isHabilitado() { return habilitado; }
/**
* Activa o desactiva la cuenta.
*
* @param habilitado {@code true} para habilitar la cuenta
*/
public void setHabilitado(boolean habilitado) { this.habilitado = habilitado; } public void setHabilitado(boolean habilitado) { this.habilitado = habilitado; }
/**
* Devuelve el conjunto de roles asignados al usuario.
*
* @return conjunto mutable de roles
*/
public Set<String> getRoles() { return roles; } public Set<String> getRoles() { return roles; }
/**
* Reemplaza el conjunto de roles del usuario.
*
* @param roles nuevo conjunto de roles
*/
public void setRoles(Set<String> roles) { this.roles = roles; } public void setRoles(Set<String> roles) { this.roles = roles; }
// Helpers para la vista // Helpers para la vista
/**
* Indica si el usuario tiene acceso al curso (rol {@code ROLE_PAGADO}).
*
* @return {@code true} si el rol está presente
*/
public boolean isPagado() { return roles.contains("ROLE_PAGADO"); } public boolean isPagado() { return roles.contains("ROLE_PAGADO"); }
/**
* Indica si el usuario es administrador (rol {@code ROLE_ADMIN}).
*
* @return {@code true} si el rol está presente
*/
public boolean isAdmin() { return roles.contains("ROLE_ADMIN"); } public boolean isAdmin() { return roles.contains("ROLE_ADMIN"); }
} }

View File

@ -5,9 +5,27 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
/**
* Repositorio JPA para la entidad {@link Usuario}.
*
* <p>Extiende {@link JpaRepository} con métodos de búsqueda adicionales
* necesarios para la autenticación y el registro de usuarios.</p>
*/
public interface UsuarioRepository extends JpaRepository<Usuario, Long> { public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
/**
* Busca un usuario por su dirección de correo electrónico.
*
* @param email email del usuario a buscar
* @return {@link Optional} con el usuario si existe, vacío en caso contrario
*/
Optional<Usuario> findByEmail(String email); Optional<Usuario> findByEmail(String email);
/**
* Comprueba si ya existe un usuario registrado con el email dado.
*
* @param email email a verificar
* @return {@code true} si el email ya está en uso
*/
boolean existsByEmail(String email); boolean existsByEmail(String email);
} }

View File

@ -15,19 +15,39 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* Servicio de negocio para la gestión de usuarios.
*
* <p>Implementa {@link UserDetailsService} para la integración con Spring Security
* y centraliza todas las operaciones sobre la entidad {@link Usuario}: registro,
* activación, asignación de roles y eliminación.</p>
*/
@Service @Service
public class UsuarioService implements UserDetailsService { public class UsuarioService implements UserDetailsService {
private final UsuarioRepository repo; private final UsuarioRepository repo;
private final PasswordEncoder encoder; private final PasswordEncoder encoder;
/**
* Constructor con inyección de dependencias.
*
* @param repo repositorio de usuarios
* @param encoder encoder de contraseñas BCrypt
*/
public UsuarioService(UsuarioRepository repo, PasswordEncoder encoder) { public UsuarioService(UsuarioRepository repo, PasswordEncoder encoder) {
this.repo = repo; this.repo = repo;
this.encoder = encoder; this.encoder = encoder;
} }
// Spring Security // Spring Security
/**
* Carga los detalles de un usuario por su email para Spring Security.
*
* @param email email del usuario (campo {@code username} del formulario de login)
* @return detalles del usuario listos para la autenticación
* @throws UsernameNotFoundException si no existe ningún usuario con ese email
*/
@Override @Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Usuario u = repo.findByEmail(email) Usuario u = repo.findByEmail(email)
@ -48,8 +68,13 @@ public class UsuarioService implements UserDetailsService {
} }
// Auto-registro (pendiente de activación) // Auto-registro (pendiente de activación)
/**
@Transactional * Registra un nuevo usuario con la cuenta deshabilitada hasta que el admin la active.
*
* @param email email del nuevo usuario
* @param passwordPlana contraseña en texto plano (mínimo 8 caracteres)
* @throws IllegalArgumentException si el email ya está registrado o la contraseña es demasiado corta
*/ @Transactional
public void registrar(String email, String passwordPlana) { public void registrar(String email, String passwordPlana) {
if (repo.existsByEmail(email)) { if (repo.existsByEmail(email)) {
throw new IllegalArgumentException("El email ya está registrado"); throw new IllegalArgumentException("El email ya está registrado");
@ -66,19 +91,32 @@ public class UsuarioService implements UserDetailsService {
} }
// Consultas // Consultas
/**
public List<Usuario> listarTodos() { * Devuelve todos los usuarios registrados.
*
* @return lista de usuarios ordenada por ID
*/ public List<Usuario> listarTodos() {
return repo.findAll(); return repo.findAll();
} }
// Acciones de admin // Acciones de admin
/**
@Transactional * Invierte el estado de habilitación de la cuenta de un usuario.
*
* @param id identificador del usuario
* @throws java.util.NoSuchElementException si no existe un usuario con ese id
*/ @Transactional
public void toggleHabilitado(Long id) { public void toggleHabilitado(Long id) {
Usuario u = repo.findById(id).orElseThrow(); Usuario u = repo.findById(id).orElseThrow();
u.setHabilitado(!u.isHabilitado()); u.setHabilitado(!u.isHabilitado());
} }
/**
* Concede o revoca el rol {@code ROLE_PAGADO} de un usuario.
*
* @param id identificador del usuario
* @throws java.util.NoSuchElementException si no existe un usuario con ese id
*/
@Transactional @Transactional
public void toggleRolPagado(Long id) { public void toggleRolPagado(Long id) {
Usuario u = repo.findById(id).orElseThrow(); Usuario u = repo.findById(id).orElseThrow();
@ -89,7 +127,15 @@ public class UsuarioService implements UserDetailsService {
} }
} }
/** Concede acceso al curso tras un pago confirmado por Stripe (nunca quita el rol) */ /**
* Concede acceso al curso tras un pago confirmado por Stripe.
*
* <p>Añade {@code ROLE_PAGADO} y activa la cuenta si estuviera deshabilitada.
* Este método <em>nunca</em> quita el rol una vez concedido.</p>
*
* @param id identificador del usuario
* @throws java.util.NoSuchElementException si no existe un usuario con ese id
*/
@Transactional @Transactional
public void darAccesoPagado(Long id) { public void darAccesoPagado(Long id) {
Usuario u = repo.findById(id).orElseThrow(); Usuario u = repo.findById(id).orElseThrow();
@ -97,7 +143,14 @@ public class UsuarioService implements UserDetailsService {
u.setHabilitado(true); u.setHabilitado(true);
} }
/** El admin crea directamente un usuario ya activo */ /**
* Crea un usuario directamente activo desde el panel de administración.
*
* @param email email del nuevo usuario
* @param passwordPlana contraseña en texto plano
* @param pagado si {@code true}, se añade {@code ROLE_PAGADO} además de {@code ROLE_USER}
* @throws IllegalArgumentException si el email ya está registrado
*/
@Transactional @Transactional
public void crearPorAdmin(String email, String passwordPlana, boolean pagado) { public void crearPorAdmin(String email, String passwordPlana, boolean pagado) {
if (repo.existsByEmail(email)) { if (repo.existsByEmail(email)) {
@ -114,6 +167,11 @@ public class UsuarioService implements UserDetailsService {
repo.save(u); repo.save(u);
} }
/**
* Elimina un usuario de la base de datos.
*
* @param id identificador del usuario a eliminar
*/
@Transactional @Transactional
public void eliminar(Long id) { public void eliminar(Long id) {
repo.deleteById(id); repo.deleteById(id);