La lección anterior dejó a CicloUrbana con un agujero que arrastramos desde el principio del módulo. Marta tiene un token válido con el rol ROLE_CIUDADANO, así que su petición POST /api/v1/alquileres/9/finalizar atraviesa la cadena de filtros sin un solo problema: la ruta está permitida a usuarios autenticados y el rol es correcto. Pero el alquiler número 9 es de otro ciudadano de Ribalta. Marta acaba de cerrar el alquiler de un desconocido, y el importe se le ha cobrado a él.
Ninguna regla de authorizeHttpRequests puede impedirlo, porque la respuesta no depende de la ruta ni del rol: depende del dato. Esta lección baja la seguridad hasta la capa donde vive la lógica de negocio, con @EnableMethodSecurity, @PreAuthorize y expresiones SpEL, y después cierra el módulo endureciendo la API completa: CORS por entorno, limitación de tasa, HTTPS obligatorio, ocultación de la documentación en producción, auditoría de eventos, revisión de dependencias vulnerables y una lista de comprobación antes de exponer la red de Ribalta a Internet.
Advertencia que se repetirá al final, porque es la más importante del módulo. Todo lo construido en estas cinco lecciones es un punto de partida didáctico. Antes de exponer un servicio real a Internet, la configuración de seguridad debe ser revisada por un profesional de seguridad y sometida a una auditoría independiente. Los secretos jamás se versionan, y ninguna lista de comprobación sustituye a una prueba de penetración hecha por alguien que no ha escrito el código.
Contenido
- Por qué las reglas por URL no bastan
@EnableMethodSecurityy sus anotaciones- SpEL en las expresiones de seguridad
- Un evaluador de permisos propio:
SeguridadAlquileres @PostAuthorizey@PostFilter: el coste de filtrar tarde- Dónde poner las anotaciones: proxies y autoinvocación
- Endurecimiento de la API
- Los riesgos OWASP y su mitigación en CicloUrbana
- Auditoría de eventos de seguridad
- Dependencias vulnerables
- Lista de comprobación antes de producción
- Errores Comunes y Consejos
- Ejercicios
- Por qué las reglas por URL no bastan
Las reglas de 05-02 son necesarias y son la primera línea de defensa, pero tienen tres límites estructurales:
No pueden expresar reglas que dependen del dato. «Solo tus propios alquileres» no es una propiedad de la ruta /api/v1/alquileres/9/finalizar: es una relación entre el usuario autenticado y la fila 9 de la tabla. La URL es idéntica sea el alquiler propio o ajeno.
La misma operación se alcanza desde varios sitios. AlquilerService.finalizar lo llama hoy AlquilerController; mañana lo llamará una tarea programada (07-03), un consumidor de mensajes o un endpoint nuevo, y cada punto de entrada es una oportunidad de olvidar la comprobación. Poner la regla en el servicio la aplica una sola vez y para todos.
La protección está lejos de la regla. Quien lee AlquilerService no ve ninguna comprobación y tiene que buscarla en otro fichero, en otro paquete, escrita como un patrón de ruta. La seguridad declarada junto al método que protege es la que se mantiene actualizada.
| Seguridad por URL (05-02) | Seguridad de método (esta lección) | |
|---|---|---|
| Dónde se declara | SecurityFilterChain |
Anotación sobre el método |
| Cuándo se evalúa | Antes del DispatcherServlet |
Al invocar el método, vía proxy |
| Qué puede consultar | Ruta, verbo, roles | Los argumentos y el resultado |
| Reglas por dato | No | Sí |
| Coste | Muy bajo | Bajo, pero real |
| Papel | Barrera perimetral | Regla fina |
No son alternativas, son capas. La regla por URL descarta pronto lo que no debe ni llegar; la de método aplica lo que solo se puede decidir con el dato en la mano:
flowchart LR
C["POST /alquileres/9/finalizar<br/>Bearer de Marta"] --> F["AuthorizationFilter<br/>¿autenticado?"]
F -- "No" --> E1["401"]
F -- "Sí, y la ruta<br/>lo permite" --> P["Proxy de AlquilerService<br/>@PreAuthorize"]
P -- "El alquiler 9<br/>no es suyo" --> E2["403"]
P -- "Es suyo, u<br/>OPERARIO/ADMIN" --> M["finalizar(): lógica de negocio"]
@EnableMethodSecurity y sus anotaciones
@EnableMethodSecurity y sus anotacionesSe activa con una anotación en una clase de configuración:
@Configuration
@EnableMethodSecurity // prePostEnabled = true por defecto
public class ConfiguracionSeguridadMetodo { }En Spring Security 6, @EnableMethodSecurity sustituye al antiguo @EnableGlobalMethodSecurity y activa por defecto @PreAuthorize y @PostAuthorize. Sus atributos habilitan los demás: @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) añade @Secured y @RolesAllowed.
| Anotación | Cuándo se evalúa | Puede usar SpEL | Uso recomendado |
|---|---|---|---|
@PreAuthorize |
Antes de invocar | Sí | La opción por defecto en CicloUrbana |
@PostAuthorize |
Después, sobre el resultado | Sí, con returnObject |
Solo si el permiso depende del resultado |
@PreFilter / @PostFilter |
Filtran una colección, antes o después | Sí, con filterObject |
@PostFilter: evitar, filtra en memoria |
@Secured |
Antes | No: solo lista de roles | Código heredado |
@RolesAllowed |
Antes | No | Igual, pero estándar de Jakarta |
El criterio: usa @PreAuthorize salvo que tengas una razón concreta. Es la única que combina evaluación temprana con expresiones completas; @Secured y @RolesAllowed solo admiten una lista de roles, lo que ya cubren las reglas por URL; y todo lo que se evalúa después implica que el método ya se ejecutó, con su coste y sus efectos secundarios.
- SpEL en las expresiones de seguridad
Las expresiones se escriben en SpEL (Spring Expression Language) y disponen de un vocabulario propio:
| Expresión | Qué evalúa |
|---|---|
hasRole('OPERARIO') |
Autoridad ROLE_OPERARIO, con la jerarquía de 05-03 aplicada |
hasAnyRole('OPERARIO','ADMIN') |
Cualquiera de ellos |
hasAuthority('estaciones:escribir') |
Autoridad exacta, sin prefijo |
authentication |
El objeto Authentication completo |
principal |
El principal: nuestro UsuarioAutenticado |
isAuthenticated(), isAnonymous() |
Estado de autenticación |
permitAll, denyAll |
Constantes |
#nombreParametro |
Un argumento del método por su nombre |
returnObject |
El valor devuelto (solo en @PostAuthorize) |
filterObject |
Cada elemento de una colección (en los *Filter) |
@beanDeSeguridad.metodo(...) |
Llama a un bean del contexto |
Ejemplos reales del proyecto:
@Service
public class BicicletaService {
/** Dar de baja una bicicleta es mantenimiento: la jerarquía incluye a ADMIN. */
@PreAuthorize("hasRole('OPERARIO')")
@Transactional
public void marcarAveriada(String matricula, String motivo) { ... }
/** Consultar el detalle: cualquier usuario identificado. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public BicicletaResponse obtenerPorMatricula(String matricula) { ... }
}
@Service
public class UsuarioService {
/** Cada uno ve su ficha; un administrador, la de cualquiera. */
@PreAuthorize("#idUsuario == principal.idUsuario or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UsuarioResponse obtener(Long idUsuario) { ... }
}Esa última expresión es el ejemplo canónico y merece desmenuzarse. #idUsuario se refiere al parámetro del método por su nombre; principal.idUsuario invoca getIdUsuario() sobre el UsuarioAutenticado de 05-03 —aquí se cobra por segunda vez la decisión de crear un UserDetails propio: con el User estándar no habría ningún id que comparar—; y or hasRole('ADMIN') añade la excepción administrativa.
Cuidado con los nombres de parámetro.
#idUsuariosolo funciona si el compilador conserva los nombres de los argumentos. Elspring-boot-maven-pluginactiva-parameterspor defecto, pero si el proyecto lo pierde, la expresión falla en tiempo de ejecución con un mensaje confuso. La alternativa robusta es@P("idUsuario")sobre el parámetro, o#p0por posición —menos legible—.
Y una advertencia de fondo: SpEL se evalúa en tiempo de ejecución y no lo comprueba el compilador. Un error tipográfico en hasRole('OPERRARIO') o en principal.idUsuarrio no impide compilar ni arrancar: falla en la primera llamada real, o peor, deniega siempre. Es la razón de que estas expresiones necesiten pruebas automáticas, que es exactamente lo que empieza en el módulo 6.
- Un evaluador de permisos propio:
SeguridadAlquileres
SeguridadAlquileresVolvamos al problema del principio. La regla es: un ciudadano solo puede finalizar sus propios alquileres; un operario o un administrador, cualquiera. Se podría intentar en la anotación:
// ❌ Ilegible, no comprobable y con una consulta escondida dentro de una cadena
@PreAuthorize("hasAnyRole('OPERARIO','ADMIN') or "
+ "@alquilerRepositorio.findById(#idAlquiler).orElse(null)?.usuario?.id "
+ "== principal.idUsuario")Esa expresión no se puede probar de forma aislada, no la entiende quien la lee, esconde una consulta a la base de datos dentro de una cadena de texto y no compila nada de lo que hay dentro. La solución idiomática es un bean de seguridad:
package com.ciclourbana.seguridad;
/** Reglas de propiedad sobre los alquileres. Consultable desde SpEL. */
@Component("seguridadAlquileres")
public class SeguridadAlquileres {
private final AlquilerRepositorio alquilerRepositorio; // constructor omitido
/** ¿El usuario autenticado es el titular del alquiler indicado? */
@Transactional(readOnly = true)
public boolean esPropietario(Long idAlquiler, UsuarioAutenticado usuario) {
if (idAlquiler == null || usuario == null) return false;
return alquilerRepositorio.existsByIdAndUsuarioId(idAlquiler, usuario.getIdUsuario());
}
}
@Service
public class AlquilerService {
@PreAuthorize("hasAnyRole('OPERARIO','ADMIN') or "
+ "@seguridadAlquileres.esPropietario(#idAlquiler, principal)")
@Transactional
public AlquilerResponse finalizar(Long idAlquiler, FinalizarAlquilerRequest peticion) {
// La lógica de negocio de 04-07, intacta: no hay ni un if de seguridad
}
}Cinco ventajas de esta forma, y son las que la convierten en la recomendada:
- Es legible. «Es operario o administrador, o es el propietario» se lee de corrido y se puede enseñar al ayuntamiento.
- Es comprobable.
SeguridadAlquilereses un bean normal: se prueba con JUnit y Mockito (06-02, 06-03) sin levantar el contexto de seguridad. - Es reutilizable. La misma expresión sirve en
obtenerPorId, encancelary en cualquier método futuro. - Es eficiente.
existsByIdAndUsuarioIdes una consulta derivada (04-06) que devuelve un booleano; no carga la entidad ni sus relaciones. - Concentra el cambio. Si mañana un ciudadano puede gestionar los alquileres de un familiar autorizado, se cambia un método Java, no siete anotaciones.
Nótese el orden de la expresión: hasAnyRole va primero a propósito. SpEL evalúa or en cortocircuito, así que para un operario la consulta a la base de datos ni siquiera se ejecuta.
Y con esto el agujero está cerrado. La petición de Marta sobre el alquiler ajeno ya no llega al cuerpo del método: AccessDeniedException sube por el proxy, ExceptionTranslationFilter la traduce y el AccessDeniedHandler de 05-03 devuelve un 403 con formato ProblemDetail.
Un matiz de diseño. Devolver
403confirma que el alquiler 9 existe. En un servicio donde la mera existencia sea información sensible, la respuesta correcta es404: «aquí no hay nada para ti». Para CicloUrbana el403es aceptable y más claro; en una aplicación con datos delicados, plantéate el404.
@PostAuthorize y @PostFilter: el coste de filtrar tarde
@PostAuthorize y @PostFilter: el coste de filtrar tarde@PostAuthorize evalúa la expresión después de ejecutar el método, con acceso a returnObject:
@PostAuthorize("returnObject.usuarioId == principal.idUsuario or hasRole('ADMIN')")
@Transactional(readOnly = true)
public AlquilerResponse obtenerPorId(Long idAlquiler) { ... }Es cómodo cuando el permiso depende de un dato que solo se conoce tras consultar, y tiene dos costes: el método ya se ejecutó, con sus consultas y su tiempo, y cualquier efecto secundario ya ocurrió. De ahí una regla dura: nunca uses @PostAuthorize en un método que escribe. Si realiza un cargo, envía un correo o cambia el estado de una bicicleta, denegar el acceso a posteriori no deshace nada; con @Transactional la excepción sí provoca rollback de la base de datos (04-07), pero no del correo enviado ni de la llamada al proveedor de pagos.
@PostFilter recorre la colección devuelta y elimina los elementos que no pasan la expresión:
// ❌ Funciona, y es una mala idea
@PostFilter("filterObject.usuarioId == principal.idUsuario")
public List<AlquilerResponse> listarTodos() { ... }El problema es de eficiencia y de escala. Si la red de Ribalta tiene 40 000 alquileres, este método los trae todos, construye 40 000 DTOs y descarta 39 987 en memoria. Y con la paginación de 04-05 el resultado es directamente incorrecto: se pide la página 0 con 20 elementos, el filtro descarta 18 y el ciudadano recibe una página de 2 con un total que miente. La solución correcta es filtrar en la consulta, enlazando con 04-06:
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PaginaResponse<AlquilerResponse> listarMios(UsuarioAutenticado usuario,
Pageable paginacion) {
return PaginaResponse.de(
alquilerRepositorio.findByUsuarioId(usuario.getIdUsuario(), paginacion)
.map(alquilerMapper::aRespuesta));
}@PostFilter |
Filtrar en la consulta | |
|---|---|---|
| Filas leídas | Todas | Solo las del usuario |
| Compatible con paginación | No | Sí |
| Coste con 40 000 alquileres | Inaceptable | Constante |
| Dónde se ve la regla | En la anotación | En el repositorio |
La regla práctica: @PostFilter solo para colecciones pequeñas y acotadas —los cuatro estados de una estación, la lista de tarifas— y nunca sobre resultados paginados. Para todo lo demás, la seguridad forma parte de la consulta.
- Dónde poner las anotaciones: proxies y autoinvocación
Las anotaciones van en el servicio, no en el controlador, por tres razones: el servicio es el punto por el que pasan todos los caminos, incluidos los futuros; el controlador se ocupa del transporte HTTP y no de las reglas del negocio; y una regla en el controlador se duplica en cuanto aparece un segundo punto de entrada. Y funcionan exactamente igual que @Transactional (04-07): mediante un proxy que evalúa la expresión antes de delegar en el objeto real. De ahí, tres consecuencias que ya conocemos:
@Service
public class AlquilerService {
@Transactional
public void finalizarLote(List<Long> ids) {
for (Long id : ids) {
finalizar(id, peticion); // ❌ llamada interna: NO pasa por el proxy
} // ¡la comprobación de @PreAuthorize se salta!
}
@PreAuthorize("@seguridadAlquileres.esPropietario(#idAlquiler, principal)")
public AlquilerResponse finalizar(Long idAlquiler, FinalizarAlquilerRequest p) { ... }
}La autoinvocación esquiva la seguridad, igual que esquiva la transacción. Es la misma trampa de 04-07 y aquí es peor, porque el síntoma no es una transacción que no se abre: es una comprobación de permisos que no se ejecuta. Las salidas son las mismas: extraer el método a otro bean —la opción limpia—, inyectar el proxy de sí mismo con @Lazy, o replantear el diseño.
Las otras dos consecuencias del modelo de proxies: los métodos private, static y final no pueden interceptarse —una anotación sobre un método privado se ignora en silencio, sin ningún aviso—, y el bean debe ser gestionado por Spring; un objeto creado con new no tiene proxy ni seguridad.
- Endurecimiento de la API
La seguridad de la aplicación no acaba en la autenticación y la autorización. Estos son los puntos que faltan, cada uno con su configuración concreta.
7.1. CORS restrictivo y por entorno
ConfiguracionCors (03-02) permite http://localhost:5173 para que el frontend del equipo funcione en desarrollo. Ese origen no debe existir en producción.
@Bean
@Profile("prod")
CorsConfigurationSource corsProduccion() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://panel.ribalta.es")); // sin localhost
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(false); // usamos Bearer, no cookies
config.setMaxAge(3600L);
var fuente = new UrlBasedCorsConfigurationSource();
fuente.registerCorsConfiguration("/api/**", config);
return fuente;
}Dos recordatorios: allowedOrigins("*") junto con allowCredentials(true) está prohibido por la especificación y Spring lo rechaza al arrancar; y CORS no es un mecanismo de seguridad del servidor, sino una política que aplica el navegador —curl la ignora—. Restringir CORS protege a tus usuarios de páginas maliciosas; no protege tu API.
7.2. Limitación de tasa
Sin límite de peticiones, un solo cliente puede saturar la API o probar contraseñas sin descanso. Las opciones, de fuera hacia dentro:
| Dónde | Herramienta | Ventaja | Inconveniente |
|---|---|---|---|
| Pasarela o CDN | API Gateway, Cloudflare, Nginx | No consume recursos de la aplicación | Menos contexto de negocio |
| Aplicación | Bucket4j, Resilience4j | Conoce al usuario y el endpoint | Consume CPU y memoria |
| Base de datos | Contadores propios | Control total | Lento y complejo |
La recomendación es limitar en la pasarela y dejar en la aplicación solo las reglas que necesitan contexto, como «cinco intentos de login por minuto y correo». Con Bucket4j:
@Component
public class FiltroLimiteTasa extends OncePerRequestFilter {
private final Map<String, Bucket> cubos = new ConcurrentHashMap<>();
private Bucket nuevoCubo() { // 100 peticiones por minuto, reposición continua
return Bucket.builder().addLimit(
l -> l.capacity(100).refillGreedy(100, Duration.ofMinutes(1))).build();
}
@Override
protected void doFilterInternal(HttpServletRequest peticion, HttpServletResponse respuesta,
FilterChain cadena) throws ServletException, IOException {
Bucket cubo = cubos.computeIfAbsent(claveDe(peticion), k -> nuevoCubo());
if (cubo.tryConsume(1)) {
cadena.doFilter(peticion, respuesta);
} else {
respuesta.setStatus(429); // Too Many Requests
respuesta.setHeader("Retry-After", "60");
}
}
}Tres advertencias. El ConcurrentHashMap no sirve con varias instancias —cada réplica tendría su contador— ni evita crecer sin límite: en producción, Redis (09-02). La clave debe elegirse con cuidado: por IP castiga a todos los usuarios tras una misma NAT; por usuario autenticado es más justo, pero no protege el login, donde aún no hay usuario. Y el estado correcto es 429 con Retry-After, para que el cliente sepa cuándo reintentar.
7.3. Tamaño máximo de petición
Un cuerpo enorme o una cabecera monstruosa son una forma barata de denegación de servicio.
server:
max-http-request-header-size: 16KB # por defecto 8KB; los JWT ocupan
tomcat:
max-swallow-size: 2MB
connection-timeout: 5s
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MBmax-http-request-header-size merece una nota: el valor por defecto de 8 KB basta de sobra para un JWT de CicloUrbana, pero con tokens grandes de un proveedor externo —con muchos claims— puede quedarse corto y producir un 431 Request Header Fields Too Large difícil de diagnosticar. Súbelo con criterio, no «por si acaso».
7.4. HTTPS obligatorio y HSTS
Sin TLS, todo el módulo es papel mojado: el token viaja legible por la red y cualquiera en el mismo wifi lo captura. Lo habitual es terminar TLS en el balanceador o el ingress, pero si la aplicación lo hace directamente:
server:
ssl:
enabled: true
key-store: ${RUTA_ALMACEN_CLAVES} # nunca dentro del repositorio
key-store-password: ${CLAVE_ALMACEN}
key-store-type: PKCS12Y para rechazar cualquier petición que llegue sin cifrar, más HSTS (05-02):
.requiresChannel(canal -> canal.anyRequest().requiresSecure())
.headers(h -> h.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true).maxAgeInSeconds(31_536_000)))Cuando TLS termina en un proxy, la aplicación ve peticiones HTTP y requiresSecure() produciría un bucle de redirecciones. La solución es que el proxy envíe las cabeceras X-Forwarded-* y activar server.forward-headers-strategy: framework.
7.5. Ocultar la versión del servidor y lo que no debe estar en producción
Cada dato que la API revela sobre sí misma facilita buscar un exploit conocido.
server:
error:
include-stacktrace: never # ya desde 03-06
include-message: never
whitelabel: { enabled: false }
tomcat:
remoteip: { protocol-header: x-forwarded-proto }Y la lista de lo que no debe estar accesible en producción:
| Elemento | Cómo se cierra |
|---|---|
Swagger UI y /v3/api-docs |
springdoc.api-docs.enabled: false en application-prod.yml |
| Consola H2 | No existe: PostgreSQL desde 04-02. Verificar que la dependencia sea test |
| Endpoints de Actuator | Exponer solo health e info; el resto, con ADMIN (07-01) |
Log de seguridad en DEBUG |
Nunca fuera de desarrollo (05-02) |
| Datos de prueba de Flyway | locations separado por entorno (04-08) |
Todo esto se gobierna con perfiles, el mecanismo que se estudia en 07-02. La regla es que el perfil prod no herede nada peligroso del de desarrollo, y la comprobación práctica es lanzar la aplicación con --spring.profiles.active=prod y verificar que /swagger-ui.html responde 404.
7.6. No filtrar detalles en los errores
Revisando 03-06 con ojos de seguridad, un error puede regalar información valiosísima: nombres de tablas, rutas del sistema de ficheros, versiones de librerías, la estructura interna del código.
| Fuga | Ejemplo | Solución |
|---|---|---|
| Traza de pila en la respuesta | at org.hibernate... |
include-stacktrace: never |
| Mensaje de excepción ajena | ERROR: relation "usuarios"... |
Nunca devolver e.getMessage() de origen desconocido |
| Distinguir «no existe» de «sin permiso» | 404 frente a 403 |
Valorar el 404 uniforme |
| Mensajes de login distintos | «correo no registrado» | Mensaje único (05-03) |
| Tiempos de respuesta distintos | Login rápido si no existe | Lo resuelve DaoAuthenticationProvider (05-03) |
El ManejadorGlobalExcepciones de 03-06 ya lo hace bien: mensajes controlados, código propio y un identificador de traza que permite al soporte encontrar el detalle en el log del servidor, no en la respuesta.
- Los riesgos OWASP y su mitigación en CicloUrbana
Cerramos el círculo abierto en 05-01, ahora con nombres concretos del proyecto:
| Riesgo | Qué sería en CicloUrbana | Mitigación aplicada |
|---|---|---|
| Inyección SQL | ... WHERE correo = ' + entrada + ' |
JPA y @Query parametrizan siempre (04-06): el valor viaja aparte de la sentencia y nunca se interpreta como SQL. El riesgo reaparece solo si alguien concatena en una consulta nativa |
| IDOR / BOLA | Marta finaliza el alquiler 9, que es de otro | @PreAuthorize con @seguridadAlquileres.esPropietario (apartado 4) |
| Exposición excesiva de datos | La respuesta incluye contrasenaHash o el DNI |
DTOs de respuesta como listas de inclusión (03-05) |
| Mass assignment | {"roles":["ADMIN"]} en el registro |
DTOs de petición: lo que no está en el DTO no llega (05-03) |
| Autenticación rota | Contraseñas débiles, sin límite de intentos | BCrypt, eventos de fallo, límite de tasa (05-02, 05-03) |
| Configuración incorrecta | Swagger abierto en producción | Perfiles (apartado 7.5, 07-02) |
| Registro insuficiente | Nadie ve 10 000 logins fallidos | Eventos de autenticación (apartado 9) |
| Componentes vulnerables | Una librería con un CVE | Dependency-Check (apartado 10) |
Dos observaciones que resumen el módulo. La primera: la mitad de estas mitigaciones no son de Spring Security. Los DTOs de 03-05, las consultas parametrizadas de 04-06 y las restricciones de 04-08 protegen tanto o más que la cadena de filtros. Buena arquitectura y seguridad son en gran medida lo mismo.
La segunda: la inyección SQL merece una nota concreta, porque es el riesgo cuya protección se pierde con más facilidad. Estos dos casos parecen equivalentes y no lo son:
// ✅ SEGURO: el parámetro viaja separado de la sentencia
@Query("SELECT a FROM Alquiler a WHERE a.usuario.correo = :correo")
List<Alquiler> buscarPorCorreo(@Param("correo") String correo);
// ❌ VULNERABLE: concatenación en una consulta nativa
@Query(value = "SELECT * FROM alquileres WHERE estado = '" + "..." + "'",
nativeQuery = true)Y hay un detalle que sorprende: el nombre de una columna en un ORDER BY dinámico no se puede parametrizar. Si alguien construye la ordenación concatenando un parámetro de la petición, ahí hay una inyección. La defensa es una lista blanca de columnas ordenables, no un intento de escapado.
- Auditoría de eventos de seguridad
Spring Security publica eventos de aplicación en cada intento de autenticación, y escucharlos cuesta muy poco:
@Component
public class AuditoriaSeguridad {
private static final Logger log = LoggerFactory.getLogger("AUDITORIA");
@EventListener
public void alEntrar(AuthenticationSuccessEvent e) {
log.info("LOGIN_OK usuario={} traza={}",
e.getAuthentication().getName(), MDC.get(FiltroTraza.CLAVE_MDC));
}
/** Clase padre: cubre credenciales malas, cuenta desactivada, bloqueada y caducada. */
@EventListener
public void alFallar(AbstractAuthenticationFailureEvent e) {
log.warn("LOGIN_FALLIDO usuario={} motivo={} traza={}", e.getAuthentication().getName(),
e.getException().getClass().getSimpleName(), MDC.get(FiltroTraza.CLAVE_MDC));
}
@EventListener
public void alDenegar(AuthorizationDeniedEvent<?> e) {
log.warn("ACCESO_DENEGADO usuario={} traza={}",
e.getAuthentication().get().getName(), MDC.get(FiltroTraza.CLAVE_MDC));
}
}| Qué registrar siempre | Qué nunca registrar |
|---|---|
| Inicios de sesión correctos y fallidos | Contraseñas, ni siquiera parciales |
Accesos denegados (403) |
Tokens JWT, ni siquiera truncados |
| Cambios de roles y permisos | Hashes de contraseña |
| Registro y baja de usuarios | Cabeceras Authorization completas |
| Cambios de contraseña | Datos personales innecesarios (RGPD) |
| El identificador de traza y la IP | Cookies de sesión |
Advertencia. Un token o una contraseña en un log es una credencial en un log, y los logs se copian a sistemas de agregación, se envían a terceros y se conservan años. Cuando alguien detecta la fuga, esa credencial lleva meses circulando. Revisa también las librerías: algunos clientes HTTP registran las cabeceras completas en
DEBUG.
El tratamiento de logs en profundidad —formato estructurado, agregación, retención— se ve en 09-05, y la alerta automática ante un pico de fallos, en 09-04.
- Dependencias vulnerables
Una aplicación arrastra decenas de dependencias transitivas, y una vulnerabilidad crítica en cualquiera de ellas es una vulnerabilidad de CicloUrbana. OWASP Dependency-Check compara el árbol de dependencias con la base pública de vulnerabilidades:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>10.0.4</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS> <!-- falla ante severidad alta -->
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
</configuration>
</plugin>mvn org.owasp:dependency-check-maven:check # informe en target/
mvn versions:display-dependency-updates # qué versiones nuevas hay
mvn dependency:tree # de dónde viene cada dependenciaCuatro consejos prácticos. Ejecútalo en la integración continua, no a mano, con failBuildOnCVSS para que una vulnerabilidad alta rompa la construcción (módulo 8). Habrá falsos positivos: gestiónalos con un fichero de supresiones documentado, uno por uno y con justificación, nunca bajando el umbral. Actualizar la versión de Spring Boot es la vía más eficaz, porque el starter-parent arrastra decenas de versiones coherentes de golpe, con atención especial a lo que no gestiona, como el JJWT de 05-04. Y suscríbete a los avisos de seguridad de Spring: una vulnerabilidad conocida se explota masivamente a las pocas horas de publicarse.
- Lista de comprobación antes de producción
| # | Comprobación | Lección |
|---|---|---|
| 1 | Ningún secreto en el repositorio; todos por variable de entorno o gestor de secretos | 02-04, 05-04 |
| 2 | HTTPS obligatorio, con HSTS y certificado válido | 7.4 |
| 3 | Contraseñas con BCrypt (o Argon2) y coste revisado | 05-02 |
| 4 | Secreto JWT de al menos 256 bits, generado aleatoriamente y rotable | 05-04 |
| 5 | Token de acceso corto (≤15 min) con refresco rotatorio y revocable | 05-04 |
| 6 | anyRequest().denyAll() al final de cada cadena |
05-02 |
| 7 | Reglas ordenadas de específica a general, revisadas una por una | 05-02 |
| 8 | Reglas por dato con @PreAuthorize en todos los recursos de un usuario |
Esta lección |
| 9 | Sesión STATELESS y CSRF coherente con dónde vive la credencial |
05-02 |
| 10 | CORS sin localhost ni * en producción |
7.1 |
| 11 | Limitación de tasa activa, al menos en login y registro | 7.2 |
| 12 | Tamaños máximos de petición y cabecera configurados | 7.3 |
| 13 | Swagger UI, consola H2 y Actuator cerrados o protegidos | 7.5 |
| 14 | Errores sin traza, sin mensajes internos y con identificador de traza | 03-06, 7.6 |
| 15 | DTOs de entrada y salida en todos los endpoints | 03-05 |
| 16 | Ninguna consulta construida por concatenación | 04-06 |
| 17 | Auditoría de eventos de seguridad activa y sin credenciales en los logs | Apartado 9 |
| 18 | Dependency-Check en la integración continua, sin vulnerabilidades altas | Apartado 10 |
| 19 | Pruebas automáticas de las reglas de seguridad | Módulo 6 |
| 20 | Revisión por un profesional de seguridad y auditoría externa | — |
Advertencia final, y la más importante del módulo. Esta lista es necesaria y no es suficiente. Todo lo construido en estas cinco lecciones es un punto de partida didáctico: cubre los errores más comunes, pero no sustituye a una revisión profesional. Antes de exponer un servicio real —y más si maneja datos personales de ciudadanos, como CicloUrbana— la configuración debe ser revisada por un especialista en seguridad y sometida a una prueba de penetración por alguien que no haya escrito el código. La seguridad no es un estado que se alcanza: es un proceso que se mantiene, con revisiones periódicas, actualizaciones y atención a los avisos.
Errores Comunes y Consejos
Poner @PreAuthorize en el controlador. Deja el servicio desprotegido ante cualquier otro punto de entrada: tareas programadas, consumidores de mensajes o un endpoint nuevo.
Anotar un método private o llamarlo desde la propia clase. El proxy no interviene y la comprobación no se ejecuta, sin ningún aviso. Es la trampa de 04-07 con consecuencias peores.
Usar @PostFilter sobre resultados paginados. Rompe la paginación y trae toda la tabla a memoria. Filtra en la consulta.
@PostAuthorize en un método que escribe. El efecto ya ocurrió; el rollback de la transacción no deshace un correo enviado ni un cargo hecho.
Escribir SpEL complejo dentro de la anotación. No se compila, no se prueba y nadie lo entiende: extrae un bean de seguridad. Y recuerda que SpEL no lo comprueba el compilador, así que hasRole('OPERRARIO') arranca sin protestar y deniega siempre; estas expresiones necesitan pruebas (módulo 6).
Confiar en CORS como mecanismo de seguridad. Lo aplica el navegador; curl lo ignora. Y nunca registres tokens ni contraseñas «solo en DEBUG»: los logs se copian y se conservan años.
Consejo: escribe la regla de seguridad y su prueba a la vez. Una regla sin prueba es una hipótesis, y el módulo 6 empieza precisamente por ahí.
Consejo: revisa la seguridad como revisas el código. Un cambio en ConfiguracionSeguridad, en un @PreAuthorize o en un DTO merece la misma atención que un cambio en la lógica de cobro.
Ejercicios
Ejercicio 1
IncidenciaService gestiona las averías de la flota de Ribalta. Aplica seguridad de método con estos requisitos, justificando cada anotación:
- Cualquier ciudadano autenticado puede crear una incidencia sobre una bicicleta.
- Solo un
OPERARIOpuede cerrar una incidencia. - Un ciudadano puede consultar las incidencias que él mismo creó; un operario, cualquiera.
- Solo un
ADMINpuede borrar una incidencia. - El listado paginado debe devolver a cada ciudadano solo las suyas, y a un operario todas.
Ejercicio 2
Esta clase tiene cinco problemas de seguridad. Encuéntralos, explica el impacto de cada uno y reescríbela.
@RestController
@RequestMapping("/api/v1/usuarios")
public class UsuarioController {
@GetMapping("/{id}")
public Usuario obtener(@PathVariable Long id) {
return usuarioRepositorio.findById(id).orElseThrow();
}
@GetMapping("/buscar")
public List<Usuario> buscar(@RequestParam String nombre) {
return entityManager.createNativeQuery(
"SELECT * FROM usuarios WHERE nombre LIKE '%" + nombre + "%'",
Usuario.class).getResultList();
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
private Usuario actualizar(@PathVariable Long id, @RequestBody Usuario usuario) {
return usuarioRepositorio.save(usuario);
}
}Ejercicio 3
El ayuntamiento va a desplegar CicloUrbana en producción la semana que viene. Redacta el informe de las diez comprobaciones que harías, ordenadas por criticidad, indicando para cada una cómo la verificarías de forma objetiva.
Soluciones
Solución 1
@Service
public class IncidenciaService {
/** 1. Cualquiera identificado reporta una avería. El autor NO viene del
* cliente: se toma del principal (regla de 05-03). */
@PreAuthorize("isAuthenticated()")
@Transactional
public IncidenciaResponse crear(CrearIncidenciaRequest peticion,
UsuarioAutenticado autor) { ... }
/** 2. Cerrar es mantenimiento. La jerarquía de 05-03 incluye a ADMIN. */
@PreAuthorize("hasRole('OPERARIO')")
@Transactional
public IncidenciaResponse cerrar(Long idIncidencia, String resolucion) { ... }
/** 3. Regla por dato: bean de seguridad, no SpEL complejo. */
@PreAuthorize("hasRole('OPERARIO') or "
+ "@seguridadIncidencias.esAutor(#idIncidencia, principal)")
@Transactional(readOnly = true)
public IncidenciaResponse obtener(Long idIncidencia) { ... }
/** 4. Borrar destruye histórico de mantenimiento: solo administración. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void borrar(Long idIncidencia) { ... }
/** 5. El filtrado va en la CONSULTA, nunca en @PostFilter. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PaginaResponse<IncidenciaResponse> listar(UsuarioAutenticado usuario, Pageable p) {
Page<Incidencia> pagina = usuario.esOperario()
? incidenciaRepositorio.findAll(p)
: incidenciaRepositorio.findByAutorId(usuario.getIdUsuario(), p);
return PaginaResponse.de(pagina.map(mapper::aRespuesta));
}
}Justificaciones. El punto 1 usa isAuthenticated() y no hasRole('CIUDADANO'), porque un operario también debe poder reportar; y el autor se toma del principal para que nadie pueda crear incidencias a nombre de otro. El punto 3 es la única regla que depende del dato y por eso necesita un bean, SeguridadIncidencias, con la misma forma que SeguridadAlquileres. El punto 5 es el más instructivo: la decisión de qué consulta ejecutar es una decisión de seguridad, y hacerla en la consulta —en lugar de con @PostFilter— es lo único compatible con paginación. El método esOperario() de UsuarioAutenticado encapsula la comprobación de la autoridad ROLE_OPERARIO, que de otro modo se repetiría por todo el proyecto.
Solución 2
Problema 1 — devuelve la entidad Usuario. La respuesta incluye contrasenaHash, los roles y cualquier campo que se añada en el futuro: es exposición excesiva de datos, el fallo que motivó los DTOs de 03-05.
Problema 2 — IDOR en obtener. No hay ninguna comprobación de permiso: cualquiera autenticado —o cualquiera, si la regla por URL fallara— lee la ficha de cualquier ciudadano cambiando el id. Es el riesgo A01/BOLA.
Problema 3 — inyección SQL en buscar. El parámetro nombre se concatena en una consulta nativa. Con nombre = ' OR '1'='1 se devuelven todos los usuarios; con una carga útil más elaborada, se pueden ejecutar otras sentencias.
Problema 4 — @PreAuthorize sobre un método private. El proxy no puede interceptarlo: la anotación se ignora en silencio y el método queda sin protección. Además, un método privado no puede ser un manejador de Spring MVC.
Problema 5 — mass assignment en actualizar. Recibe la entidad completa, así que la petición puede cambiar roles, contrasenaHash, activo o incluso el id. Un ADMIN comprometido —o un error— puede reescribir cualquier campo.
El controlador queda reducido a transporte: tres métodos public que reciben @PathVariable, @RequestParam validados y @Valid @RequestBody ActualizarUsuarioRequest, devuelven UsuarioResponse o PaginaResponse<UsuarioResponse> y delegan en el servicio, donde viven ahora todas las reglas:
@Service
public class UsuarioService {
@PreAuthorize("#id == principal.idUsuario or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UsuarioResponse obtener(Long id) { ... }
@PreAuthorize("hasRole('ADMIN')")
@Transactional(readOnly = true)
public PaginaResponse<UsuarioResponse> buscarPorNombre(String nombre, Pageable p) {
// Consulta derivada: parametrizada por Spring Data, inmune a inyección
return PaginaResponse.de(usuarioRepositorio
.findByNombreContainingIgnoreCase(nombre, p).map(mapper::aRespuesta));
}
/** public, no private: si no, el proxy no lo intercepta y la regla no se aplica. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public UsuarioResponse actualizar(Long id, ActualizarUsuarioRequest peticion) {
Usuario usuario = usuarioRepositorio.findById(id)
.orElseThrow(() -> new RecursoNoEncontradoException("Usuario", id));
usuario.setNombre(peticion.nombre()); // solo lo que el DTO permite
usuario.setTipoTarifa(peticion.tipoTarifa());
return mapper.aRespuesta(usuario);
}
}ActualizarUsuarioRequest contiene solo nombre y tipoTarifa: ni roles, ni contraseña, ni activo, ni id. Ese es el punto: lo que no está en el DTO no se puede modificar, y el cambio de roles vive en su propio endpoint con sus propias reglas (ejercicio 3 de 05-03).
Solución 3
| # | Comprobación | Verificación objetiva |
|---|---|---|
| 1 | Ningún secreto en el repositorio | git log -p filtrado con una herramienta de detección de secretos; revisar el historial completo, no solo la última versión |
| 2 | HTTPS obligatorio y certificado válido | curl -I http://api... debe redirigir o rechazar; comprobar la cadena y la caducidad del certificado |
| 3 | Denegación por defecto | Lanzar peticiones a rutas inexistentes y no clasificadas: todas deben dar 401 o 403, nunca 200 |
| 4 | Cada usuario solo ve lo suyo | Con dos ciudadanos reales, intentar cruzar identificadores en alquileres, incidencias y fichas: todo 403 |
| 5 | Documentación y consolas cerradas | /swagger-ui.html, /v3/api-docs, /h2-console y /actuator/env deben responder 404 o 401 |
| 6 | Errores sin información interna | Provocar un 500 y comprobar que la respuesta no contiene traza, SQL ni rutas, y que sí trae identificador de traza |
| 7 | Límite de tasa activo | Lanzar 200 peticiones seguidas a /api/v1/auth/login y comprobar que aparece el 429 |
| 8 | Sin vulnerabilidades altas | mvn dependency-check:check con failBuildOnCVSS=7 en verde, y el informe revisado |
| 9 | Auditoría sin credenciales | Buscar en los logs de una sesión completa cadenas como eyJ, Bearer o contrasena: cero resultados |
| 10 | Revisión profesional | Informe firmado de la revisión de seguridad y de la prueba de penetración |
Criterio de ordenación: primero lo que compromete todo el sistema de golpe (un secreto filtrado, tráfico sin cifrar), después lo que compromete datos de terceros (denegación por defecto, acceso cruzado), luego lo que facilita el ataque (documentación abierta, fugas en errores) y por último lo preventivo. Y una observación sobre la número 1 que se olvida siempre: un secreto que estuvo en un commit y luego se borró sigue estando en el historial de Git, así que borrarlo no basta: hay que rotarlo.
Conclusión
El módulo 5 se cierra con la red de Ribalta protegida de arriba abajo. Has entendido por qué las reglas por URL de 05-02, siendo necesarias, no bastan: no pueden expresar reglas que dependen del dato, no cubren los puntos de entrada que aún no existen y dejan la protección lejos del código que protegen. La seguridad de método es la capa que completa la perimetral, y ahora sabes elegir entre sus anotaciones con criterio: @PreAuthorize como opción por defecto, @PostAuthorize solo cuando el permiso depende del resultado y nunca sobre métodos que escriben, @PostFilter prácticamente nunca —porque trae toda la tabla a memoria y rompe la paginación de 04-05— y @Secured o @RolesAllowed únicamente en código heredado. Dominas el vocabulario SpEL —hasRole, principal, #parametro, @bean.metodo(...)— y su gran advertencia: no lo comprueba el compilador, así que una regla mal escrita arranca sin protestar y deniega siempre.
Has cerrado el agujero con el que empezaba este módulo mediante SeguridadAlquileres.esPropietario, un bean legible, comprobable, reutilizable y eficiente, y sabes que las anotaciones viven en el servicio y funcionan con proxies, con la misma trampa de autoinvocación de @Transactional y una consecuencia peor: la comprobación que no se ejecuta no avisa. Y has endurecido la API punto por punto: CORS sin localhost en producción y sin la ilusión de que CORS proteja el servidor, limitación de tasa con 429 y Retry-After, tamaños máximos de petición, HTTPS obligatorio con HSTS y X-Forwarded-*, Swagger y Actuator cerrados fuera de desarrollo, errores que no filtran nada, la tabla OWASP con la mitigación concreta de cada riesgo —incluida la nota sobre el ORDER BY dinámico que ningún parámetro puede proteger—, la auditoría de eventos con su lista de lo que jamás se registra, la revisión de dependencias con Dependency-Check y una lista de veinte comprobaciones cuyo último punto es el más importante: esto es un punto de partida didáctico y necesita la revisión de un profesional de seguridad y una auditoría independiente antes de exponerse a Internet.
CicloUrbana tiene por fin puertas, cerraduras y un registro de quién entra por cada una. Pero hay una pregunta incómoda que atraviesa todo lo construido y que hasta ahora hemos respondido con curl y buena voluntad: ¿cómo sabemos que funciona? Nadie ha comprobado automáticamente que un ciudadano no pueda finalizar el alquiler de otro, que anyRequest().denyAll() cierre de verdad lo que creemos, que el token caduque a los quince minutos o que la migración V4 deje el esquema como esperamos. Cada cambio futuro —un @PreAuthorize retocado, una regla reordenada, una dependencia actualizada— puede romper en silencio cualquiera de esas garantías, y lo descubriríamos en producción. El módulo 6, Pruebas en Spring Boot, resuelve eso: veremos la pirámide de pruebas y qué merece probarse, escribiremos pruebas unitarias con JUnit 5 y dobles con Mockito, pruebas de integración con @SpringBootTest, @WebMvcTest y @DataJpaTest —incluidas las de seguridad con @WithMockUser, que convertirán en asertos automáticos cada regla de este módulo— y levantaremos un PostgreSQL real y efímero con Testcontainers para comprobar que Flyway, las entidades y las consultas se comportan igual que en Ribalta. La red de Ribalta está protegida; ahora hay que demostrarlo.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
