CicloUrbana terminó la lección anterior con una política de accesos correcta y unas llaves ridículas: Marta, Luis y Ana viven en un InMemoryUserDetailsManager, sus contraseñas están escritas en el código fuente, desaparecen en cada reinicio y nadie puede darse de alta. Mientras tanto, en PostgreSQL hay una tabla usuarios con el correo, el nombre, el tipo de tarifa y la fecha de alta de los ciudadanos de Ribalta que no tiene ninguna relación con el usuario que se autentica. Son dos mundos separados, y esta lección los une.

Ampliaremos la entidad Usuario de 04-03 con credenciales y roles mediante la migración V4, escribiremos un UserDetailsService propio que cargue por correo desde UsuarioRepositorio, crearemos un UsuarioAutenticado que conserve el id —una decisión pequeña con enormes consecuencias en 05-05—, abriremos el endpoint de registro de ciudadanos, aclararemos de una vez la confusión entre roles y autoridades, y haremos que POST /api/v1/alquileres deje de fiarse del usuarioId que le envíe el cliente. Al terminar, la identidad de CicloUrbana será real y persistente.

Advertencia. Todos los datos personales y contraseñas de esta lección son ficticios. Una gestión de identidades real exige más de lo que cabe aquí: verificación del correo, política de contraseñas contrastada contra listas de contraseñas filtradas, recuperación segura, segundo factor y cumplimiento del RGPD. Nada de lo que construimos debe exponerse a Internet sin la revisión de un profesional de seguridad. Y los secretos nunca se versionan en el repositorio.

Contenido

  1. De usuarios en memoria a usuarios reales
  2. Ampliar la entidad Usuario con credenciales y roles
  3. La migración V4__anadir_credenciales_usuarios.sql
  4. UsuarioAutenticado: un UserDetails propio
  5. ServicioDetallesUsuario: el UserDetailsService de CicloUrbana
  6. DaoAuthenticationProvider: qué hace exactamente
  7. El flujo completo con AuthenticationManager
  8. Registro de ciudadanos
  9. Roles y autoridades: el prefijo ROLE_ y los permisos granulares
  10. Jerarquía de roles con RoleHierarchy
  11. Acceder al usuario autenticado desde el controlador
  12. Respuestas 401 y 403 con formato ProblemDetail
  13. Intentos fallidos y bloqueo de cuentas
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. De usuarios en memoria a usuarios reales

El cambio consiste en sustituir una sola pieza. Todo lo demás —la cadena de filtros, las reglas de authorizeHttpRequests, el PasswordEncoder— sigue igual:

Pieza En 05-02 A partir de ahora
UserDetailsService InMemoryUserDetailsManager ServicioDetallesUsuario contra UsuarioRepositorio
UserDetails User de Spring Security UsuarioAutenticado propio, con el id
Origen de los datos Un mapa en memoria La tabla usuarios de PostgreSQL
Alta de usuarios Recompilar y reiniciar POST /api/v1/auth/registro
Roles Fijos en el código Tabla usuario_roles

Que baste con cambiar una implementación es exactamente el objetivo del diseño de Spring Security que vimos en 05-01: UserDetailsService es una interfaz de un solo método, y todo lo que hay por encima ignora de dónde salen los datos.

  1. Ampliar la entidad Usuario con credenciales y roles

La entidad de 04-03 tiene identidad y datos personales, pero nada con lo que autenticar. Le faltan tres cosas: con qué demostrar quién es (el hash de la contraseña), qué puede hacer (los roles) y si sigue en activo —esto último ya lo teníamos—. El correo tampoco hay que añadirlo: la columna correo existe desde V1 y ya es UNIQUE, así que es el identificador de login natural.

package com.ciclourbana.usuarios;

public enum RolUsuario {
    CIUDADANO,   // alquila bicicletas
    OPERARIO,    // mantiene la flota
    ADMIN        // gestiona la red y los usuarios
}
@Entity
@Table(name = "usuarios")
public class Usuario extends EntidadAuditable {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "usuarios_seq")
    @SequenceGenerator(name = "usuarios_seq", sequenceName = "usuarios_id_seq",
                       allocationSize = 50)
    private Long id;

    @Column(nullable = false, unique = true, length = 120)
    private String correo;                  // identificador de login

    /** Hash BCrypt con prefijo, nunca la contraseña. Excluido de toString y equals. */
    @Column(name = "contrasena_hash", nullable = false, length = 100)
    private String contrasenaHash;

    @Column(nullable = false)
    private boolean activo = true;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "usuario_roles", joinColumns = @JoinColumn(name = "usuario_id"))
    @Column(name = "rol", nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private Set<RolUsuario> roles = new HashSet<>();

    // nombre, tipoTarifa, fechaAlta, version y auditoría siguen igual (04-03)
}

Cuatro decisiones que merecen justificación:

@ElementCollection y no @ManyToMany con una entidad Rol. Los roles de CicloUrbana son un conjunto fijo y cerrado de tres valores sin atributos propios ni ciclo de vida: no hay nada que administrar sobre «el rol OPERARIO». Si algún día fueran configurables desde un panel, con descripción y permisos asociados, entonces sí haría falta una entidad.

@Enumerated(EnumType.STRING), nunca ORDINAL. Es la regla de 04-03, y aquí es crítica: con ORDINAL se guardan 0, 1, 2, así que insertar un rol nuevo en medio del enumerado reasignaría en silencio los permisos de todos los usuarios.

fetch = EAGER, contra la regla general de 04-04. Es la excepción deliberada del proyecto: los roles se necesitan siempre e inmediatamente al autenticar, y con LAZY la carga ocurriría fuera de la transacción del UserDetailsService, provocando una LazyInitializationException —recuerda que open-in-view está en false desde 04-02—. Son como mucho tres valores por usuario. Si la colección creciera, la alternativa fina sería @EntityGraph.

contrasenaHash con length = 100. Un hash BCrypt ocupa 60 caracteres, y con el prefijo {bcrypt} son 68; los 100 dejan margen para migrar a {argon2}. El nombre del campo también importa: se llama contrasenaHash, no contrasena, para que nadie dude de qué contiene. Y debe quedar fuera de toString(), equals() y hashCode(), y jamás aparecer en un DTO de respuesta: los DTOs de 03-05 lo garantizan por construcción, porque son listas de inclusión.

  1. La migración V4__anadir_credenciales_usuarios.sql

Con ddl-auto: validate desde 04-08, el esquema solo cambia por migración. Como V1 ya creó usuarios con correo y activo, esta migración solo añade lo que falta.

-- V4__anadir_credenciales_usuarios.sql — credenciales y roles de la red de Ribalta.

-- 1) Hash de contraseña. Nullable de momento: el paso 3 la rellena y el 4 la fija.
ALTER TABLE usuarios ADD COLUMN contrasena_hash VARCHAR(100);

-- 2) Roles. Clave primaria compuesta: un usuario no puede tener dos veces el mismo rol.
CREATE TABLE usuario_roles (
    usuario_id BIGINT      NOT NULL,
    rol        VARCHAR(20) NOT NULL,

    CONSTRAINT pk_usuario_roles      PRIMARY KEY (usuario_id, rol),
    CONSTRAINT fk_usuario_roles_user FOREIGN KEY (usuario_id)
        REFERENCES usuarios (id) ON DELETE CASCADE,
    CONSTRAINT ck_usuario_roles_rol  CHECK (rol IN ('CIUDADANO', 'OPERARIO', 'ADMIN'))
);

CREATE INDEX ix_usuario_roles_usuario ON usuario_roles (usuario_id);

-- 3) Los usuarios preexistentes quedan sin credencial utilizable y desactivados.
UPDATE usuarios SET contrasena_hash = '{noop}SIN-CREDENCIAL', activo = FALSE
 WHERE contrasena_hash IS NULL;

INSERT INTO usuario_roles (usuario_id, rol)
SELECT id, 'CIUDADANO' FROM usuarios;

-- 4) Ahora sí, obligatoria.
ALTER TABLE usuarios ALTER COLUMN contrasena_hash SET NOT NULL;

Tres puntos que se aprenden con esta migración:

El patrón «nullable → rellenar → NOT NULL» en tres pasos es la única forma de añadir una columna obligatoria a una tabla con filas: añadirla directamente como NOT NULL falla, y ponerle un DEFAULT sería peor, porque dejaría a todos los usuarios con la misma contraseña.

ON DELETE CASCADE en la clave ajena de roles es una de las pocas cascadas correctas en base de datos: un rol no tiene sentido sin su usuario, el criterio de dominio de 04-04. Y la restricción CHECK replica el enumerado en el motor: si alguien inserta 'SUPERADMIN' con SQL directo, la base de datos lo rechaza en lugar de dejar que Hibernate reviente al leerlo.

Los usuarios preexistentes quedan desactivados a propósito. Es una decisión de seguridad, no un descuido: no existe ninguna contraseña que puedan haber elegido, así que dejarlos activos con un hash cualquiera sería peor. El literal {noop}SIN-CREDENCIAL no corresponde a ninguna contraseña real, pero además activo = FALSE hace que Spring Security los rechace antes incluso de comparar (apartado 6).

Marta, Luis y Ana necesitan además credenciales para poder seguir probando. Eso va en una migración aparte, V4.1__usuarios_demo_ribalta.sql, colocada en db/migration/dev con el locations por entorno de 04-08: un UPDATE que les pone un hash BCrypt ficticio y activo = TRUE, más dos INSERT en usuario_roles que dan OPERARIO a Luis y ADMIN a Ana. Nunca debe llegar a producción, y por eso vive fuera de db/migration.

  1. UsuarioAutenticado: un UserDetails propio

Lo más rápido sería que el UserDetailsService devolviera el User de Spring Security. Funciona, y es lo que hace la mitad de los tutoriales. CicloUrbana no lo hace, por un motivo muy concreto.

User guarda solo el nombre de usuario, el hash y las autoridades. Cuando después, en un servicio, necesitemos saber qué usuario de la base de datos está haciendo la petición para comprobar que un alquiler es suyo, solo tendremos el correo, y habrá que ir a buscar el id a la base de datos en cada petición. Con un UserDetails propio, el id viaja dentro del SecurityContext:

package com.ciclourbana.seguridad;

public class UsuarioAutenticado implements UserDetails {

    private final Long idUsuario;
    private final String correo;
    private final String contrasenaHash;
    private final boolean activo;
    private final Collection<? extends GrantedAuthority> autoridades;

    public UsuarioAutenticado(Usuario usuario) {   // copia inmutable de la entidad
        this.idUsuario = usuario.getId();
        this.correo = usuario.getCorreo();
        this.contrasenaHash = usuario.getContrasenaHash();
        this.activo = usuario.isActivo();
        this.autoridades = usuario.getRoles().stream()
                .map(rol -> new SimpleGrantedAuthority("ROLE_" + rol.name()))
                .toList();
    }

    /** La razón de ser de esta clase: el id sin consultar la base de datos. */
    public Long getIdUsuario() { return idUsuario; }

    @Override public String getUsername() { return correo; }
    @Override public String getPassword() { return contrasenaHash; }
    @Override public boolean isEnabled()  { return activo; }
    @Override public Collection<? extends GrantedAuthority> getAuthorities() { return autoridades; }

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }  // apartado 13
    @Override public boolean isCredentialsNonExpired() { return true; }

    @Override public String toString() {      // nunca el hash
        return "UsuarioAutenticado{id=%d, correo='%s'}".formatted(idUsuario, correo);
    }
}

Cuatro observaciones:

El prefijo ROLE_ se añade aquí, al convertir el enumerado en GrantedAuthority. En la base de datos se guarda ADMIN; en el contexto de seguridad vive ROLE_ADMIN. Concentrar esa conversión en un único punto evita que el prefijo aparezca a medias por el resto del código, que es la causa de la mitad de los 403 misteriosos.

Es una copia inmutable, no la entidad. Hacer que Usuario implementara UserDetails es un atajo tentador y mala idea: obligaría a la entidad JPA a arrastrar métodos de Spring Security, la acoplaría al framework y —lo importante— metería una entidad gestionada dentro del SecurityContext, con su EntityManager cerrado y sus colecciones perezosas listas para lanzar LazyInitializationException en cualquier punto. El dominio no debe saber que existe Spring Security.

toString() no expone el hash, porque un log.debug("usuario={}", usuario) inocente sería una fuga. Y isAccountNonLocked() devuelve true hoy, pero es el punto exacto por donde entrará el bloqueo por intentos fallidos del apartado 13.

  1. ServicioDetallesUsuario: el UserDetailsService de CicloUrbana

package com.ciclourbana.seguridad;

@Service
public class ServicioDetallesUsuario implements UserDetailsService {

    private static final Logger log = LoggerFactory.getLogger(ServicioDetallesUsuario.class);

    private final UsuarioRepositorio usuarioRepositorio;

    public ServicioDetallesUsuario(UsuarioRepositorio repo) { this.usuarioRepositorio = repo; }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String correo) throws UsernameNotFoundException {
        return usuarioRepositorio.findByCorreoIgnoreCase(correo)
                .map(UsuarioAutenticado::new)
                .orElseThrow(() -> {
                    // Nivel DEBUG: en INFO, un ataque de enumeración llenaría el log
                    log.debug("Intento de acceso con correo no registrado");
                    return new UsernameNotFoundException("Credenciales no válidas");
                });
    }
}

En UsuarioRepositorio (04-05) bastan dos consultas derivadas: Optional<Usuario> findByCorreoIgnoreCase(String correo) y boolean existsByCorreoIgnoreCase(String correo).

Cuatro detalles:

@Transactional(readOnly = true) es imprescindible: sin transacción activa, la colección roles no se podría cargar aunque fuera EAGER en algunos escenarios, y readOnly permite a Hibernate saltarse la comprobación de cambios (04-07).

IgnoreCase. Los correos no distinguen mayúsculas en la práctica. Sin esto, [email protected] sería un usuario distinto de [email protected] y el ciudadano no entendería por qué no puede entrar. Lo complementaremos normalizando a minúsculas al registrar.

El mensaje de la excepción no dice «el correo no existe». Es deliberado, y el apartado siguiente explica por qué.

No se comprueba aquí si el usuario está activo. Ese trabajo es del AuthenticationProvider, que sabe distinguir entre «no existe», «está desactivado» y «la contraseña no coincide». El UserDetailsService solo busca y devuelve.

  1. DaoAuthenticationProvider: qué hace exactamente

DaoAuthenticationProvider es el AuthenticationProvider que combina un UserDetailsService con un PasswordEncoder. Con Spring Boot se configura solo: basta con que existan ambos beans en el contexto. Declararlo explícitamente, sin embargo, hace visible lo que ocurre:

@Bean
AuthenticationProvider proveedorAutenticacion(ServicioDetallesUsuario detalles,
                                              PasswordEncoder codificador) {
    var proveedor = new DaoAuthenticationProvider();
    proveedor.setUserDetailsService(detalles);
    proveedor.setPasswordEncoder(codificador);
    proveedor.setHideUserNotFoundExceptions(true);   // oculta el motivo real: a propósito
    return proveedor;
}

/** Necesario en 05-04 para autenticar desde el endpoint de login. */
@Bean
AuthenticationManager gestorAutenticacion(AuthenticationConfiguration configuracion)
        throws Exception {
    return configuracion.getAuthenticationManager();
}

Su trabajo, paso a paso:

  1. Llama a loadUserByUsername con el nombre presentado.
  2. Si no lo encuentra, ejecuta igualmente una comparación de contraseña contra un hash ficticio. Esto no es un despiste: es una defensa contra ataques de temporización. Si al no encontrar el usuario respondiera de inmediato, el atacante mediría que la respuesta llega en 2 ms para correos inexistentes y en 90 ms para correos reales —los 90 ms del BCrypt— y podría enumerar todos los correos registrados de la red de Ribalta sin acertar ni una contraseña.
  3. Comprueba el estado de la cuenta con los cuatro booleanos de UserDetails, lanzando DisabledException, LockedException, AccountExpiredException o CredentialsExpiredException según corresponda.
  4. Compara con passwordEncoder.matches(presentada, almacenada), que aplica el algoritmo indicado por el prefijo {bcrypt} y compara en tiempo constante.
  5. Si el hash usa parámetros obsoletos y el codificador implementa upgradeEncoding, puede recodificar la contraseña al vuelo. Y por último devuelve un UsernamePasswordAuthenticationToken autenticado, con el UsuarioAutenticado como principal, las autoridades y las credenciales borradas.

Por qué se ocultan los motivos al cliente. Con hideUserNotFoundExceptions en true, tanto «este correo no existe» como «la contraseña no coincide» salen como una única BadCredentialsException, y la API responde siempre lo mismo: 401 con el texto «Credenciales no válidas». Distinguirlas sería un regalo para el atacante: le permitiría confirmar qué correos están dados de alta en el servicio municipal, lo cual además es un dato personal. Es incómodo para el usuario legítimo y es la práctica correcta.

El mismo criterio se aplica al registro: no debe responder «ese correo ya está registrado» de forma pública si eso permite descubrir quién usa el servicio. En CicloUrbana devolveremos 409 porque el registro es abierto y el usuario necesita saber que ya tiene cuenta, pero es una decisión que en un servicio sensible se tomaría al revés (respuesta genérica más aviso por correo).

  1. El flujo completo con AuthenticationManager

sequenceDiagram
    participant C as Cliente
    participant F as Filtro de autenticación
    participant PM as ProviderManager
    participant DAP as DaoAuthenticationProvider
    participant SDU as ServicioDetallesUsuario
    participant R as UsuarioRepositorio
    participant PE as PasswordEncoder

    C->>F: [email protected] + contraseña
    F->>PM: authenticate(token sin autenticar)
    PM->>DAP: supports(UsernamePasswordAuthenticationToken)? sí
    DAP->>SDU: loadUserByUsername("[email protected]")
    SDU->>R: findByCorreoIgnoreCase(...)
    R-->>SDU: Usuario + roles
    SDU-->>DAP: UsuarioAutenticado
    DAP->>DAP: ¿activo? ¿no bloqueado?
    DAP->>PE: matches(presentada, "{bcrypt}$2a$10$...")
    PE-->>DAP: true
    DAP-->>PM: Authentication autenticado
    PM-->>F: Authentication con UsuarioAutenticado
    F->>F: SecurityContextHolder.setContext(...)

Si algo falla —usuario inexistente, contraseña incorrecta, cuenta desactivada— el ProviderManager recoge la AuthenticationException, publica un evento de fallo (apartado 13) y la propaga. El ExceptionTranslationFilter de 05-01 la traduce en un 401 a través del AuthenticationEntryPoint.

  1. Registro de ciudadanos

Con la entidad y el servicio listos, ya se puede abrir el alta pública. Es el único endpoint de escritura sin autenticación de toda la API, así que merece cuidado.

package com.ciclourbana.seguridad.dto;

public record RegistroRequest(
        @NotBlank @Email @Size(max = 120) String correo,
        @NotBlank @Size(max = 120) String nombre,
        @NotBlank @Size(min = 12, max = 72) String contrasena) {}

public record UsuarioRegistradoResponse(Long id, String correo, String nombre) {}

El controlador es el habitual desde 03-03: un @RestController sobre /api/v1/auth con un @PostMapping("/registro") que recibe @Valid @RequestBody RegistroRequest, delega en el servicio y responde 201 Created con la cabecera Location. Toda la sustancia está en el servicio:

@Service
public class ServicioRegistro {

    private final UsuarioRepositorio usuarioRepositorio;
    private final PasswordEncoder codificador;
    private final Clock reloj;
    // constructor omitido

    @Transactional
    public UsuarioRegistradoResponse registrarCiudadano(RegistroRequest peticion) {
        String correo = peticion.correo().trim().toLowerCase(Locale.ROOT);

        if (usuarioRepositorio.existsByCorreoIgnoreCase(correo)) {
            throw new ConflictoRecursoException(
                    "Ya existe una cuenta con ese correo", "CORREO_YA_REGISTRADO");
        }

        Usuario usuario = new Usuario();
        usuario.setCorreo(correo);
        usuario.setNombre(peticion.nombre().trim());
        usuario.setContrasenaHash(codificador.encode(peticion.contrasena()));
        usuario.setActivo(true);
        usuario.setFechaAlta(LocalDate.now(reloj));
        usuario.setRoles(Set.of(RolUsuario.CIUDADANO));   // nunca desde la petición

        Usuario guardado = usuarioRepositorio.save(usuario);
        return new UsuarioRegistradoResponse(guardado.getId(), guardado.getCorreo(),
                                             guardado.getNombre());
    }
}

Cinco decisiones de seguridad en veinte líneas:

El rol no viene de la petición: se fija en el servidor. RegistroRequest no tiene campo roles. Si lo tuviera, cualquiera podría registrarse como ADMIN enviando {"roles":["ADMIN"]}. Es el ataque de mass assignment, y los DTOs de petición de 03-05 lo evitan por construcción: lo que no está en el DTO no puede llegar. Volveremos sobre ello en 05-05.

La respuesta no incluye el hash ni los roles. UsuarioRegistradoResponse es una lista de inclusión con tres campos.

El correo se normaliza a minúsculas antes de comprobar y de guardar, coherente con el IgnoreCase del repositorio. La comprobación de duplicado tiene una condición de carrera —dos registros simultáneos con el mismo correo pueden pasar los dos el existsBy—, y por eso la restricción UNIQUE de la base de datos es la garantía real. Si salta, Hibernate lanza DataIntegrityViolationException, que conviene traducir también a 409 en ManejadorGlobalExcepciones. Es el mismo razonamiento del índice único parcial de 04-08: la validación en Java es para dar un buen mensaje; el motor es quien garantiza.

El mínimo de 12 caracteres es un mínimo, no una política. Una política de contraseñas seria comprueba además la contraseña contra listas de contraseñas filtradas —el servicio Pwned Passwords permite hacerlo sin enviar la contraseña— y evita las reglas de composición obsoletas (obligar a un símbolo y un número produce Password1! una y otra vez). El máximo de 72 no es arbitrario: BCrypt trunca silenciosamente a 72 bytes, así que aceptar más daría una falsa sensación de seguridad.

Falta algo importante y hay que decirlo: este registro no verifica el correo. Cualquiera puede darse de alta con el correo de otra persona. Un servicio real envía un enlace de confirmación con un token de un solo uso y mantiene la cuenta inactiva hasta que se confirma. Queda fuera del alcance del curso, pero no del alcance de un sistema en producción.

  1. Roles y autoridades: el prefijo ROLE_ y los permisos granulares

Es la confusión más extendida de Spring Security, y se aclara con una sola idea: solo existen autoridades. Un rol es una autoridad cuyo nombre empieza por ROLE_.

Expresión Autoridad que busca Equivalente
hasRole("ADMIN") ROLE_ADMIN hasAuthority("ROLE_ADMIN")
hasAuthority("ADMIN") ADMIN No es lo mismo que lo anterior
hasRole("ROLE_ADMIN") ROLE_ROLE_ADMIN Error: lanza excepción al arrancar
hasAuthority("estaciones:escribir") estaciones:escribir Permiso granular

La regla mecánica: hasRole añade el prefijo, hasAuthority no lo añade. Lo mismo ocurre al crear usuarios (.roles(...) frente a .authorities(...)) y en el RoleHierarchy del apartado siguiente, donde sí se escribe el prefijo porque se trabaja con autoridades.

Roles frente a permisos. Los roles agrupan personas; los permisos describen acciones. Un sistema con solo roles acaba lleno de reglas como hasAnyRole("OPERARIO","ADMIN","SUPERVISOR","MANTENIMIENTO"), que hay que revisar entera cada vez que aparece un perfil nuevo:

Roles Permisos granulares
Ejemplo ROLE_OPERARIO bicicletas:escribir, estaciones:leer
Legibilidad de la regla Alta Media
Añadir un perfil nuevo Tocar todas las reglas Solo asignar permisos
Auditoría «¿quién puede X?» Difícil Directa
Complejidad inicial Baja Alta

CicloUrbana usa roles, y es la decisión correcta hoy: tres perfiles estables y bien delimitados no justifican la complejidad de un modelo de permisos. Pero conviene saber que el cambio no es traumático si UsuarioAutenticado es quien traduce el modelo de dominio a autoridades: bastaría con que cada RolUsuario aportase su conjunto de permisos y devolver ambos. La señal de que ha llegado el momento es concreta: cuando aparezca el cuarto rol, o cuando dos perfiles necesiten solapamientos parciales de permisos.

  1. Jerarquía de roles con RoleHierarchy

En 05-02 escribimos hasAnyRole("OPERARIO", "ADMIN") dos veces, y anotamos que era un síntoma. La causa es que un ADMIN de CicloUrbana debería poder hacer todo lo que hace un OPERARIO, y eso no está dicho en ninguna parte: hay que repetirlo regla a regla, y basta olvidarlo una vez para que un administrador se encuentre un 403 inexplicable.

@Bean
static RoleHierarchy jerarquiaRoles() {
    return RoleHierarchyImpl.withDefaultRolePrefix()
            .role("ADMIN").implies("OPERARIO")
            .role("OPERARIO").implies("CIUDADANO")
            .build();
}

/** Necesario para que la jerarquía se aplique también en la seguridad de método (05-05). */
@Bean
static MethodSecurityExpressionHandler manejadorExpresiones(RoleHierarchy jerarquia) {
    var manejador = new DefaultMethodSecurityExpressionHandler();
    manejador.setRoleHierarchy(jerarquia);
    return manejador;
}

ADMIN > OPERARIO > CIUDADANO. A partir de aquí, una regla hasRole("OPERARIO") la satisface también un ADMIN, y las reglas de 05-02 se simplifican:

.requestMatchers("/api/v1/incidencias/**").hasRole("OPERARIO")   // ADMIN incluido
.requestMatchers("/api/v1/bicicletas/**").hasRole("OPERARIO")    // ADMIN incluido

Dos advertencias. Los beans son static porque deben crearse muy pronto en el ciclo de vida, antes que la infraestructura de seguridad que los consume; si no, Spring avisa de una inicialización prematura de beans. Y la jerarquía se define con el prefijo por defecto, es decir, trabaja sobre ROLE_ADMIN y ROLE_OPERARIO: withDefaultRolePrefix() lo añade por ti.

Cuándo no usar jerarquía. Solo tiene sentido cuando los perfiles son realmente acumulativos. Si en el futuro CicloUrbana tuviera un rol AUDITOR que puede leerlo todo pero no escribir nada, no encajaría en la cadena: no es «más» ni «menos» que los otros, es distinto. Forzar una jerarquía sobre roles que no la tienen produce permisos concedidos por accidente, que es lo contrario de lo que buscamos.

  1. Acceder al usuario autenticado desde el controlador

Tres formas, y no son equivalentes:

Forma Cómo se obtiene Ventajas Inconvenientes
SecurityContextHolder Estático, desde cualquier punto Funciona en servicios y utilidades Acopla el código a Spring Security; difícil de probar; falla en hilos @Async (07-03)
Parámetro Authentication Spring lo inyecta en el método Explícito, fácil de simular en pruebas Hay que hacer cast del principal
@AuthenticationPrincipal Inyecta directamente el principal tipado Legible, tipado y sin cast Requiere un UserDetails propio para ser útil
// 1. Parámetro Authentication: correcto, pero obliga a un cast
public List<AlquilerResponse> mios(Authentication autenticacion) {
    var usuario = (UsuarioAutenticado) autenticacion.getPrincipal();
    return alquilerService.buscarPorUsuario(usuario.getIdUsuario());
}

// 2. @AuthenticationPrincipal: la forma preferida en CicloUrbana
@GetMapping("/mios")
public List<AlquilerResponse> mios(@AuthenticationPrincipal UsuarioAutenticado usuario) {
    return alquilerService.buscarPorUsuario(usuario.getIdUsuario());
}

Aquí se cobra la decisión del apartado 4. Con el User estándar, la tercera opción daría un objeto sin id y habría que consultar la base de datos por correo en cada petición. Con UsuarioAutenticado, el id está ahí.

El cambio importante: POST /api/v1/alquileres

Desde 03-03, iniciar un alquiler recibía el usuarioId en el cuerpo:

public record IniciarAlquilerRequest(Long usuarioId, Long bicicletaId) {}

Eso es ahora un agujero de seguridad de manual. Marta puede iniciar alquileres a nombre de cualquier ciudadano de Ribalta con solo cambiar un número: los cargos irían a la cuenta de otro. El servidor no tiene por qué preguntar quién es el cliente cuando ya lo sabe.

public record IniciarAlquilerRequest(@NotNull @Positive Long bicicletaId) {}  // sin usuarioId

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<AlquilerResponse> iniciar(
        @Valid @RequestBody IniciarAlquilerRequest peticion,
        @AuthenticationPrincipal UsuarioAutenticado usuario) {

    Alquiler alquiler = alquilerService.iniciar(usuario.getIdUsuario(), peticion.bicicletaId());
    URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(alquiler.getId()).toUri();
    return ResponseEntity.created(ubicacion).body(alquilerMapper.aRespuesta(alquiler));
}

La regla general, y es de las más valiosas del módulo: la identidad del usuario nunca se acepta del cliente. Se deduce de la credencial. Todo campo de una petición que diga «quién soy» es un vector de suplantación. Lo mismo vale para POST /api/v1/alquileres/{id}/finalizar, aunque ahí falta además comprobar que ese alquiler es de ese usuario: es autorización sobre el dato concreto, y ninguna regla por URL puede expresarlo. Es el tema de 05-05.

  1. Respuestas 401 y 403 con formato ProblemDetail

En 05-01 vimos el problema: los fallos de seguridad ocurren en los filtros, antes del DispatcherServlet, así que el @RestControllerAdvice de 03-06 no los ve y la API devuelve dos formatos de error distintos. Un cliente que sabe leer ProblemDetail se encuentra de pronto con otra cosa.

La solución son dos objetos que se conectan al ExceptionTranslationFilter:

package com.ciclourbana.seguridad;

@Component
public class PuntoEntradaNoAutenticado implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;   // constructor omitido

    @Override
    public void commence(HttpServletRequest peticion, HttpServletResponse respuesta,
                         AuthenticationException excepcion) throws IOException {
        ProblemDetail problema = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNAUTHORIZED, "Se requiere autenticación para esta operación");
        problema.setType(URI.create("https://api.ciclourbana.es/errores/no-autenticado"));
        problema.setTitle("No autenticado");
        problema.setProperty("codigo", "NO_AUTENTICADO");
        problema.setProperty("traza", MDC.get(FiltroTraza.CLAVE_MDC));   // traza de 03-06
        respuesta.setStatus(HttpStatus.UNAUTHORIZED.value());
        respuesta.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        objectMapper.writeValue(respuesta.getOutputStream(), problema);
    }
}

El AccessDeniedHandler es idéntico salvo el estado 403, el título «Acceso denegado» y el código SIN_PERMISO. Ambos se registran en la cadena de 05-02:

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(puntoEntradaNoAutenticado)
    .accessDeniedHandler(manejadorAccesoDenegado))

Tres cosas importantes. No se dice nunca por qué falló: ni «contraseña incorrecta», ni «usuario desactivado», ni «te falta el rol ADMIN». El mensaje genérico evita regalar información, y el motivo real va al log del servidor. Se reutiliza MDC.get(FiltroTraza.CLAVE_MDC) de 03-06, de modo que el ciudadano que llama al soporte con el identificador a3f5c9e1 permite localizar la petición exacta —siempre que FiltroTraza esté registrado antes que la seguridad en la cadena de filtros, lo que se consigue dándole un @Order bajo—. Y el tipo de contenido es application/problem+json, el mismo del RFC 7807, para que el cliente aplique el mismo tratamiento a todos los errores.

Resultado, ya coherente con el resto de la API:

{ "type": "https://api.ciclourbana.es/errores/no-autenticado",
  "title": "No autenticado", "status": 401,
  "detail": "Se requiere autenticación para esta operación",
  "codigo": "NO_AUTENTICADO", "traza": "a3f5c9e1" }

  1. Intentos fallidos y bloqueo de cuentas

Sin límite de intentos, un atacante puede probar contraseñas indefinidamente. BCrypt lo hace lento —unos 90 ms por intento con coste 10—, lo que ya descarta la fuerza bruta masiva, pero no impide probar las cien contraseñas más frecuentes contra miles de correos, que es como se comprometen la mayoría de las cuentas reales.

Spring Security publica eventos de autenticación que permiten reaccionar sin tocar el flujo:

@Component
public class EscuchaEventosAutenticacion {

    private static final Logger log =
            LoggerFactory.getLogger(EscuchaEventosAutenticacion.class);

    private final ServicioIntentosFallidos intentos;   // constructor omitido

    @EventListener
    public void alFallar(AuthenticationFailureBadCredentialsEvent evento) {
        String correo = evento.getAuthentication().getName();
        int fallos = intentos.registrarFallo(correo);
        log.warn("Autenticación fallida para '{}' (intento {})", correo, fallos);
        // NUNCA: log.warn("contraseña={}", evento.getAuthentication().getCredentials())
    }

    @EventListener
    public void alAcertar(AuthenticationSuccessEvent e) { intentos.limpiar(e.getAuthentication().getName()); }
}

AuthenticationFailureBadCredentialsEvent es una de las subclases de AbstractAuthenticationFailureEvent; hay otras para cuenta desactivada, bloqueada o credenciales caducadas, y se pueden escuchar todas a la vez usando la clase padre.

Con el contador disponible, isAccountNonLocked() de UsuarioAutenticado deja de devolver true fijo y consulta el estado. Cuatro decisiones de diseño:

  • Bloqueo temporal, no permanente. Bloquear para siempre convierte el ataque en una denegación de servicio: bastaría con fallar cinco veces contra el correo de un ciudadano para dejarlo fuera. Lo habitual son 5 a 15 minutos, o mejor un retraso progresivo —1 s tras el tercer fallo, 2 s tras el cuarto, 4 s tras el quinto—, que frena al atacante sin castigar a quien se equivoca al teclear.
  • Contar también por IP de origen, no solo por cuenta, para detectar el ataque distribuido contra muchos correos. Y el almacén debe ser compartido si hay varias instancias: un ConcurrentHashMap en memoria deja de servir en cuanto la aplicación escala (la caché distribuida se trata en 09-02).

En un sistema real esto se complementa con limitación de tasa en la pasarela (05-05) y con la alerta al equipo de seguridad ante picos de fallos (09-05).

Errores Comunes y Consejos

Hacer que Usuario implemente UserDetails. Acopla la entidad JPA al framework y mete un objeto gestionado, con colecciones perezosas, dentro del SecurityContext. Usa una copia inmutable.

Guardar los roles con @Enumerated(ORDINAL). Insertar un valor nuevo en medio del enumerado reasigna en silencio los permisos de todos los usuarios.

Guardar el rol con el prefijo ROLE_ en la base de datos. Duplica el prefijo al construir las autoridades. Guarda ADMIN y añade ROLE_ en un único punto.

Dejar roles como LAZY sin @EntityGraph. Con open-in-view: false (04-02), la carga fuera de la transacción del UserDetailsService produce LazyInitializationException en el punto más incómodo posible.

Distinguir en la respuesta entre «usuario no existe» y «contraseña incorrecta». Permite enumerar los correos registrados. Responde siempre lo mismo.

Aceptar el rol o el id de usuario en el DTO de registro o de alquiler. Es mass assignment y suplantación. La identidad y los privilegios los fija el servidor.

Codificar la contraseña en el controlador, o en dos sitios distintos. El encode va en el servicio, dentro de la transacción y en un único punto; si se duplica, tarde o temprano uno de los dos guardará la contraseña en claro. Y nunca registres la contraseña ni el hash en un log, ni en DEBUG: los logs se copian, se envían a sistemas externos y se conservan años.

Consejo: normaliza el correo al registrar y al buscar. Minúsculas y trim en ambos extremos evitan cuentas duplicadas que solo se diferencian en mayúsculas. Y recuerda que la garantía real es la restricción UNIQUE de la base de datos, no el existsBy: el existsBy da un buen mensaje, pero el motor es quien impide el duplicado bajo concurrencia.

Ejercicios

Ejercicio 1

Escribe el UserDetailsService de CicloUrbana y el UsuarioAutenticado correspondiente, partiendo de una entidad Usuario con correo, contrasenaHash, activo y Set<RolUsuario> roles. Justifica: dónde se añade el prefijo ROLE_, por qué no se devuelve la entidad, qué anotación transaccional lleva el método y qué mensaje de error se expone al cliente.

Ejercicio 2

Un ciudadano informa de que cambió su correo a [email protected] desde el panel de administración y ahora no puede entrar, mientras que un compañero, con el mismo cambio, sí entra. Analiza las causas posibles y propón una solución completa que incluya código, migración y prevención.

Ejercicio 3

Diseña el endpoint PATCH /api/v1/usuarios/{id}/roles, que permite a un ADMIN asignar el rol OPERARIO a un ciudadano. Enumera todas las comprobaciones de seguridad necesarias y escribe el DTO, el controlador y el servicio.

Soluciones

Solución 1

El código es el de los apartados 4 y 5. Las cuatro justificaciones:

Dónde se añade ROLE_: en el constructor de UsuarioAutenticado, al mapear RolUsuario a SimpleGrantedAuthority. En la base de datos se guarda ADMIN, sin prefijo, porque es un dato de dominio y no una convención de Spring Security. Concentrarlo en un punto es lo que evita los ROLE_ROLE_ y los 403 inexplicables.

Por qué no se devuelve la entidad: porque acoplaría el dominio al framework, obligaría a Usuario a implementar siete métodos ajenos a su responsabilidad y, sobre todo, metería una entidad gestionada en el SecurityContext. Con open-in-view: false, cualquier acceso posterior a una relación perezosa desde el contexto de seguridad lanzaría LazyInitializationException lejos del origen del problema.

Anotación transaccional: @Transactional(readOnly = true). Garantiza una sesión activa para materializar los roles y permite a Hibernate omitir la comprobación de cambios (04-07).

Mensaje al cliente: siempre el mismo —«Credenciales no válidas»— tanto si el correo no existe como si la contraseña falla, para no permitir enumerar los usuarios de la red. El motivo real, en el log del servidor a nivel DEBUG.

Solución 2

Causa raíz: el correo se guardó tal cual, con mayúsculas. Si loadUserByUsername usara findByCorreo sin IgnoreCase, Marta tendría que escribir exactamente [email protected] para entrar. Que a su compañero le funcione indica que él lo escribió en minúsculas o que su cliente lo normaliza.

Causa secundaria posible: duplicado. Si el cambio se hizo con un INSERT en lugar de un UPDATE, puede haber dos filas, [email protected] y [email protected]. La restricción UNIQUE de V1 no lo impide, porque en PostgreSQL distingue mayúsculas.

Solución completa, en tres frentes.

1. Consulta insensible a mayúsculas —ya está en el apartado 5— con findByCorreoIgnoreCase, que genera WHERE upper(correo) = upper(?).

2. Normalización en toda escritura, en el servicio: correo.trim().toLowerCase(Locale.ROOT) al registrar y al modificar. Locale.ROOT no es un detalle menor: con la configuración regional turca, toLowerCase() convierte la I en ı y produciría un correo distinto.

3. Migración que arregla los datos y previene la reaparición:

-- V5__normalizar_correos_usuarios.sql

-- Falla explícitamente si ya hay duplicados: hay que resolverlos a mano
DO $$
DECLARE duplicados INT;
BEGIN
    SELECT COUNT(*) INTO duplicados FROM (SELECT lower(correo) FROM usuarios
        GROUP BY lower(correo) HAVING COUNT(*) > 1) d;
    IF duplicados > 0 THEN
        RAISE EXCEPTION 'Hay % correos duplicados por mayúsculas', duplicados;
    END IF;
END $$;

UPDATE usuarios SET correo = lower(trim(correo)) WHERE correo <> lower(trim(correo));

-- Prevención definitiva: el índice único opera sobre la forma normalizada
ALTER TABLE usuarios DROP CONSTRAINT uk_usuarios_correo;
CREATE UNIQUE INDEX uk_usuarios_correo ON usuarios (lower(correo));

El índice único funcional es la solución definitiva, porque hace imposible el duplicado sea cual sea el código que escriba: la garantía vuelve a estar en el motor y no en la disciplina de los desarrolladores. Y el bloque DO que aborta si ya hay duplicados es buena práctica de migración: es preferible que la migración falle a que un UPDATE provoque una violación de restricción a medias.

Solución 3

Comprobaciones necesarias, en orden:

  1. Autenticación: el llamante debe estar autenticado → 401 si no.
  2. Autorización por rol: solo ADMIN → 403. Ya lo cubre requestMatchers("/api/v1/usuarios/**").hasRole("ADMIN") de 05-02.
  3. Existencia del usuario destino → 404.
  4. Validación del rol recibido: debe ser un valor del enumerado. Bean Validation y el tipo RolUsuario lo garantizan; un valor desconocido produce 400.
  5. Regla de negocio: nadie se concede privilegios a sí mismo. Un ADMIN no debe poder modificar sus propios roles, porque elimina el control de cuatro ojos y facilita la escalada si su cuenta se ve comprometida.
  6. Regla de negocio: no dejar el sistema sin administradores. Quitar el último ADMIN deja la red ingobernable.
  7. Auditoría obligatoria: quién cambió qué roles a quién y cuándo. Es un cambio de privilegios.
public record ActualizarRolesRequest(@NotEmpty Set<RolUsuario> roles) {}

El controlador es un @PatchMapping("/{id}/roles") que recibe el @Valid @RequestBody ActualizarRolesRequest, el @PathVariable y el @AuthenticationPrincipal UsuarioAutenticado admin, y delega en el servicio, donde está toda la lógica:

@Transactional
public UsuarioResponse actualizarRoles(Long id, Set<RolUsuario> nuevosRoles,
                                       UsuarioAutenticado admin) {

    if (id.equals(admin.getIdUsuario())) {
        throw new ReglaNegocioException(
                "Un administrador no puede modificar sus propios roles", "AUTOASIGNACION");
    }

    Usuario usuario = usuarioRepositorio.findById(id)
            .orElseThrow(() -> new RecursoNoEncontradoException("Usuario", id));

    if (usuario.getRoles().contains(RolUsuario.ADMIN)
            && !nuevosRoles.contains(RolUsuario.ADMIN)
            && usuarioRepositorio.contarPorRol(RolUsuario.ADMIN) <= 1) {
        throw new ReglaNegocioException(
                "No se puede eliminar el último administrador", "ULTIMO_ADMIN");
    }

    Set<RolUsuario> anteriores = Set.copyOf(usuario.getRoles());
    usuario.setRoles(nuevosRoles);
    log.warn("CAMBIO DE ROLES: admin={} usuarioAfectado={} antes={} despues={}",
             admin.getIdUsuario(), id, anteriores, nuevosRoles);
    return usuarioMapper.aRespuesta(usuario);
}

Tres apuntes. El log es WARN a propósito: un cambio de privilegios no es rutina y debe destacar en la revisión de logs (09-05). La comprobación del último administrador tiene una condición de carrera —dos peticiones simultáneas podrían quitar los dos últimos—, resoluble con el bloqueo pesimista de 04-07 o con una restricción en base de datos. Y el usuario afectado seguirá teniendo sus roles antiguos hasta que renueve su credencial: con sesión, hasta que vuelva a entrar; con JWT, hasta que caduque el token. Es una limitación importante del modelo sin estado y la trataremos en 05-04.

Conclusión

La identidad de CicloUrbana ya es real. Has ampliado la entidad Usuario de 04-03 con contrasenaHash, el activo que ya tenía y un Set<RolUsuario> mapeado con @ElementCollection y @Enumerated(STRING), justificando cada decisión: por qué un enumerado y no una entidad Rol, por qué EAGER es aquí la excepción correcta a la regla de 04-04, y por qué el campo se llama contrasenaHash y queda fuera de toString. La migración V4__anadir_credenciales_usuarios.sql lo ha llevado al esquema con el patrón de tres pasos «nullable → rellenar → NOT NULL», una tabla usuario_roles con clave compuesta, ON DELETE CASCADE y una restricción CHECK que replica el enumerado en el motor, dejando desactivados a los usuarios preexistentes por decisión de seguridad.

Has escrito ServicioDetallesUsuario, que carga por correo desde UsuarioRepositorio con findByCorreoIgnoreCase dentro de una transacción de solo lectura, y UsuarioAutenticado, la copia inmutable que implementa UserDetails conservando el id —una decisión de tres líneas que evita una consulta por petición y que en 05-05 permitirá escribir principal.idUsuario en una expresión de seguridad—. Entiendes exactamente qué hace el DaoAuthenticationProvider: busca, compara con PasswordEncoder, comprueba los cuatro estados de la cuenta y calcula un hash incluso cuando el usuario no existe, porque responder rápido delataría qué correos están registrados en la red municipal. Y sabes por qué todos los fallos de autenticación se responden con un único mensaje genérico.

Has abierto el registro de ciudadanos con cinco decisiones de seguridad en veinte líneas: el rol se fija en el servidor y no viaja en el DTO —evitando el mass assignment—, la respuesta es una lista de inclusión sin hash ni roles, el correo se normaliza, la garantía de unicidad la da la restricción de la base de datos y no el existsBy, y el límite de 72 caracteres responde a un detalle real de BCrypt; todo ello con la advertencia expresa de que falta la verificación del correo, que ningún servicio real puede omitir. Has aclarado que solo existen autoridades y que un rol es una autoridad con prefijo ROLE_, has comparado roles y permisos granulares sabiendo cuándo tocará migrar, y has simplificado las reglas de 05-02 con RoleHierarchy y la jerarquía ADMIN > OPERARIO > CIUDADANO, sin forzarla sobre roles que no sean acumulativos.

Y has hecho el cambio más importante de la lección: POST /api/v1/alquileres ya no acepta el usuarioId del cliente, sino que lo deduce del @AuthenticationPrincipal. La regla que resume todo: la identidad nunca se acepta del cliente, se deduce de la credencial. Los errores de seguridad hablan por fin el mismo idioma que el resto de la API, con AuthenticationEntryPoint y AccessDeniedHandler propios que devuelven ProblemDetail con su código y su traza del MDC de 03-06; y conoces el esquema de control de intentos fallidos con los eventos de autenticación y sus cuatro cautelas.

Queda un problema práctico que la app móvil de Ribalta nota en cuanto se conecta: con HTTP Basic, el cliente tiene que guardar la contraseña del ciudadano y enviarla en cada una de las peticiones. Cada consulta del mapa de estaciones arrastra la credencial más valiosa del usuario por la red, y cada una obliga al servidor a calcular un BCrypt de 90 ms. No hay forma de caducar el acceso sin cambiar la contraseña, ni de conceder permisos limitados a una integración. En 05-04, Implementación de Autenticación JWT, lo resolveremos: la contraseña se enviará una sola vez a POST /api/v1/auth/login y a cambio el servidor emitirá un token firmado y caducable que el cliente presentará en la cabecera Authorization: Bearer. Veremos la anatomía de un JWT campo a campo, lo implementaremos con JJWT y un FiltroAutenticacionJwt, añadiremos tokens de refresco con rotación y revocación en base de datos, y afrontaremos con honestidad las dos preguntas incómodas del modelo sin estado: dónde guarda el token el cliente y qué significa realmente cerrar sesión.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados