CodeGym /Cursos /Spring Security /In-memory users: en la BD

In-memory users: en la BD

Spring Security
Nivel 14, Lección 0
Disponible

1. In-memory: etapa y limitaciones

Antes de declarar solemnemente a los usuarios en memoria como «malos» y enviarlos a la jubilación, es importante reconocer sus méritos. Los usuarios en memoria son como las ruedas de entrenamiento en una bicicleta: no te convierten en campeón, pero te dan la oportunidad de llegar a la tienda a por pan sin besar cada bordillo. Es una etapa metodológicamente correcta, y la necesitábamos.

En las primeras lecciones necesitábamos obtener rápidamente un UserDetailsService funcional para ver cómo DaoAuthenticationProvider verifica la contraseña, cómo se llena SecurityContext y cómo SecurityFilterChain toma la decisión «dejar pasar / no dejar pasar». InMemoryUserDetailsManager es literalmente una implementación de UserDetailsService que almacena usuarios en memoria, en una estructura Map, y está diseñada precisamente para ese escenario.

Además, el modelo en memoria es excelente para demostraciones y pruebas locales: ves claramente los usuarios, los roles, las contraseñas (más precisamente, sus hashes) y puedes comprobar manualmente con Postman o el navegador que la zona de editor es realmente de editor y la zona de admin realmente está cerrada para un usuario normal. Con fines educativos, es casi una herramienta ideal.

Pero, como con las ruedas de entrenamiento, hay un momento en que empiezan a estorbar. Y ese momento llega exactamente cuando dejas de construir demos y empiezas a construir un producto.

Cuentas en el código: cuándo es problemático

Cuando los usuarios están descritos directamente en la configuración de Java, parece «conveniente» justo hasta el primer requisito «hazlo como en un servicio real». Mientras tengas un usuario admin y un user, te sientes como un dios. Pero en cuanto aparece la necesidad de gestionar cuentas, los roles empiezan a oler no a libertad, sino a despliegue. Y el despliegue, como sabemos, no es la forma más cómoda de «pedirle a un usuario que cambie su contraseña».

Veamos un ejemplo clásico de aprendizaje:

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Bean
UserDetailsService users() {
    // Con fines educativos, el usuario se crea directamente en la configuración.
    // Esto es conveniente para demos, pero escala mal en un producto (ver texto abajo).
    var user = User.withDefaultPasswordEncoder()
        // El login (username) es por lo que Spring Security buscará la cuenta.
        .username("user")
        // La contraseña se define aquí solo para empezar.
        // Importante: withDefaultPasswordEncoder() es un atajo “getting started”, no para producción.
        .password("password")
        // Los roles son la futura “matriz de acceso” (USER/EDITOR/ADMIN, etc.).
        .roles("USER")
        .build();

    // InMemoryUserDetailsManager almacena las cuentas en la memoria del proceso.
    return new InMemoryUserDetailsManager(user);
}

Ilustra perfectamente la idea, pero de inmediato vemos dos problemas. Primero, la cuenta es parte del código. Segundo, withDefaultPasswordEncoder() es un atajo «getting started» que el propio Spring Security considera inseguro para producción y recomienda usar solo para demos/inicios.

E incluso si ya te has «corregido» y usas un PasswordEncoder normal, sigue existiendo un aspecto fundamental: la cuenta vive en el repositorio de código. Esto significa que cualquier cambio en la lista de usuarios, sus roles o su estado se convierte en una cadena «editar código → commit → CI → despliegue». Para la lógica de producto, esto suena más o menos así: «para bloquear a un usuario, hay que lanzar un release». Es gracioso, pero dan ganas de llorar.

También hay un problema más mundano: en cuanto las cuentas pasan al código, inevitablemente empiezas a pensar «bueno, es un proyecto educativo, se puede hacer así». Es un hábito peligroso. Security es un área donde un mal hábito se afianza rápido y cuesta mucho deshacerse de él.

Memoria del proceso ≠ almacenamiento: amnesia

La palabra «in-memory» suena casi como «in-money» (es una buena metáfora: el dinero también tiende a desaparecer). Básicamente significa una cosa simple: los datos viven en la memoria RAM del proceso. El proceso tiene un bug extremadamente desagradable para el almacenamiento de datos: a veces muere. Y a veces incluso según un horario, por ejemplo durante un despliegue.

En el modelo in-memory puedes realizar acciones que parecen «gestión de usuarios», pero en realidad es una ilusión temporal. Supongamos que «actualizas» una contraseña o «cambias» roles (InMemoryUserDetailsManager tiene métodos de gestión porque implementa UserDetailsManager). Te alegras, verificas, funciona. Pero luego la aplicación se reinicia y vuelves a la Edad de Piedra: todo vuelve a lo que estaba en el código.

En condiciones reales, los reinicios son normales. Ocurren por releases, por caídas, por cambios de configuración, a veces simplemente porque así es la vida. Y si la base de usuarios se «resetea» con cada reinicio, entonces no es una base de usuarios, sino un decorado.

Hay una segunda capa: en cuanto la aplicación empieza a vivir en más de una instancia (no necesariamente «microservicios», simplemente dos instancias detrás de un balanceador), los usuarios en memoria se vuelven «locales» para cada instancia. Una instancia «conoce» al usuario, la otra no. Y esto ya no es un caso educativo, sino una clase real de problemas que te encuentras de repente, en el momento más inoportuno.

Por lo tanto, la conclusión principal de esta sección es muy simple y muy de ingeniería: la memoria RAM es un lugar para cálculos y cachés, pero no para entidades de dominio que deben vivir mucho tiempo. Las cuentas son precisamente ese tipo de entidades.

2. Requisitos del producto en esta etapa

En las primeras etapas del curso, nuestro proyecto tenía suficientes tres «personajes»: USER, EDITOR y ADMIN. Y está bien: estábamos aprendiendo a construir reglas de acceso y verificar que realmente funcionan. Pero el dominio Secure Content Platform API está diseñado de forma que los usuarios no son un decorado, sino una parte clave del sistema: hay autores, editores, administradores, y cada uno tiene sus áreas de responsabilidad.

Cuando empiezas a ver el proyecto como un producto (aunque sea educativo, pero un producto), los requisitos cambian como por arte de magia. Necesitas cuentas que sobrevivan a reinicios. Necesitas poder cambiar roles no a través del IDE, sino como una «operación de gestión». Necesitas tener la capacidad de desactivar una cuenta, bloquearla o restringir temporalmente el acceso. Necesitas que el usuario tenga un perfil que pueda cambiar, y que esos cambios no se conviertan en un riesgo de «tocar» accidentalmente el núcleo de seguridad.

Para que esto se vea no solo a nivel de lemas, conviene verlo como una tabla de requisitos y sus consecuencias:

Requisito del producto Qué significa para el backend Por qué in-memory falla
Las cuentas sobreviven a reinicios El usuario son datos, no código Tras un reinicio, todo vuelve al estado codificado
Gestión de roles El rol se cambia como una operación, no como un commit Se necesita un despliegue para «otorgar admin»
Bloqueo/desactivación La cuenta tiene un estado «puede/no puede entrar» En el código esto o falta o se vuelve un caos
Seed transparente para aprendizaje Debe haber usuarios iniciales para probar escenarios En el código los hay, pero sigue siendo «vida dentro de la aplicación»
Perfil y contenido del usuario La cuenta no es solo un login, sino también una relación con datos El almacenamiento en memoria no se convierte en «verdad» para el dominio

Esto no significa que in-memory «no deba usarse nunca». Sigue siendo una herramienta excelente para pruebas y demos locales. Pero en cuanto quieres hacer del usuario parte del modelo de dominio y apoyarte en él en escenarios de negocio, in-memory deja de ser un soporte.

De ahí el límite práctico: de momento no necesitamos reconstruir todo el flujo de login sobre la BD. Primero es importante fijar el modelo de cuenta, los roles y el punto de almacenamiento, para que DaoAuthenticationProvider tenga una fuente de datos normal. Cuando esa parte sea estable, ya se podrá conectar a la autenticación estándar de username/password sin tener que reinventar todo el modelo de seguridad.

3. Mudanza a la BD: qué permanece igual

Parte invariable: matriz de acceso y flujo de auth

Un error muy común de los principiantes es percibir la migración a la BD como «ahora hacemos un nuevo modelo de seguridad». Y aquí queremos decir suave pero firmemente: no, no estás reinventando la seguridad. Estás cambiando la fuente de datos del usuario. La mecánica de autenticación y autorización sigue siendo la misma que ya montamos y verificamos.

En términos de Spring Security, DaoAuthenticationProvider (que en nuestro modelo mental se encarga de username/password) sigue usando UserDetailsService + PasswordEncoder. La única diferencia es que en lugar de InMemoryUserDetailsManager le damos un UserDetailsService que lee al usuario de la capa de persistencia.

Por eso es apropiado dibujar este esquema (a nivel de arquitectura, sin detalles de almacenamiento):

flowchart TD
    R[HTTP request] --> FC[SecurityFilterChain]
    FC --> AP[DaoAuthenticationProvider]
    AP --> UDS[UserDetailsService]
    UDS --> STORE[(User store)]
    STORE --> UDS
    AP --> SC[SecurityContext]
    SC --> C[Controller]

En la variante in-memory, «User store» es simplemente un Map en memoria. En la variante con BD, «User store» es una base de datos, pero para el resto del contorno de seguridad sigue siendo un «User store» que sabe responder a la pregunta: «dame el usuario por login».

Lo mismo ocurre con la matriz de acceso. Tus reglas del tipo «/api/public/** accesible para todos» y «/api/admin/** solo para ADMIN» a nivel de SecurityFilterChain no tienen por qué cambiar solo porque el usuario ahora se almacena en la BD. No reescribimos las matemáticas solo porque hayamos cambiado de calculadora.

Límite de responsabilidad: almacenamiento de cuentas

Cuando pasas de in-memory a persistencia, lo más valioso que debes conservar es la disciplina de separación de responsabilidades. La capa de seguridad no debería saber «cómo está organizada la BD». Debería saber que hay una forma de obtener el usuario, su contraseña (en forma de hash) y sus roles. Eso es todo. Si la seguridad empieza a «entender las tablas», se convierte en un pastel de capas difícil de mantener.

Precisamente por eso es útil distinguir mentalmente dos niveles. El primer nivel es Spring Security y su contrato: UserDetailsService devuelve UserDetails, y luego DaoAuthenticationProvider hace su trabajo. El segundo nivel es tu persistencia: repositorio, base de datos, datos semilla, migraciones y demás «vida terrenal» de los datos.

Por cierto, Spring Security muestra directamente que «mudarse a la BD» es un escenario normal y estándar: incluso hay una implementación lista JdbcUserDetailsManager que crea usuarios en la base y los lee de allí. En nuestro proyecto educativo, probablemente iremos no a través del gestor JDBC puro, sino a través de nuestro propio modelo de cuenta, pero la idea es la misma: hay que cambiar el almacenamiento, no reflashear todo el motor de seguridad.

De ahí el límite metodológico de hoy: no estamos convirtiendo el curso de seguridad en un curso de JPA. Nos interesa no «cómo escribir una migración perfecta», sino «cómo hacer que los usuarios sean datos reales del proyecto, no cadenas en un archivo de configuración».

Mini-ejemplos: desde hardcoded hasta abstracción

Para que la transición no parezca un salto a lo desconocido, es útil ver el «puente»: una abstracción mínima que nos separa de la forma concreta de almacenar usuarios. Y sí, ahora parecerá casi trivial. Pero en el desarrollo backend, las abstracciones triviales a menudo resultan ser las más útiles (y las más costosas si no se hacen a tiempo).

Primero tenemos el enfoque in-memory, donde el usuario aparece directamente en el código:

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Bean
InMemoryUserDetailsManager users() {
    // Aquí el admin está “incrustado” en la configuración: solo es válido como punto educativo.
    // Importante: withDefaultPasswordEncoder() no es un enfoque para producción.
    UserDetails admin = User.withDefaultPasswordEncoder()
        .username("admin")     // Login del administrador
        .password("password")  // Contraseña (en modo demo)
        .roles("ADMIN")        // Rol que luego se usará en las reglas de acceso
        .build();

    // El gestor almacena usuarios en la memoria del proceso — al reiniciar se olvida todo.
    return new InMemoryUserDetailsManager(admin);
}

Una vez más: esto es normal como punto educativo temporal, pero withDefaultPasswordEncoder() es solo «getting started», no un modelo para un proyecto real.

Aclaro de inmediato: esto no es una nueva capa obligatoria del proyecto ni un requisito para arrastrar al código una interfaz con ese nombre exacto. Solo necesitamos un corte transparente de responsabilidad: security pide una cuenta por login, y la forma de almacenamiento se puede cambiar sin reescribir toda la mecánica.

Ahora veamos la idea de «usuario → datos». Necesitamos un punto donde viva la responsabilidad de «encontrar una cuenta por login»:

import java.util.Optional;

public interface UserAccountStore {
    // Buscamos una cuenta por login: esto es exactamente lo que necesita la capa de autenticación.
    Optional<UserAccount> findByUsername(String username);

    // Guardamos/actualizamos una cuenta: en la BD será INSERT/UPDATE, en memoria put en Map.
    void save(UserAccount account);
}

Incluso si aún no sabes cómo será UserAccount (conscientemente no entramos en el modelo de datos en esta lección), ya ves algo importante: la cuenta se convierte en un objeto de dominio, no en una línea de configuración. Y lo más importante: creas un límite que luego se podrá implementar mediante una BD sin reescribir toda la aplicación.

Para hacer esta idea aún más tangible (y no asustarse con las palabras «capa de persistencia»), puedes incluso escribir temporalmente una «pseudo-BD» con Map — puramente como transición educativa:

import java.util.Map;
import java.util.Optional;

public class MapUserAccountStore implements UserAccountStore {
    // “Almacenamiento” en memoria: útil para la etapa de transición y para pruebas.
    private final Map<String, UserAccount> accounts;

    public Optional<UserAccount> findByUsername(String username) {
        // Devolvemos Optional para mostrar explícitamente: el usuario puede no existir.
        return Optional.ofNullable(accounts.get(username));
    }
}

Sí, sigue siendo in-memory. Pero ahora in-memory no son «usuarios en el config», sino una implementación temporal del almacenamiento. En un proyecto real, este papel lo tomará tranquilamente un repositorio JPA, y la implementación con Map es útil precisamente como puente educativo entre usuarios hardcodeados y persistencia. Y esto supone una gran diferencia en la mentalidad: estás preparando el proyecto para que la fuente de datos se pueda sustituir, mientras el resto del sistema sigue siendo predecible.

Y por último: observa que tus reglas de acceso (por ejemplo, SecurityFilterChain) con esta refactorización no tienen por qué cambiar en absoluto. Por eso la transición a persistencia no es un día de «reescribimos todo», sino un día de «hacemos que los usuarios sean datos».

4. Errores típicos al migrar a la BD

Error nº 1: intentar «forzar» in-memory con más usuarios.
Parece que si añades diez usuarios más a InMemoryUserDetailsManager, el problema desaparece. En realidad, solo estás agrandando el problema: en lugar de «un par de cuentas de prueba» obtienes una «mini-base de usuarios codificada en Java» que luego da miedo tocar.

Error nº 2: cambiar todo a la vez.
Si en un mismo día cambias la fuente de usuarios, las reglas de URL, los roles, el método de login y la estructura de paquetes, pierdes la relación causa-efecto. El enfoque correcto de hoy: solo cambia la fuente de las cuentas. La matriz de acceso y la lógica de «quién puede qué» deben permanecer iguales; de lo contrario no sabrás qué se ha roto exactamente.

Error nº 3: «por un momento» olvidar el PasswordEncoder y guardar la contraseña en texto plano en el seed/BD.
Normalmente se explica con la frase «bueno, luego la codificaremos de todas formas». En seguridad, esto suena como «bueno, ahora voy sin cinturón, pero luego me lo pondré». Si te permites una vez almacenar una contraseña en texto plano, ya has construido un sistema de coordenadas incorrecto.

Error nº 4: convertir la migración a la BD en una epopeya de JPA.
Sí, la capa de datos es importante, pero hoy no entrenamos Hibernate, sino disciplina de diseño: los usuarios son datos del proyecto y deben existir independientemente del código de la aplicación. Si te ahogas en «entidades perfectas y migraciones», puede que no notes que el límite de responsabilidad nunca apareció.

Error nº 5: mezclar la capa de seguridad y los detalles de almacenamiento (tablas/SQL/campos) en un mismo montón.
La capa de seguridad debe conocer el contrato: «dame el usuario, su hash de contraseña y sus roles». En cuanto la seguridad empieza a «entender las tablas», se convierte en un pastel de capas difícil de testear y aún más difícil de modificar.

1
Tarea
Spring Security, nivel 14, lección 0
Bloqueada
Abstracción del almacenamiento de cuentas
Abstracción del almacenamiento de cuentas
1
Tarea
Spring Security, nivel 14, lección 0
Bloqueada
Endpoint REST sobre store
Endpoint REST sobre store
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION