CodeGym /Cursos /Spring Security /Mapeo de UserAccount a UserDetails

Mapeo de UserAccount a UserDetails

Spring Security
Nivel 15, Lección 1
Disponible

1. Propósito del mapeo UserAccount → UserDetails

Después de buscar la cuenta en la base de datos, queda la parte más importante de todo el esquema basado en DB: toUserDetails(account). El método es pequeño, pero aquí es donde decidimos qué datos verá DaoAuthenticationProvider: login, hash de la contraseña, flags de estado de la cuenta y authorities. Si este paso está mal construido, la autenticación puede funcionar, pero los permisos y estados comenzarán a comportarse como una lotería.

Para ser honestos, el mapeo parece algo cotidiano: tomar campos de un objeto y pasarlos a otro. Pero en seguridad no es «pasar», sino «preparar el pase de entrada al edificio», con el cual el guardia de seguridad (Spring Security) dejará pasar o rechazará al usuario. Y si el pase está mal hecho, la culpa no es del guardia, él solo sigue las instrucciones.

Spring Security deliberadamente no sabe nada sobre sus entidades JPA, tablas, repositorios y «cómo lo modelamos todo de forma bonita». Solo conoce el contrato: necesita un objeto UserDetails que contenga el login, la contraseña (más precisamente, su hash), el conjunto de autoridades (authorities) y los flags de estado de la cuenta. Precisamente por eso UserDetailsService es utilizado por DaoAuthenticationProvider para obtener el nombre, la contraseña y atributos adicionales del usuario durante la autenticación con username/password.

Si intenta «engañar al sistema» y devolver desde UserDetailsService algo aleatorio, por ejemplo, su entidad JPA completa, obtendrá una mezcla de problemas: desde datos innecesarios en memoria hasta sorpresas inesperadas de lazy-loading. Por eso creamos un puente simple, aburrido y fiable: UserAccount (dominio/BD) → UserDetails (modelo de seguridad).

Un pequeño esquema para tener en mente el flujo de datos:

flowchart TD
    DB[(PostgreSQL)]
    Repo[UserAccountRepository]
    UDS[CustomUserDetailsService]
    UD[UserDetails]
    DAP[DaoAuthenticationProvider]

    DB --> Repo --> UDS --> UD --> DAP

2. UserDetails: pasaporte, UserAccount: cuenta

Un error muy común de los novatos es percibir UserDetails como «otro modelo de usuario». Entonces la mano tiende a poner allí de todo: displayName, bio, avatarPath, el color favorito del botón «Guardar», y para rematar, todo el historial de publicaciones. Pero por su significado, UserDetails es una huella mínima de seguridad que Spring Security necesita para tomar una decisión sobre el inicio de sesión y los permisos.

Si observa el contrato de UserDetails, es bastante honesto sobre lo que necesita: getUsername(), getPassword(), getAuthorities(), así como los flags isEnabled(), isAccountNonLocked(), isAccountNonExpired(), isCredentialsNonExpired(). Todo esto no es «para decorar». Son los datos que participan en la decisión de si se puede autenticar al usuario y qué authorities asignarle.

Es útil tener esta comparación en forma de tabla. No se trata de «cómo se hace correctamente en todo el mundo», sino del sentido común en nuestro proyecto educativo:

Qué es UserAccount (BD / dominio) UserDetails (vista de seguridad) Para qué
Nombre de usuario username o
email
getUsername() Identificar al usuario en el flujo de autenticación
Contraseña passwordHash getPassword() El proveedor comparará la contraseña introducida mediante PasswordEncoder
Permisos roles (y más tarde, posiblemente, permissions) getAuthorities() Proporcionar a Spring Security los datos para
hasRole
/
hasAuthority
Estado enabled,
accountNonLocked
isEnabled(),
isAccountNonLocked()
Denegar el inicio de sesión a una cuenta bloqueada/deshabilitada
Perfil displayName,
bio
,
avatarPath
no es necesario El perfil no participa en la verificación de la contraseña ni en la concesión de authorities

La idea principal: UserAccount es «cómo se almacena el usuario», y UserDetails es «qué necesita la capa de seguridad en este momento». Cuando mantiene esta separación, el código no solo se vuelve más seguro, sino también más fácil de mantener.

3. Login y contraseña: username y passwordHash

En este punto uno quiere decir «bueno, esto es obvio», pero las estadísticas de errores dicen lo contrario: es precisamente en estos dos campos donde la gente comete los errores más sonados. Aquí es importante entender: UserDetailsService devuelve datos que ya están en el sistema, no lo que el usuario introdujo en el formulario de inicio de sesión. Lo que ingresó el usuario irá a un objeto separado Authentication como credentials, y luego DaoAuthenticationProvider lo comparará con lo que devolvió UserDetailsService.

Es decir, UserDetails#getPassword() es un hash que ya guardamos antes en la base de datos. El proveedor comparará la contraseña en texto plano (ingresada) con la contraseña codificada (de la BD) mediante PasswordEncoder.matches(...). No hacemos esto manualmente, de lo contrario perderíamos el sentido de DaoAuthenticationProvider.

Una mini-implementación del mapeo «login + hash de contraseña» normalmente se ve así:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

private UserDetails toUserDetails(UserAccount account) {
    // En el contrato de seguridad, username es el identificador de inicio de sesión (qué exactamente: login/correo — lo decide el proyecto)
    return User.withUsername(account.getUsername())
            // IMPORTANTE: aquí ponemos no la contraseña en texto plano, sino el hash de la base de datos (encoded password)
            .password(account.getPasswordHash())
            // Las authorities se adjuntan al usuario en la fase de carga de UserDetails
            .authorities(roleNames(account.getRoles()))
            .build();
}

Aquí no solo importa la forma del código, sino el significado. Elegimos account.getUsername() como lo que será el «nombre de usuario» en el contexto de seguridad, y ponemos passwordHash como contraseña. Sí, la palabra password en el builder parece que estamos poniendo la contraseña en texto plano, pero en realidad es «password used to authenticate» — en nuestro caso, precisamente el hash.

Y otro detalle sutil: el nombre del método loadUserByUsername le provoca pensar que el inicio de sesión siempre es por username. En realidad, es solo un nombre histórico en la API. En esta cadena puede ir tanto el email como el login, o «lo que se decida como identificador de inicio de sesión». Simplemente dentro del proyecto debe ser consistente. No elegimos entre username y email aquí mismo (ese es tema de otra discusión), pero el mapeo debe ser coherente con el criterio por el que se busca en el repositorio.

4. Estados de la cuenta: enabled y accountNonLocked

Cuando empiezas a trabajar con usuarios reales en la base de datos, rápidamente descubres que una cuenta puede existir pero no tener derecho a iniciar sesión. Por ejemplo, fue desactivada por el administrador, o está bloqueada. Esto no es «lógica de negocio en alguna parte después», sino un requisito directo para la autenticación: a estos usuarios no se les puede autenticar, incluso si la contraseña es correcta.

UserDetails contiene directamente los métodos isEnabled() y isAccountNonLocked(), que participan en la decisión de si se puede autenticar al usuario. Y la clase User (el famoso org.springframework.security.core.userdetails.User) soporta estos flags en su constructor y modelo.

Y aquí comienza la trampa clásica: en el builder de User, a menudo encontrará métodos en forma negativa:

- disabled(true/false) — no enabled(...)
- accountLocked(true/false) — no accountNonLocked(...)

Por lo tanto, en el código aparecen honestas «dobles negaciones» que al principio parecen una cucaracha en la sopa, pero luego se convierten en algo normal:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

private UserDetails toUserDetails(UserAccount account) {
    return User.withUsername(account.getUsername())
            .password(account.getPasswordHash())
            // En la base de datos enabled=true significa «se puede iniciar sesión», y en el builder — disabled=false
            .disabled(!account.isEnabled())
            // En la base de datos accountNonLocked=true significa «no bloqueado», y en el builder — accountLocked=false
            .accountLocked(!account.isAccountNonLocked())
            // Los permisos (authorities) deben estar listos para usarse en hasRole/hasAuthority
            .authorities(roleNames(account.getRoles()))
            .build();
}

Aquí hacemos dos cosas que son muy fáciles de confundir. Primero, disabled(!enabled): si la cuenta en la BD está deshabilitada, entonces disabled(true). Segundo, accountLocked(!accountNonLocked): si en la BD accountNonLocked=false, entonces en el builder establecemos accountLocked(true).

En User.UserBuilder, estos métodos existen y se llaman exactamente así (disabled, accountLocked), así que no es «magia personalizada» — es el modelo estándar de Spring Security.

Sobre los otros dos flags (accountNonExpired, credentialsNonExpired) no nos complicamos ahora: en el proyecto educativo, en esta fase, se pueden dejar con valores por defecto (es decir, «no expirado»). Lo principal es pasar correctamente enabled y locked, porque son estados que realmente se usan en nuestro dominio.

5. Roles: authorities y ROLE_

En este momento muchos piensan: «Bueno, tenemos roles en la BD, eso es suficiente». Pero Spring Security toma decisiones de autorización basándose en GrantedAuthority. Y los roles son solo una de las variantes de authorities (generalmente con el prefijo ROLE_). En la documentación se dice claramente que hasRole es un atajo para hasAuthority, que añade ROLE_ (u otro prefijo configurado).

De esto se desprende una idea desagradable pero útil: si olvida el prefijo, puede tener el rol ADMIN en la BD, pero en tiempo de ejecución el usuario tendrá la autoridad "ADMIN", y sus reglas están escritas como hasRole("ADMIN"), y obtendrá «por algún motivo 403». Y ese «por algún motivo» tendrá exactamente cuatro caracteres: ROLE_.

El enfoque más simple y predecible es almacenar los roles en el código como un enum, y al hacer el mapeo, crear las cadenas de authorities con el prefijo explícito:

import java.util.Set;

private String[] roleNames(Set<Role> roles) {
    return roles.stream()
            // Spring Security por defecto espera los roles como authorities con el prefijo ROLE_
            .map(role -> "ROLE_" + role.name())
            .toArray(String[]::new);
}

Así obtenemos "ROLE_USER", "ROLE_EDITOR", "ROLE_ADMIN" — exactamente lo que esperan muchos mecanismos de Spring Security.

¿Por qué es importante a nivel de UserDetailsService? Porque las authorities generalmente se «adjuntan» al usuario en la fase de carga de UserDetails. Y luego estas authorities pasan a Authentication y son utilizadas por la autorización. En Spring Security, las authorities se consideran permisos de alto nivel, y los roles son uno de los ejemplos estándar de dichos permisos.

Otro consejo práctico: no disperse la lógica de transformación de roles por todo el proyecto. En cuanto empiece a hacer "ROLE_" + ... en tres lugares, aparecerá un cuarto lugar donde se olvidará de hacerlo. Y volverá a obtener «por algún motivo 403», pero en un nivel más sofisticado.

6. Práctica del mapeo

Dónde mantener el mapeo

El código de mapeo no es la parte del proyecto que uno quiera leer por la mañana con un café. Pero precisamente esta parte debe ser lo más aburrida y predecible posible. Un buen mapeo es como un buen cinturón de seguridad: se recuerda solo en el momento de un accidente, y entonces ya es tarde para descubrir que lo atornilló a la guantera.

En nuestro caso, la opción más práctica es mantener el mapeo en un método private separado, junto a loadUserByUsername(). De esta forma, el propio loadUserByUsername() se mantiene corto: encontró la cuenta, no la encontró — lanzó UsernameNotFoundException, la encontró — la tradujo a UserDetails. Y el mapeo es una función pequeña separada, sin efectos secundarios.

Si intenta incrustar el mapeo directamente en el repositorio, «el repositorio de repente comienza a saber sobre seguridad». Si lo intenta en la configuración, la configuración de seguridad se infla. Si en el controlador… bueno, no traumatice a los controladores, ya han sufrido bastante.

Y sí, este es ese raro caso en el que el método toUserDetails(account) es un nombre excelente. No pretende ser filosófico, dice honestamente: «traduzco a la representación de seguridad».

Ejemplo: loadUserByUsername y toUserDetails

Ahora armemos el rompecabezas: el repositorio devuelve UserAccount, CustomUserDetailsService lo convierte en UserDetails, y luego Spring Security hace su trabajo. Lo principal es mantener este código lo más lineal posible, para que pueda leerlo en estado semiconsciente y aun así entender lo que sucede (este es un requisito real para el código de seguridad, porque los problemas suelen surgir los viernes por la noche).

Aquí está la versión compacta de loadUserByUsername(), que ya se ve como «de producción», no como un stub:

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@Override
public UserDetails loadUserByUsername(String username) {
    return repo.findByUsername(username).map(this::toUserDetails)
            .orElseThrow(() -> new UsernameNotFoundException(username));
}

Observe lo «sin magia» que es esto. No hay verificación manual de la contraseña. No hay if(hash.equals(raw)). No hay intentos de resolver la autorización. Simplemente cargamos los datos y los entregamos en el contrato esperado. Es precisamente este estilo el que hace que UserDetailsService sea un buen participante en el vínculo con DaoAuthenticationProvider.

Y aquí está el famoso toUserDetails(account), que fija todo lo importante: el hash de la contraseña, los estados de la cuenta y las authorities:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

private UserDetails toUserDetails(UserAccount account) {
    return User.withUsername(account.getUsername())
            .password(account.getPasswordHash())
            .disabled(!account.isEnabled())
            .accountLocked(!account.isAccountNonLocked())
            .authorities(roleNames(account.getRoles()))
            .build();
}

Si este método está hecho correctamente, obtendrá un comportamiento del sistema muy comprensible. Un usuario deshabilitado no podrá autenticarse, incluso con la contraseña correcta. Un usuario bloqueado tampoco. Un usuario sin el rol necesario iniciará sesión, pero obtendrá una denegación de acceso donde se requiera otro rol. Y eso es exactamente lo que buscamos: previsibilidad.

7. Errores típicos en el mapeo UserAccount → UserDetails

En este punto es útil admitir honestamente: aquí habrá errores. No porque usted sea un mal desarrollador, sino porque a la seguridad le encantan las «pequeñas cadenas que lo deciden todo». La buena noticia es que estos errores casi siempre se repiten, y puede aprender a reconocerlos por su olor.

Error nº1: Poner en UserDetails la contraseña en texto plano en lugar de passwordHash.
Esto parece especialmente engañoso si prueba con un solo usuario y «parece que funciona». En realidad, está rompiendo todo el modelo: DaoAuthenticationProvider espera una contraseña codificada del almacén de usuarios y la compara mediante PasswordEncoder. Una contraseña en texto plano en la base de datos o en UserDetails ya no es un atajo, sino una vulnerabilidad real y un error arquitectónico.

Error nº2: Confundir «locked» y «non locked».
Cuando ve accountNonLocked en la BD, y en el builder — accountLocked, el cerebro intenta hacerlo «de alguna manera». El resultado es que accidentalmente bloquea a todos los usuarios o, por el contrario, permite el acceso a los bloqueados. La regla correcta es simple: si en la BD accountNonLocked=false, entonces en el builder hay que poner accountLocked(true).

Error nº3: Olvidar que hasRole espera el prefijo ROLE_.
Almacena roles en la BD como ADMIN, devuelve la autoridad "ADMIN", y en las reglas usa hasRole("ADMIN"). Entonces comienza el juego favorito del novato «por qué 403, si soy admin». La respuesta: es admin en la BD, pero no ROLE_ADMIN en Spring Security.

Error nº4: Devolver un conjunto vacío de authorities y considerarlo normal sin una decisión explícita.
Técnicamente, un usuario puede existir con roles vacíos, pero entonces casi siempre se comportará de forma extraña: inicia sesión, pero «no se puede hacer nada». A veces esto es un modelo válido (por ejemplo, un administrador asigna el rol más tarde), pero entonces debe ser una decisión consciente y un estado bien diagnosticable. De lo contrario, perderá tiempo buscando «dónde se cayó la autorización», cuando en realidad se cayó a nivel de datos.

Error nº5: Arrastrar campos de perfil a UserDetails.
displayName y avatarPath parecen inofensivos, pero gradualmente convierte la capa de seguridad en un almacén de todo. Esto infla el contexto, complica la serialización, puede traer dependencias inesperadas y, en general, rompe la separación entre UserAccount y UserProfile. UserDetails debe mantenerse compacto.

1
Tarea
Spring Security, nivel 15, lección 1
Bloqueada
Mapeo del rol EDITOR en authorities
Mapeo del rol EDITOR en authorities
1
Tarea
Spring Security, nivel 15, lección 1
Bloqueada
Mapeo de estados enabled y accountNonLocked
Mapeo de estados enabled y accountNonLocked
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION