1. UserAccount como núcleo de seguridad
Si simplificamos a lo cotidiano, UserAccount no es «el usuario como persona», sino «el usuario como pase de acceso al sistema». Cuando una solicitud llega a la aplicación, Spring Security debe responder a varias preguntas de forma muy rápida y muy inequívoca: ¿cómo se llama este usuario en el sistema?, ¿se le puede dejar entrar siquiera?, y ¿cuáles son sus permisos básicos? Todo lo demás — «bio», avatar, color favorito de botones — no tiene relación con estas decisiones, por mucho que queramos almacenarlo todo en una bonita clase User.
Es útil tener en cuenta que antes usábamos usuarios en memoria como un banco de pruebas educativo. Allí el usuario se creaba con un builder, los roles estaban al lado, la contraseña estaba codificada y todo funcionaba. Pero desde el punto de vista del producto, este enfoque implica que las cuentas «viven» dentro de la aplicación, como muebles clavados al suelo: reinicias el servicio y los muebles vuelven a estar exactamente en los mismos clavos. En la realidad, la cuenta debe vivir en los datos, sobrevivir a los reinicios, ser modificada por el administrador, bloquearse, desactivarse y, sin embargo, no requerir modificar la configuración de Java.
Para no perderse, podemos imaginarnos UserAccount como la respuesta mínima a la pregunta: ¿qué necesita exactamente Spring Security para tomar una decisión? Y aquí nos ayuda el ya conocido contrato UserDetails: tiene getUsername(), getPassword(), así como indicadores como isEnabled() e isAccountNonLocked(). Nuestra tarea es diseñar campos en la BD de modo que estos indicadores no sean un «adorno», sino participantes reales en el flujo de inicio de sesión.
A continuación se muestra un esquema muy simplificado de cómo UserAccount participa en la decisión de autenticación (sin profundizar en los detalles de la carga desde la BD, porque ahora estamos diseñando el modelo):
flowchart TD
A["Entrada: username + password"] --> B["Buscar cuenta por username"]
B --> C["Verificar passwordHash mediante PasswordEncoder"]
C --> D{"¿enabled y accountNonLocked?"}
D -->|sí| E["Autenticación exitosa: roles disponibles para autorización"]
D -->|no| F["Inicio de sesión denegado: cuenta desactivada o bloqueada"]
2. Campos de UserAccount: mínimo
Al diseñar una entidad, especialmente en los primeros pasos, siempre surge la tentación de «añadir un poquito más». Hoy añadimos displayName (¿y qué?), mañana timezone, pasado mañana favoriteQuoteFromLinuxManual, y de repente la entidad de seguridad se parece a una maleta donde se mete todo sin orden. Esto perjudica la legibilidad y empeora la seguridad: cuantos más campos tenga UserAccount, mayor será la probabilidad de que un día, sin querer, se devuelva «demasiada» información en la respuesta. Por eso ahora necesitamos un conjunto de campos que responda exactamente a las preguntas de seguridad y a los escenarios administrativos básicos.
A continuación se muestra un práctico «mapa de campos» que puede considerarse como una lista de verificación. Señalo expresamente «para qué sirve el campo», porque los campos sin razón rápidamente se convierten en un cementerio de ideas.
| Campo | Tipo | Para qué sirve específicamente a la capa de seguridad |
|---|---|---|
| id | Long | Identificador técnico en la BD, útil para relaciones (perfil, contenido, archivos). |
| username | String | Identificador principal (por el que buscamos la cuenta al iniciar sesión). |
| String | Contacto e invariante de unicidad separada; no lo mezclamos con username. | |
| passwordHash | String | Almacenamos el hash, no la contraseña en texto plano. Es la «contraseña» desde la perspectiva de Spring Security. |
| enabled | boolean | Si la cuenta puede realizar un inicio de sesión normal (acceso al sistema). |
| accountNonLocked | boolean | Si la cuenta no está bloqueada (a menudo como reacción a infracciones/acciones administrativas). |
| roles | Set<Role> | Permisos generales: USER, EDITOR, ADMIN. |
| createdAt | Instant | Cosa pragmática: cuándo se creó la cuenta (auditoría, depuración, panel de administración). |
El punto más importante de esta tabla es el nombre passwordHash. No es cosmético ni un «como lo llames, así navegarás». Si el campo se llama password, el cerebro (especialmente el de un novato después de tres noches sin dormir) podría decidir un día que está bien poner ahí la contraseña en texto plano. Pero si el campo se llama passwordHash, nos recuerda cada vez: «amigo, aquí no hay contraseña, aquí hay un hash, tranquilízate».
Esqueleto mínimo en Java (de momento sin anotaciones JPA, solo para ver la forma):
import java.util.Set;
public class UserAccount {
// Identificador técnico del registro en la BD (no es el "login de negocio").
Long id;
// Aquello por lo que Spring Security normalmente encuentra al usuario al iniciar sesión (principal).
String username;
// Contacto y unicidad separada: no mezclar con username.
String email;
// IMPORTANTE: esto es exactamente el hash/contraseña codificada, no la cadena en texto plano.
String passwordHash;
// Cuenta "activada" (el administrador puede desactivarla sin eliminar el registro).
boolean enabled;
// Cuenta no bloqueada (eje separado de los roles).
boolean accountNonLocked;
// Conjunto de roles para la autorización después de una autenticación exitosa.
Set<Role> roles;
}
Sí, se ve muy «simple». Y eso es bueno. En un buen modelo de seguridad, la simplicidad no es señal de pobreza, sino de control.
3. passwordHash: almacenamiento de la contraseña
Los principiantes tienen una lucha interna eterna entre «quiero que funcione» y «quiero que sea seguro». Las contraseñas son el lugar donde esta lucha se pierde más a menudo en unos treinta minutos, especialmente si la fecha límite ya te susurra al oído. Pero en nuestro curso ya acordamos: la contraseña vive solo en forma codificada, y PasswordEncoder es el canal único a través del cual la contraseña se convierte en algo que se puede almacenar. Ahora simplemente trasladamos esta disciplina del mundo en memoria al mundo respaldado por BD.
Es importante entender una cosa simple: passwordHash no es una «cadena secreta en la base de datos» que nadie verá. En primer lugar, las bases de datos a veces «se filtran» (y esto ocurre más a menudo de lo que parece). En segundo lugar, los registros (logs) a veces «se filtran» (y eso también duele). En tercer lugar, los volcados de la base de datos a veces alguien los pone en el chat del equipo «para ayudar» (y eso es un tipo especial de folklore). Por lo tanto, el modelo debe ser tal que incluso en el peor de los casos, la contraseña en texto plano no aparezca en los datos.
Y una razón práctica más: Spring Security en el modelo de username/password espera que el usuario tenga una «contraseña» (formalmente getPassword() de UserDetails). Pero no es una «contraseña», sino una contraseña codificada. Por lo tanto, passwordHash es lo que participará en la verificación a través de PasswordEncoder.matches(...).
Un pequeño ejemplo que muestra la idea correcta: la contraseña de entrada es texto plano, pero en UserAccount solo entra el hash.
import java.util.Set;
import org.springframework.security.crypto.password.PasswordEncoder;
public UserAccount createDemoUser(String username, String rawPassword, PasswordEncoder encoder) {
// rawPassword vive solo aquí: lo convertimos en hash y no lo arrastramos más por el sistema.
String hash = encoder.encode(rawPassword);
// En el modelo de dominio guardamos solo el resultado de encode(...), no la contraseña original.
return UserAccount.active(username, username + "@example.com", hash, Set.of(Role.USER));
}
Observa el punto psicológicamente importante: rawPassword vive exactamente el tiempo necesario para ser hasheado. A partir de ahí no debe viajar por la aplicación como un turista sin visa.
4. Estados: enabled y accountNonLocked
Un error muy típico en proyectos de novatos: «si el usuario tiene el rol ADMIN, debe tener acceso a todo». Suena lógico… hasta que surge la tarea de «bloquear la cuenta de un administrador porque su contraseña se ha filtrado». Y entonces resulta que el rol trata sobre permisos, mientras que el estado de la cuenta trata sobre el acceso al inicio de sesión normal. Son dos ejes diferentes y deben mantenerse separados; de lo contrario, o no podrás bloquear la cuenta, o empezarás a bloquear los permisos de formas extrañas como «quitar el rol ADMIN pero dejarlo como USER».
El indicador enabled responde a la pregunta: «¿Está esta cuenta activa en el sistema?». A veces se parece a un «interruptor»: el administrador lo apagó, el usuario ya no puede iniciar sesión, pero el registro en la base de datos permanece. Esto es útil para mantener la lógica del dominio y la auditoría: la cuenta existió, pero está desactivada.
El indicador accountNonLocked responde a un escenario más «coercitivo»: la cuenta puede estar activa, pero bloqueada temporalmente. La mayoría de las veces es una medida administrativa o una reacción a un comportamiento sospechoso. En esencia, no es un «rol» ni un «permiso», es un estado de seguridad.
Para que la idea sea más tangible, es útil añadir al modelo un pequeño método que exprese la regla de inicio de sesión como una oración legible. Aunque este método no sea llamado directamente por Spring Security (que tiene sus propios mecanismos), te ayuda a pensar correctamente:
public boolean canUseNormalLogin() {
// El "inicio de sesión normal" solo es posible si la cuenta está activada y no bloqueada.
// Los roles no entran aquí: los roles se verifican después de la autenticación exitosa.
return enabled && accountNonLocked;
}
Y aquí aparece un excelente efecto «antimágico»: ves con tus propios ojos que incluso un ADMIN no tiene por qué poder acceder si enabled == false o la cuenta está bloqueada. Los roles no entran en este método porque los roles se refieren al acceso después de la autenticación exitosa.
Si hacemos una analogía, roles son «qué puertas puedes abrir dentro del edificio», mientras que enabled/accountNonLocked son «si te dejan entrar al edificio». Y sí, a veces la seguridad no deja pasar ni al director. Especialmente si el director olvidó su credencial e intenta demostrar que es el director diciendo «¡si yo construí todo esto!».
5. Invariantes de UserAccount
La palabra «invariante» suena como un hechizo de fantasía matemática, pero en la práctica es simplemente una regla del tipo: «si esta regla se viola, el sistema se rompe o deja de ser seguro». En las cuentas, las invariantes son especialmente importantes porque el flujo de autenticación se basa en ellas. Si tienes dos usuarios con el mismo username, o si passwordHash de repente se vuelve null, entonces comienzan las aventuras del tipo «¿por qué tenemos a veces 500, a veces 401 y a veces fantasmas en los registros?».
Hay dos capas de invariantes: las que verificas en el código (validación a nivel de servicio) y las que fijas en la BD (restricciones). A nivel fundamental, es importante al menos entender que la BD debe ayudar y no ser un «almacén de cadenas sin opinión». De lo contrario, tarde o temprano te encontrarás con una situación en la que la aplicación verificó la «unicidad» en el código, pero una solicitud paralela logró insertar un duplicado, y todo se fue al traste.
¿Qué invariantes para UserAccount parecen más «inquebrantables»?: username debe ser único y no null. El email también debe ser único y no null (si has decidido que el email es obligatorio). passwordHash debe ser no null, porque de lo contrario es imposible verificar la contraseña. Además, enabled y accountNonLocked deben tener valores por defecto claros. En un proyecto educativo, suele ser conveniente comenzar con enabled = true y accountNonLocked = true, para que la cuenta pueda acceder por defecto y el bloqueo/desactivación sean eventos explícitos.
Si mostramos esto a nivel de JPA de forma muy breve (sin «sumergirnos en ORM»), la idea se ve así:
import jakarta.persistence.*;
@Entity
@Table(name = "user_accounts")
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // PK técnico: necesario para relaciones y referencias estables a la cuenta.
@Column(nullable = false, unique = true)
private String username; // Invariante para el flujo de inicio de sesión: username obligatorio y único.
}
Esta no es la clase completa, solo la idea: «username obligatorio y único». Ese fragmento de anotaciones no es «magia de JPA», sino una forma de fijar la invariante donde debe estar: en la estructura de datos.
6. Roles: Set<Role> y límites
Cuando almacenamos roles, a veces queremos hacerlo «rápido»: poner la cadena "ADMIN" en un campo role y listo. Pero la realidad es un poco más multifacética: una cuenta puede tener varios roles (por ejemplo, USER + EDITOR), y esto no es una «historia compleja de empresa», sino algo perfectamente normal incluso en un proyecto educativo. Por lo tanto, en el modelo, los roles son un conjunto, no una sola cadena.
Además, los roles no deben «suplantar» los estados de la cuenta. Si la cuenta está bloqueada, no debe poder iniciar sesión independientemente de los roles. Si la cuenta está desactivada, tampoco debe poder iniciar sesión. Y viceversa: si la cuenta está activa, los roles determinan lo que podrá hacer dentro del sistema. Así se evita el caos cuando el bloqueo se implementa «quitando el rol» y de repente el usuario accede como USER cuando en realidad no debería acceder en absoluto.
A nivel del modelo Java, esto se ve extremadamente simple:
import java.util.Set;
public class UserAccount {
private String username; // Identificador principal: por él encontramos la cuenta al iniciar sesión.
private Set<Role> roles; // Los roles son un conjunto: sin orden y sin duplicados.
}
Y sí, Set aquí no es un capricho. Previene «dos roles iguales en el conjunto» y además refleja bien el significado: los roles son un conjunto, no una lista ordenada.
El principio práctico más importante: el rol es un lenguaje de grano grueso, no reemplaza ni el estado de la cuenta, ni la propiedad, ni los permisos detallados. Pero dentro del paso de hoy, nos importa otra cosa: los roles deben existir como parte de la cuenta, porque de lo contrario el panel de administración (/api/admin/**) estaría gestionando «algo inexistente».
7. Marcado JPA mínimo de UserAccount
Cuando aparece una BD, en algunos desarrolladores se despierta el arquitecto interior con ganas de construir el Imperio Romano: tablas separadas, catálogos, migraciones, relaciones complejas. Esto es normal… pero hoy hacemos otro género: un modelo mínimo suficiente para que la seguridad pueda apoyarse en usuarios reales. Por lo tanto, las anotaciones JPA aquí no son para embellecer ni para un «enterprise correcto», sino para fijar: esto es una entidad, tiene una tabla y los campos portan invariantes.
Lo más útil que se puede hacer en este momento es marcar cuidadosamente los campos clave y darles nombres de columna claros. Es especialmente importante el nombre para passwordHash, para que no se convierta en «password» en el esquema de la BD y no provoque errores.
Ejemplo de marcado mínimo para varios campos (de nuevo: breve y al grano):
import jakarta.persistence.*;
@Entity
@Table(name = "user_accounts")
public class UserAccount {
@Column(name = "password_hash", nullable = false, length = 200)
private String passwordHash; // Almacenamos solo el hash/contraseña codificada (no texto plano).
@Column(nullable = false)
private boolean enabled = true; // Valor por defecto: cuenta activada, la desactivación es una acción explícita.
}
Dos comentarios importantes. Primero, length = 200 no es un «número mágico», sino un margen seguro para los hashes (bcrypt, por ejemplo, normalmente cabe, pero un margen no viene mal). Segundo, los valores por defecto a nivel de campo ayudan a mantener el modelo en un estado adecuado incluso antes de empezar a escribir servicios completos de gestión de cuentas.
Y un punto más, directamente relacionado con la seguridad, aunque parezca «solo arquitectura»: no devuelvas UserAccount hacia el exterior como respuesta de API. La entidad es un modelo de almacenamiento, no un modelo de contrato público. Para la API, es mejor construir una representación segura que no contenga passwordHash en absoluto.
Por ejemplo, un DTO de vista mínimo se puede hacer con un record:
// DTO para la API: proyección segura de la cuenta sin passwordHash.
public record UserAccountView(
Long id,
String username,
String email,
boolean enabled,
boolean accountNonLocked
) {}
Que sea aburrido: es un cumplido. Los DTO aburridos suelen ser más seguros que los «objetos bonitos universales».
8. Errores típicos al trabajar con UserAccount
Error nº 1: campo password con contraseña en texto plano.
Esto ocurre no porque el desarrollador sea malo o tonto, sino porque el cerebro ahorra energía. Si el campo se llama password, en algún lugar de las pruebas o datos de inicialización la mano escribirá automáticamente "qwerty". El nombre passwordHash recuerda cada vez que hay que almacenar el resultado de PasswordEncoder, no la cadena original.
Error nº 2: mezclar roles y estados de la cuenta en un solo «estado».
A veces se crea un campo status = "ADMIN_ACTIVE" o status = "LOCKED_USER", y luego toda la lógica se convierte en parsear cadenas y usar switch con «valores mágicos». A este nivel, es mucho más saludable mantener los roles separados (roles) y los estados separados (enabled, accountNonLocked). Así, bloquear una cuenta no requiere «repensar» los roles, y los roles no rompen la posibilidad de inicio de sesión.
Error nº 3: almacenar roles en una sola cadena separada por comas.
"USER,ADMIN" parece una solución rápida, hasta que te encuentras con espacios, mayúsculas/minúsculas, duplicados y de repente "USER,ADMIN,". Luego aparece un analizador, luego un segundo, luego un tercero… y de repente has escrito un mini-framework «CSV Roles Security Edition». Set<Role> resuelve el 90% de este dolor incluso antes de empezar.
Error nº 4: falta de unicidad de username y email a nivel de BD.
Verificar «si ya existe ese username» en el código es útil, pero sin una restricción de unicidad en la BD, un día obtendrás dos inicios de sesión iguales debido a solicitudes concurrentes. Para la seguridad, esto es especialmente desagradable porque «quién inició sesión exactamente» se convierte en una pregunta no filosófica, sino muy práctica. La unicidad no es una opción, es un fundamento.
Error nº 5: devolver UserAccount como JSON «por comodidad».
En algún momento dan ganas de escribir return userAccount; desde el controlador y «luego refactorizarlo bonito». La práctica demuestra que «luego» puede llegar seis meses después, una vez que los datos ya se han filtrado en los registros, en la colección de Postman del equipo y en los hábitos. Es más correcto separar desde el principio: la entidad para el almacenamiento, el DTO para la API. E incluso si el DTO parece «burocracia innecesaria», es una burocracia que un día te ahorrará muchos nervios.
GO TO FULL VERSION