1. El papel de UserDetailsService en la autenticación respaldada por BD
Cuando pasas por primera vez a usuarios respaldados por BD, surge la idea ingenua: "Bueno, si tengo UserAccountRepository, entonces Spring Security también puede simplemente ir al repositorio". Desafortunadamente (o afortunadamente), Spring Security no sabe cómo ir a tu repositorio, porque en general no conoce tu UserAccount. Funciona con su propio contrato: UserDetailsService devuelve UserDetails, y luego el proveedor de autenticación verifica la contraseña y los estados de la cuenta.
La metáfora más útil para UserDetailsService es la de un traductor. El repositorio habla el idioma de tu BD y del modelo de dominio (UserAccount). Spring Security habla el idioma del modelo de seguridad (UserDetails). Y UserDetailsService es el "diplomático" que toma uno y lo convierte cuidadosamente en el otro, sin mezclar responsabilidades adicionales.
Es importante establecer de inmediato el límite correcto. UserDetailsService no debe decidir quién puede acceder a /api/admin/**. No debe resolver asuntos de negocio como "¿puede este usuario publicar un borrador?". Ni siquiera debe "comparar contraseñas manualmente". Su tarea es más simple y estricta: encontrar la cuenta por la cadena de inicio de sesión y devolver la representación de seguridad mínima suficiente, con la que luego trabaja el mecanismo estándar de Spring Security.
Para ver el lugar de UserDetailsService en el flujo, es conveniente dibujar un esquema:
flowchart TD
A[Solicitud de inicio de sesión] --> B[Authentication Filter]
B --> C[AuthenticationManager]
C --> D[DaoAuthenticationProvider]
D --> E[UserDetailsService]
E --> F[UserAccountRepository]
F --> G[(BD)]
D --> H["PasswordEncoder.matches"]
D --> I[SecurityContext]
UserDetailsService aquí es el punto estrecho donde la seguridad "se encuentra" con tu base de datos, pero sin convertirse en "las entrañas de la base de datos".
2. Límite: repositorio vs CustomUserDetailsService
Cuando aparece tu propio servicio de carga de usuarios, es muy fácil convertirlo en una "combinación": cargar la cuenta, cargar el perfil, verificar la contraseña, calcular los permisos, ajustar las reglas de acceso, todo en un solo método. Este enfoque al principio parece conveniente, pero luego rompe el mantenimiento: los errores son difíciles de diagnosticar, el código deja de ser predecible y la seguridad comienza a depender de detalles aleatorios de la lógica de dominio.
El límite es más simple. El repositorio es responsable de una sola cosa: obtener el UserAccount de la capa de persistencia. No sabe sobre UserDetails, roles en forma de GrantedAuthority y otros términos de seguridad. CustomUserDetailsService es responsable de otra cosa: tomar el resultado del repositorio y convertirlo en un objeto UserDetails, además de manejar correctamente la situación de "usuario no encontrado". Eso es todo, punto.
Para que esto no quede en filosofía, fijemos los roles en una pequeña tabla:
| Componente | Qué hace | Qué no hace |
|---|---|---|
| UserAccountRepository | Lee la cuenta de la BD | No construye UserDetails, no verifica la contraseña |
| CustomUserDetailsService | Convierte UserAccount en UserDetails | No decide reglas de acceso, no compara contraseñas |
| DaoAuthenticationProvider | Verifica la contraseña, account states, forma Authentication | No accede directamente a la BD |
| SecurityFilterChain | Define las reglas de acceso a las solicitudes | No lee usuarios de la BD |
Por ahora mantenemos el repositorio en su forma más simple: aquí es importante ver el límite limpio entre persistencia y seguridad. Incluso si luego la solicitud de autenticación comienza a traer roles junto con la cuenta, la responsabilidad del repositorio no cambiará: simplemente sigue leyendo datos.
Mini-ejemplo: repositorio como "lectura pura"
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
// Por ahora mostramos la ruta de lectura mínima:
// el repositorio simplemente encuentra la cuenta por la cadena de inicio de sesión.
Optional<UserAccount> findByUsername(String username);
}
Aquí el repositorio es lo más "modesto" posible: solo promete que puede encontrar la cuenta por username. Nada de UserDetails, ninguna verificación de contraseña, ningún "si el rol es ADMIN, entonces…". Esta es la disciplina útil: el repositorio sigue siendo repositorio.
Incluso cuando la solicitud de autenticación se vuelva más precisa y comience a traer roles junto con la cuenta, el límite en sí no cambiará: el repositorio sigue siendo el lugar de lectura de datos, no el lugar donde nace UserDetails.
3. Contrato de UserDetailsService y loadUserByUsername
El nombre del método loadUserByUsername parece indicar que Spring Security nos obliga a iniciar sesión precisamente por username. Pero en realidad Spring Security solo pide una cosa: "dame el usuario por la cadena de inicio de sesión". Cómo interpretes esa cadena (username, email o algo más) es una decisión de diseño. Hoy asumiremos que la cadena de inicio de sesión es username para no dispersarnos, pero es útil recordar que esto es precisamente un contrato.
El contrato de UserDetailsService es simple: el método recibe una cadena, devuelve UserDetails o lanza UsernameNotFoundException. Nada de Optional, nada de null, nada de "bueno, devolví un usuario vacío porque no lo encontré". Spring Security prefiere una lógica estricta: o el usuario es encontrado y representado en UserDetails, o no existe, y eso se expresa con una excepción.
Y otro detalle importante: UserDetailsService es un bean de Spring. Es decir, lo crea Spring y lo coloca en el contexto, o "no existe en la naturaleza" para la aplicación. Por eso normalmente hacemos nuestra propia implementación como @Service e inyectamos el repositorio a través del constructor. La inyección por constructor aquí es un buen ejemplo de "no magia": se ve que el servicio necesita el repositorio, y se ve que no necesita nada más.
Mini-ejemplo: esqueleto de CustomUserDetailsService
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserAccountRepository repository;
public CustomUserDetailsService(UserAccountRepository repository) {
// El repositorio es la única forma de obtener la cuenta de la BD en esta capa
this.repository = repository;
}
@Override
public UserDetails loadUserByUsername(String username) {
// La implementación se agregará más adelante: aquí será "find → throw → map"
throw new UnsupportedOperationException("Not implemented yet");
}
}
Por ahora es un "esqueleto vacío", pero ya se ve lo principal: esta clase es el contrato oficial de entrada de Spring Security hacia tu almacén de usuarios. Si aquí comienza el caos, entonces el caos estará en todas partes.
4. Implementación de loadUserByUsername
Cuando implementas loadUserByUsername, es importante mantener dos cualidades: previsibilidad y minimalismo. Previsibilidad significa que el método siempre se comporta igual: si encuentra, devuelve UserDetails; si no encuentra, lanza UsernameNotFoundException. Minimalismo significa que no haces nada superfluo aquí: no accedes al perfil, no lees contenido del usuario, no intentas "reparar la base" si la cuenta tiene un estado extraño.
En la práctica, este método casi siempre se ve como "repositorio → orElseThrow → mapeo". Y eso es normal. A veces los desarrolladores se preocupan: "¿No es demasiado simple? ¿Quizás me estoy perdiendo algo?" No, así debe ser. La capa de seguridad ama las cosas simples e inequívocas. Cuando complicas loadUserByUsername, aumentas el riesgo de errores que se manifestarán en el momento más inoportuno, por ejemplo, cuando un administrador necesita entrar urgentemente al sistema y el servicio de repente intenta cargar su avatar y todo falla.
El manejo de "no encontrado" se hace mediante UsernameNotFoundException. No es simplemente "cualquier excepción". Spring Security sabe interpretarla correctamente en el proceso de autenticación. Además, esto documenta tu intención: el usuario no existe, no "algo se rompió en la base".
Mini-ejemplo: loadUserByUsername como "find → throw → map"
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Override
public UserDetails loadUserByUsername(String username) {
// 1) Obtenemos el modelo de dominio de la BD
UserAccount account = repository.findByUsername(username)
// 2) Si no se encuentra, estamos obligados a lanzar UsernameNotFoundException (es el contrato)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// 3) Mapeamos el modelo de dominio al modelo de seguridad
return toUserDetails(account); // el mapeo se detallará por separado
}
Observa la redacción: no devolvemos null. No escribimos return Optional.empty(). Decimos explícitamente: "Usuario no encontrado". Dentro de la aplicación, esto significa un escenario de autenticación claro y controlable, no un misterioso NullPointerException en algún lugar profundo del proveedor.
5. Contraseña: zona de DaoAuthenticationProvider
Cuando empiezas a construir la autenticación respaldada por BD, dan muchas ganas de "hacerlo todo de una vez" y verificar la contraseña directamente en loadUserByUsername. Piensas: "ya obtuve el passwordHash, comparémoslo y listo". Parece lógico, pero arquitectónicamente es incorrecto: rompes la separación de responsabilidades de Spring Security, y luego te será más difícil usar los mecanismos estándar.
La verificación de la contraseña es tarea de DaoAuthenticationProvider, que está construido específicamente para el esquema username/password. Toma UserDetails (que contiene el hash, no la contraseña en texto plano) y lo compara con la contraseña ingresada a través de PasswordEncoder.matches(raw, encoded). Es en este lugar donde Spring Security tiene los hooks correctos para el manejo de errores, la semántica adecuada de excepciones y un comportamiento comprensible para diferentes estados de la cuenta.
Además, si empiezas a verificar la contraseña manualmente en CustomUserDetailsService, casi inevitablemente cometerás al menos uno de los "clásicos" errores: empezarás a registrar la contraseña en texto plano, comparar cadenas directamente, olvidarte del encoder, o construir tu propia mini-autenticación que entrará en conflicto con el proveedor.
Es útil ver esto una vez como "casi pseudocódigo" para separar mentalmente las responsabilidades:
// CustomUserDetailsService es responsable solo de cargar el usuario
UserDetails user = userDetailsService.loadUserByUsername(login);
// PasswordEncoder sabe cómo comparar la contraseña en texto plano con el hash de la BD
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
// La excepción y la semántica del error son responsabilidad del proveedor
throw new BadCredentialsException("Bad credentials");
}
CustomUserDetailsService es responsable solo de la primera línea. Todo lo demás es trabajo del proveedor. Y cuanto más estrictamente mantengas este límite, menos errores "mágicos" tendrás, donde la contraseña es correcta pero el inicio de sesión no funciona.
6. Qué devolver: UserDetails compacto
Otra tentación frecuente: "Ya que tengo UserAccount, devolvámoslo". Pero Spring Security no trabaja con tu UserAccount. Necesita UserDetails. Y esto no es un capricho, sino una arquitectura útil: la capa de seguridad se apoya conscientemente en un modelo compacto que contiene inicio de sesión, hash de contraseña, conjunto de permisos (authorities) y estados de la cuenta.
En nuestro proyecto existe la separación entre UserAccount y UserProfile. Esto no es simplemente una "separación bonita". El perfil (displayName, bio, avatarPath) puede cambiar, puede requerir sus propias reglas de acceso, puede cargarse con consultas separadas. Pero en el momento de la autenticación, todo eso es superfluo. Si empiezas a arrastrar el perfil en loadUserByUsername, obtendrás una consulta más pesada, un código más enredado y razones adicionales para fallos.
Por eso en CustomUserDetailsService suele aparecer un método separado de mapeo. Por ahora nos basta con ver su forma: construye un UserDetails compacto a partir de la cuenta de dominio y no dispersa el mapeo por el código. Y dentro de este método es importante transferir cuidadosamente el inicio de sesión, el hash de la contraseña, los estados de la cuenta y las authorities.
Mini-ejemplo: método adaptador toUserDetails(...)
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
// Aquí construimos la representación de seguridad mínima:
// inicio de sesión, hash de contraseña y authorities (más los estados de la cuenta, más adelante).
return User.withUsername(account.getUsername())
.password(account.getPasswordHash()) // esto es un hash, no la contraseña en texto plano
.authorities("ROLE_USER") // temporal, lo detallaremos después
.build();
}
Sí, por ahora hemos "hardcodeado" ROLE_USER. No es el modelo final, pero ya demuestra la mecánica correcta: Spring Security trabaja con UserDetails, que se construye a partir de la cuenta de dominio. Y no conviertes a UserAccount en un "cuchillo suizo" que debe ser compatible con todo.
Ahora toUserDetails(account) parece un adaptador honesto pero aún demasiado tosco. Aquí surge de inmediato la siguiente pregunta de ingeniería: ¿qué campos, banderas de estado y authorities deben entrar en UserDetails para que el proveedor tenga suficientes datos para la contraseña, los estados de la cuenta y las reglas de acceso? Este paso es necesario analizarlo por separado, de lo contrario todo el esquema respaldado por BD seguirá siendo semimágico.
7. Detalles útiles
"Fuente única de verdad" para los usuarios
Después de la transición a la BD, a veces queda un "legado": los antiguos usuarios en memoria todavía están declarados como bean, y al lado ya vive el nuevo servicio de la BD. Y comienza una divertida búsqueda: inicias sesión con el mismo username, pero los roles llegan a veces de la memoria, a veces de la base. Parece que "todo está configurado", pero el comportamiento ya depende no de los datos, sino de un orden aleatorio de configuración.
La razón es simple: Spring Security debe entender dónde está la fuente principal de usuarios. En la rama principal del proyecto necesitamos un solo UserDetailsService claro, y la base de datos se convierte en el almacén principal de usuarios. De lo contrario, incluso un CustomUserDetailsService correctamente escrito pierde sentido, porque no está claro quién es responsable del usuario al iniciar sesión.
No duplicamos el bean UserDetailsService
Si CustomUserDetailsService ya está registrado como @Service, eso es suficiente para la rama principal del proyecto. No se necesita un segundo @Bean de UserDetailsService al lado: así no fortaleces la configuración, sino que creas una fuente competidora, lo que luego dificulta entender qué servicio usa realmente el proveedor.
La idea útil aquí es muy simple: abre el contexto y podrás responder de inmediato a la pregunta "¿de dónde viene el usuario al iniciar sesión?". Si en el proyecto hay dos respuestas a esta pregunta, el problema ya comenzó.
Dónde colocar CustomUserDetailsService en el proyecto
La arquitectura limpia no es "belleza por belleza", sino una forma de no perder el sentido cuando el proyecto crece. Según el acuerdo de proyecto, tenemos un paquete separado security, y dentro de él, la zona donde vive la infraestructura de autenticación. CustomUserDetailsService pertenece precisamente allí: no es un servicio de negocio de perfil ni un repositorio, es una integración de seguridad.
Una buena regla práctica: si la clase implementa una interfaz de Spring Security (UserDetailsService, AuthenticationEntryPoint, AccessDeniedHandler, etc.), su lugar suele estar en com.example.securecontent.security.*. Entonces abres el paquete security y ves "toda la capa de seguridad" como una parte integral de la aplicación, no "piezas dispersas por funcionalidades".
Para nuestro caso, la estructura podría verse así:
com.example.securecontent
├─ auth
│ ├─ UserAccount
│ └─ UserAccountRepository
└─ security
├─ config
│ └─ SecurityBeansConfig
└─ userdetails
└─ CustomUserDetailsService
Observa cómo esto ayuda a la mente: auth se encarga del modelo de dominio de la cuenta y del repositorio, y security.userdetails se encarga de adaptar ese modelo a Spring Security. Nada de "transferencias de responsabilidad" ni "y si aquí en el controlador verificamos la contraseña, es rápido".
8. Errores típicos al escribir CustomUserDetailsService
Error nº1: verificar la contraseña dentro de loadUserByUsername.
Este código a menudo nace del deseo de "hacerlo más rápido y simple". Pero es precisamente aquí donde pierdes el modelo estándar de Spring Security: la contraseña debe verificarse en DaoAuthenticationProvider a través de PasswordEncoder. Si comparas la contraseña aquí, duplicas la lógica del proveedor o empiezas a reemplazarla, y luego te sorprendes de que el comportamiento se vuelva impredecible.
Error nº2: devolver null si el usuario no es encontrado.
null en el flujo de seguridad es como una cáscara de plátano en el pasillo: no sabes quién ni dónde resbalará. El contrato espera o UserDetails o UsernameNotFoundException. Cuando devuelves null, el error se manifestará más tarde y de una forma más desagradable, y diagnosticarlo será más difícil de lo necesario.
Error nº3: convertir CustomUserDetailsService en un servicio de negocio.
A veces se añaden en loadUserByUsername comprobaciones como "si el usuario no ha confirmado el correo, entonces…", "si no tiene el perfil completo, entonces…", "si no ha pagado la suscripción, entonces…". Incluso si tales reglas aparecen alguna vez en el producto, UserDetailsService no es el mejor lugar para ello. Aquí debe permanecer la lógica mínima y predecible de carga de la cuenta.
Error nº4: tener varias formas diferentes de cargar el usuario sin una razón clara.
Hoy cargas por username, mañana añades otro método "para el panel de administración", luego otro "para el perfil", y al final tienes tres lugares donde la cuenta se mapea a diferentes conjuntos de authorities. Así aparece el "efecto Schrödinger": los permisos del usuario dependen de por qué camino fue cargado. Es mejor tener un único punto de entrada principal (loadUserByUsername) y un mapeo centralizado.
Error nº5: mezclar UserAccount y UserProfile en el proceso de autenticación.
Si en el momento del inicio de sesión arrastras el perfil, el avatar, la bio y otros campos "agradables", complicas la consulta, aumentas la cantidad de posibles causas de fallos y, lo más importante, desdibujas el sentido del modelo de seguridad. La autenticación debe requerir un mínimo de datos: inicio de sesión, hash, roles/authorities, estados de la cuenta.
GO TO FULL VERSION