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;
}
- Cliente envía credenciales ("admin", "clave123")
- AuthenticationManager llama a UserDetailsService
- loadUserByUsername() consulta UsuarioRepository en PostgreSQL
- Devuelve UserDetails con hash BCrypt guardado
- PasswordEncoder.matches("clave123", hashGuardado)
- É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
- Abre la entidad de usuario, su repositorio, el PasswordEncoder y SecurityConfig. Revisa los nombres exactos de roles que espera tu configuración.
- 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.
- 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:
- Login exitoso con usuario de base de datos:
- Lanza
POST /api/v1/proyectoscon Basic Auth: usuarioadmin, contraseñaPassword123!. - Resultado: Código
201 Created. En los logs de Hibernate verás la consulta:SELECT ... FROM usuarios WHERE username = ?.
- Lanza
- Usuario inexistente en base de datos:
- Lanza la petición con usuario
fantasmay clavePassword123!. - Resultado: Código
401 Unauthorized.
- Lanza la petición con usuario
- Usuario con contraseña errónea:
- Lanza la petición con usuario
adminy claveClaveIncorrecta!. - Resultado: Código
401 Unauthorized.
- Lanza la petición con usuario
- Usuario desactivado (
activo = false):- Lanza la petición con usuario
inactivoy clavePassword123!. - Resultado: Código
401 Unauthorized. Spring Security leeisEnabled() == falsey bloquea el acceso de inmediato.
- Lanza la petición con usuario
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.
- Crea en
UsuarioControllerel método:@GetMapping("/me") public ResponseEntity<UsuarioResponse> obtenerMiPerfil(@AuthenticationPrincipal Usuario usuarioAutenticado) { return ResponseEntity.ok(new UsuarioResponse( usuarioAutenticado.getId(), usuarioAutenticado.getUsername(), usuarioAutenticado.getRol().name() )); } - La anotación
@AuthenticationPrincipalinyecta directamente la instancia deUsuarioque Spring Security validó en la base de datos. Funciona porque tu entidad es unUserDetails: ese es el rédito de haber implementado la interfaz en el paso 1. - Define el
record UsuarioResponse(Long id, String username, String rol)encom.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. - Prueba la llamada con
adminy condev1y comprueba que cada uno recibe su propia identidad, sin que el endpoint reciba ningúnidpor parámetro: la identidad no se pide, se deduce del token o de las credenciales. - Lanza
GET /api/v1/usuarios/mesin credenciales y confirma que responde401antes de entrar al método. Añade las tres peticiones a la carpeta09-seguridadde tu colección. - Añade al
data.sqlun cuarto usuariojefe1con rolROLE_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;adminentra,fantasmayinactivono;/medevuelve identidades distintas para credenciales distintas sin recibir ningún parámetro; y enapplication.propertiesya no queda ni rastro despring.security.user.
Paso 6 · Comprobar y registrar el resultado del proyecto
- Autentica un usuario guardado, rechaza una contraseña incorrecta y comprueba que una cuenta inexistente tampoco accede.
- 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):
- ¿Qué hace internamente
DaoAuthenticationProvidercuandoloadUserByUsernamelanzaUsernameNotFoundException? - ¿Por qué ejecuta de todos modos una llamada falsa a
passwordEncoder.matches()antes de responder401?
UserDetailsService implementada y conectada a PostgreSQL mediante UsuarioRepository.Usuario implementando UserDetails, verificación de cuenta activa (isEnabled) y endpoint /me.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.