← Sesión, autenticación y Spring Security

Sesión 37 · Semana 19

Usuarios persistentes y roles

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado planificar permisos y preparar el entorno de seguridad. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

La configuración de seguridad ya distingue accesos, pero necesita cargar usuarios reales de tu base de datos. UserDetailsService es el punto donde Spring Security obtiene identidad, hash y permisos. Hoy conectarás esa consulta con el usuario persistente.

Cómo busca identidades Spring Security: UserDetailsService

En el trabajo anterior usamos un usuario temporal configurado en un archivo de texto. En el mundo real, los usuarios se registran en una pantalla, cambian de contraseña, se dan de baja y sus credenciales viven en tablas de PostgreSQL.

Spring Security no te obliga a usar una estructura de base de datos rígida. En su lugar, define un contrato funcional mediante una interfaz:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
El flujo de autenticación contra base de datos
  1. Cliente envía credenciales ("admin", "clave123")
  2. AuthenticationManager llama a UserDetailsService
  3. loadUserByUsername() consulta UsuarioRepository en PostgreSQL
  4. Devuelve UserDetails con hash BCrypt guardado
  5. PasswordEncoder.matches("clave123", hashGuardado)
  6. Éxito o 401 Unauthorized

El desacoplamiento entre Usuario (JPA) y UserDetails (Seguridad)

Tu entidad de dominio Usuario representa una persona en tu negocio (nombre, email, fecha de alta, departamento).

Spring Security no sabe qué es un departamento: solo necesita saber qué dice el contrato de UserDetails:

  • getUsername(): Nombre de usuario único.
  • getPassword(): Hash BCrypt guardado en la base de datos.
  • getAuthorities(): Colección de roles y permisos del usuario (Collection<? extends GrantedAuthority>).
  • isEnabled(): Si la cuenta está activa (true) o desactivada (false).
  • isAccountNonLocked(): Si la cuenta está bloqueada por intentos fallidos.

Podemos hacer que nuestra entidad Usuario implemente directamente UserDetails, permitiendo que viaje de forma nativa a través de todo el framework.

De la cuenta del dominio a la identidad de seguridad

La entidad que representa a una persona y la identidad que necesita Spring Security tienen responsabilidades distintas. La primera puede contener datos de negocio; la segunda debe proporcionar el identificador de acceso, el hash de contraseña, las autoridades y el estado de la cuenta. Un adaptador permite utilizar esos datos sin devolver toda la entidad como perfil público.

Durante la autenticación se busca la cuenta mediante el identificador acordado y se compara la contraseña recibida con el hash almacenado mediante el PasswordEncoder. No se calcula un hash nuevo para compararlo como una cadena: el algoritmo incorpora parámetros y sal, y su operación de verificación sabe interpretarlos.

El perfil público utiliza un DTO que excluye el hash. Los roles proceden de datos controlados por el servidor; aceptar un rol enviado libremente en el registro permitiría autoconcederse privilegios. La práctica comprueba dos cuentas con roles diferentes, una contraseña incorrecta y la persistencia tras reiniciar.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre la entidad de usuario, su repositorio, el PasswordEncoder y SecurityConfig. Revisa los nombres exactos de roles que espera tu configuración.
  2. Prepara usuarios ficticios con hashes generados por el encoder de tu aplicación. Comprueba que el entorno de pruebas apunta a la base de datos correcta.
  3. Localiza dónde se cargaban hasta ahora los usuarios de prueba para sustituirlo por la consulta persistente, sin mantener dos fuentes contradictorias.

Paso 2 · De la entidad JPA al CustomUserDetailsService

Modifica Usuario conservando sus campos y relaciones del trimestre anterior; añade los métodos de UserDetails sin borrar sus getters y setters de negocio. Añade findByUsername a su repositorio existente. Crea después service/CustomUserDetailsService.java y comprueba que es la única implementación activa de UserDetailsService. Prepara hashes con el test proporcionado e inserta o actualiza únicamente los usuarios ficticios de la práctica. No borres toda la tabla para evitar duplicados: podría estar referenciada por tareas y proyectos.

package com.ejemplo.gestor.model;

import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Entity
@Table(name = "usuarios")
public class Usuario implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 30)
    private Rol rol;

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

    // Métodos obligatorios del contrato UserDetails
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // En Spring Security los roles se representan como GrantedAuthority con prefijo ROLE_
        return List.of(new SimpleGrantedAuthority(rol.name()));
    }

    @Override
    public String getPassword() { return this.password; }

    @Override
    public String getUsername() { return this.username; }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return this.activo; }

    // Getters y setters de dominio
    public Long getId() { return id; }
    public Rol getRol() { return rol; }
    public void setRol(Rol rol) { this.rol = rol; }
    public void setActivo(boolean activo) { this.activo = activo; }
}
package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Usuario;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
    Optional<Usuario> findByUsername(String username);
}

Creamos el servicio anotado con @Service para que Spring Security lo detecte automáticamente como el proveedor oficial de identidades:

package com.ejemplo.gestor.service;

import com.ejemplo.gestor.repository.UsuarioRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UsuarioRepository usuarioRepository;

    public CustomUserDetailsService(UsuarioRepository usuarioRepository) {
        this.usuarioRepository = usuarioRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return usuarioRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("No existe usuario con username: " + username));
    }
}

Aquí no vale copiar un hash de unos apuntes. BCrypt incorpora una sal aleatoria dentro del propio hash, así que el de tu compañero no es el tuyo aunque la contraseña sea la misma, y un hash mal copiado se traduce siempre en un 401 que parece un fallo de configuración y no lo es.

Genera los tuyos reutilizando el PasswordEncoder de la sesión 36. Crea src/test/java/com/ejemplo/gestor/GenerarHashesTest.java:

package com.ejemplo.gestor;

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

class GenerarHashesTest {

    @Test
    void imprimirHashesParaDataSql() {
        PasswordEncoder encoder = new BCryptPasswordEncoder(12);
        for (String clave : new String[] { "Password123!", "Dev2026!" }) {
            System.out.println(clave + "  ->  " + encoder.encode(clave));
        }
    }
}

Ejecútalo con ./mvnw test -Dtest=GenerarHashesTest y copia de la consola las cadenas que empiezan por $2a$12$. Cada una mide exactamente 60 caracteres: si la tuya mide otra cosa, se ha partido al copiarla.

Crea src/main/resources/data.sql pegando tus propios hashes, no los de este guion:

INSERT INTO usuarios (username, password, rol, activo) VALUES
('admin',    '<pega aquí tu hash de Password123!>', 'ROLE_ADMINISTRADOR', true),
('dev1',     '<pega aquí tu hash de Dev2026!>',     'ROLE_DESARROLLADOR', true),
('inactivo', '<pega aquí tu hash de Dev2026!>',     'ROLE_DESARROLLADOR', false);

Añade esta línea a application.properties:

# Sin esto, data.sql se ejecuta ANTES de que Hibernate cree la tabla usuarios
spring.jpa.defer-datasource-initialization=true

La línea que se olvida todo el mundo

Con ddl-auto=update, Spring Boot ejecuta data.sql antes de que Hibernate haya creado las tablas. El arranque falla con un relation "usuarios" does not exist que no tiene nada que ver con tu SQL. defer-datasource-initialization=true invierte ese orden.

El otro efecto que conviene conocer: data.sql se ejecuta en cada arranque. Si reinicias dos veces tendrás el error de clave única del username. Empieza el archivo con DELETE FROM usuarios; mientras estés en desarrollo.

Paso 3 · Autenticación real contra PostgreSQL

Elimina del archivo application.properties las tres propiedades fijas spring.security.user.name, .password y .roles de la sesión 36: así la configuración refleja el mecanismo actual. Al declarar tu propio UserDetailsService, la identidad automática de Boot deja de aplicarse; quitar estas propiedades evita confusión.

Reinicia Spring Boot y prueba en Bruno:

  1. Login exitoso con usuario de base de datos:
    • Lanza POST /api/v1/proyectos con Basic Auth: usuario admin, contraseña Password123!.
    • Resultado: Código 201 Created. En los logs de Hibernate verás la consulta: SELECT ... FROM usuarios WHERE username = ?.
  2. Usuario inexistente en base de datos:
    • Lanza la petición con usuario fantasma y clave Password123!.
    • Resultado: Código 401 Unauthorized.
  3. Usuario con contraseña errónea:
    • Lanza la petición con usuario admin y clave ClaveIncorrecta!.
    • Resultado: Código 401 Unauthorized.
  4. Usuario desactivado (activo = false):
    • Lanza la petición con usuario inactivo y clave Password123!.
    • Resultado: Código 401 Unauthorized. Spring Security lee isEnabled() == false y bloquea el acceso de inmediato.

Paso 4 · Si algo no sale como dice el guion

Síntoma Causa casi segura Qué mirar
Al arrancar: relation "usuarios" does not exist data.sql corre antes que Hibernate Falta spring.jpa.defer-datasource-initialization=true
Al reiniciar: duplicate key value violates unique constraint data.sql se ejecuta en cada arranque Prepara solo las cuentas que falten con INSERT … ON CONFLICT (username) DO NOTHING; conserva los usuarios existentes
401 con el usuario y la contraseña correctos El hash de data.sql no corresponde a esa contraseña Cuenta los caracteres: deben ser 60. Regenéralo con el test del paso 4
Encoded password does not look like BCrypt en el log La columna guarda la contraseña en claro Estás insertando 'Password123!' en vez de su hash
401 siempre, y en los logs no aparece ningún SELECT ... FROM usuarios Tu CustomUserDetailsService no se está usando ¿Tiene @Service? ¿Hay algún otro bean UserDetailsService (por ejemplo, el de application.properties) todavía activo?
LazyInitializationException al leer los roles La colección de roles se carga fuera de la transacción Con un solo Rol mapeado como @Enumerated esto no pasa; si has pasado a Set<Rol>, necesitarás FetchType.EAGER en esa colección

Paso 5 · Endpoint de perfil del usuario autenticado (/me)

Crea UsuarioResponse con id, username y rol si todavía no existe. En UsuarioController, establece explícitamente el prefijo de clase /api/v1/usuarios y añade el método /me: la URL completa será /api/v1/usuarios/me. Importa AuthenticationPrincipal y el tipo Usuario. El método no recibe un id del cliente: construye el DTO a partir de la identidad autenticada. Compruébalo con dos cuentas y observa que cambia la respuesta al cambiar las credenciales.

  1. Crea en UsuarioController el método:
    @GetMapping("/me")
    public ResponseEntity<UsuarioResponse> obtenerMiPerfil(@AuthenticationPrincipal Usuario usuarioAutenticado) {
        return ResponseEntity.ok(new UsuarioResponse(
            usuarioAutenticado.getId(),
            usuarioAutenticado.getUsername(),
            usuarioAutenticado.getRol().name()
        ));
    }
  2. La anotación @AuthenticationPrincipal inyecta directamente la instancia de Usuario que Spring Security validó en la base de datos. Funciona porque tu entidad es un UserDetails: ese es el rédito de haber implementado la interfaz en el paso 1.
  3. Define el record UsuarioResponse(Long id, String username, String rol) en com.ejemplo.gestor.dto. Fíjate en lo que no lleva: la contraseña. Aunque sea un hash, un endpoint de perfil no tiene ninguna razón para publicarla, y este es exactamente el escenario que la UD3 anticipó al separar entidad y DTO.
  4. Prueba la llamada con admin y con dev1 y comprueba que cada uno recibe su propia identidad, sin que el endpoint reciba ningún id por parámetro: la identidad no se pide, se deduce del token o de las credenciales.
  5. Lanza GET /api/v1/usuarios/me sin credenciales y confirma que responde 401 antes de entrar al método. Añade las tres peticiones a la carpeta 09-seguridad de tu colección.
  6. Añade al data.sql un cuarto usuario jefe1 con rol ROLE_JEFE_PROYECTO: lo vas a necesitar en la sesión 38 para probar la fila intermedia de la matriz, y es mejor tener los tres roles poblados desde ya.
Cómo saber que lo has terminado
En los logs de Hibernate aparece un select ... from usuarios where username=? por cada intento de autenticación; admin entra, fantasma y inactivo no; /me devuelve identidades distintas para credenciales distintas sin recibir ningún parámetro; y en application.properties ya no queda ni rastro de spring.security.user.

Paso 6 · Comprobar y registrar el resultado del proyecto

  1. Autentica un usuario guardado, rechaza una contraseña incorrecta y comprueba que una cuenta inexistente tampoco accede.
  2. Consulta /me: debe representar al usuario autenticado sin exponer su contraseña ni su hash. Reinicia y confirma que las cuentas siguen disponibles.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · Prevención de ataques de temporización (Timing Attacks)

Cuando un atacante intenta adivinar si un nombre de usuario existe en tu base de datos:

  • Si el usuario no existe, la base de datos responde rápido y la petición tarda 10 ms.
  • Si el usuario existe, la base de datos lo encuentra y el servidor ejecuta la costosa función BCrypt (tardando 250 ms).
  • Midiendo el tiempo de respuesta con un script de milisegundos, el atacante puede enumerar todos los usuarios válidos del sistema.

Investiga cómo Spring Security mitiga este vector mediante contraseñas simuladas (dummy hash computation):

  1. ¿Qué hace internamente DaoAuthenticationProvider cuando loadUserByUsername lanza UsernameNotFoundException?
  2. ¿Por qué ejecuta de todos modos una llamada falsa a passwordEncoder.matches() antes de responder 401?
Objetivo mínimoInterfaz UserDetailsService implementada y conectada a PostgreSQL mediante UsuarioRepository.
Si lo tienesEntidad Usuario implementando UserDetails, verificación de cuenta activa (isEnabled) y endpoint /me.
RetoProtección contra ataques de temporización (*Timing Attacks*) comprendida e inspeccionada en los componentes internos.
Ver respuestas

1 · El método loadUserByUsername(String username), lanzando UsernameNotFoundException si el registro no se localiza.

2 · Porque las expresiones de seguridad de Spring (hasRole) asumen internamente la existencia del prefijo ROLE_ por convención.

3 · Para inyectar directamente en el parámetro del controlador el objeto UserDetails/Usuario asociado al contexto de seguridad de la petición actual.

4 · Spring Security comprueba su valor booleano tras validar la contraseña; si devuelve false, aborta la autenticación con una excepción de cuenta deshabilitada (DisabledException) y responde 401.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Los usuarios sobreviven al reinicio y las operaciones responden de acuerdo con su rol.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.