BACKEND. Spring Security: Conceptos y estrategias

En este artículo profundizamos en los conceptos y estrategias clave para asegurar aplicaciones Java con Spring Security, el framework líder en autenticación y autorización. Desde su arquitectura modular hasta la configuración avanzada, descubrirás cómo implementar reglas de acceso, gestionar roles y autoridades, y personalizar filtros para proteger recursos críticos. Además, abordamos prácticas esenciales como la protección frente a ataques comunes (CSRF, CORS), el uso de JWT para autenticación sin estado y la integración con OAuth2 y OpenID para delegar credenciales de forma segura. Con ejemplos claros y recomendaciones prácticas, aprenderás a aplicar seguridad a nivel de API y métodos, optimizando la protección en entornos empresariales y microservicios. Una guía imprescindible para desarrolladores que buscan robustez, flexibilidad y escalabilidad en sus aplicaciones.

    Artículo elaborado por:

    Fran García

    Software Engineer

    Desarrollador full stack con experiencia en Java-SpringBoot y Angular. Graduado en Ingeniería Informática y Doctor en Biología. Interesado en la implementación y aplicación de soluciones tecnológicas que impulsen la innovación y mejora continua.

    Miguel Feliu

    Software Engineer

    Desarrollador backend con experiencia en Java-SpringBoot y Python. Graduado en Ingeniería Informática, interesado en las nuevas posibilidades que ofrece la IA y en continuo aprendizaje.


Contenidos

  1. Introducción a Spring Security y su arquitectura
  2. Configuración de Spring Security
  3. Autorización y roles
  4. Filtos en Spring Security
  5. Seguridad en métodos
  6. CORS y CSRF
  7. JWT
  8. OAuth2 y OpenID: Ejemplo completo con SpringSecurity
  9. Conclusiones

Asegurar que sólo usuarios autorizados puedan acceder a ciertas partes de una aplicación es fundamental para proteger datos sensibles dentro de un contexto empresarial. Spring Security es un potente framework que proporciona autenticación, autorización y protección frente a ataques comunes. En este artículo se tratarán diversos aspectos de este framework, desde los mecanismos básicos de autenticación y autorización hasta el uso de estrategias más avanzadas para mejorar la seguridad de las aplicaciones.

1. Introducción a Spring Security y su arquitectura

Spring Security es un marco poderoso y flexible diseñado para proporcionar autenticación, autorización y otras características de seguridad en aplicaciones Java. Su arquitectura está basada en una serie de componentes modulares que interactúan entre sí para garantizar la protección de los recursos de la aplicación. Haciendo uso del siguiente esquema ilustraremos los principales elementos de la arquitectura de Spring Security y servirá como base para entender el flujo de trabajo y los componentes clave del sistema.

Figura 1. Arquitectura básica de Spring Security

En primer lugar, supongamos que un usuario intenta acceder a una API protegida sin proporcionar credenciales. En este caso, el sistema lo redirigirá a una página de inicio de sesión donde podrá ingresar sus credenciales. Sin embargo, consideremos el escenario en el que el usuario proporciona credenciales válidas en la solicitud mientras intenta acceder a una API protegida. Aquí, los filtros de Spring Security son los encargados de procesar la autenticación.

La primera tarea de estos filtros es convertir las credenciales de la solicitud HTTP servlet en un objeto Authentication. Este objeto contiene campos como el username, password y un campo booleano llamado isAuthenticated, que indica si el usuario está autenticado. Cuando el objeto Authentication se crea por primera vez, el campo isAuthenticated se establece en false. Este mismo objeto Authentication se pasa y utiliza por otros componentes dentro del marco de Spring Security.

Una vez que el objeto Authentication está poblado, los filtros reenvían la solicitud al AuthenticationManager. El AuthenticationManager es un componente central responsable de completar el proceso de autenticación. Determina si la autenticación es exitosa o no y comunica el resultado de vuelta a los filtros. Para realizar la autenticación real, el AuthenticationManager delega la solicitud a una o más instancias de AuthenticationProvider. Estos providers son componentes especializados dentro del marco de Spring Security que manejan la lógica de autenticación. 

Aunque Spring Security proporciona varias implementaciones integradas de AuthenticationProvider, los desarrolladores también pueden definir providers personalizados para cumplir con requisitos específicos del proyecto. Un proyecto puede tener múltiples instancias de AuthenticationProvider, y es el AuthenticationManager quien asegura que todos los providers aplicables sean verificados para determinar el resultado de la autenticación. La interfaz AuthenticationProvider incluye el método authenticate(), que recibe y devuelve un objeto de autenticación, y el método supports(), que devuelve true si el AuthenticationProvider actual es compatible con el tipo de objeto de autenticación proporcionado.

Cuando un AuthenticationProvider recibe el objeto Authentication, su primera tarea es cargar los detalles del usuario basándose en el nombre de usuario proporcionado. Para lograr esto, el AuthenticationProvider se basa en una implementación de UserDetailsService o UserDetailsManager. Estos componentes son responsables de recuperar información del usuario, como el nombre de usuario, contraseña y roles, desde un sistema de almacenamiento como una base de datos o almacenamiento en memoria. Más específicamente, la interfaz UserDetailsService define un único método abstracto responsable de cargar los detalles del usuario desde un sistema de almacenamiento, mientras que la interfaz UserDetailsManager`, que extiende `UserDetailsService, introduce métodos adicionales como `createUser()`, `updateUser()`, `deleteUser()`, `changePassword()` y `userExists()`. La separación entre interfaces existe para proporcionar flexibilidad. Por ejemplo, la interfaz UserDetailsManager es indicada para escenarios donde se requiere una funcionalidad completa de gestión de usuarios.

En la Figura 2 se muestra una implementación del UserDetailsService personalizada donde el nombre de usuario recuperado del objeto Authentication se utiliza como argumento para el método loadUserByUsername() definido en la interfaz UserDetailsService. La finalidad de este método es obtener los detalles del usuario correspondientes desde el sistema de almacenamiento.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = userRepository.get(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found: " + username);
        }
        return user;
    }
}

Figura 2. Implementación personalizada del UserDetailsService que sobrescribe el método loadUserByUsername para recuperar el usuario del repositorio

Una vez que los detalles del usuario se cargan con éxito se envían de vuelta al AuthenticationProvider. Sin embargo, el AuthenticationProvider no compara directamente la contraseña proporcionada por el usuario con la almacenada en el sistema. En su lugar, delega esta tarea a un PasswordEncoder, que es un componente especializado diseñado para manejar la comparación de contraseñas.

El framework de Spring Security ofrece numerosas implementaciones de la interfaz PasswordEncoder, desde texto plano hasta basadas en diversos algoritmos de hash proporcionados por la industria, como Bcrypt, Scrypt, Argon2 y PBKDF2. Si las contraseñas coinciden, el AuthenticationProvider actualiza el campo isAuthenticated en el objeto Authentication a true e informa al AuthenticationManager que la autenticación fue exitosa. Si las contraseñas no coinciden, el proceso de autenticación falla, y el AuthenticationManager es notificado en consecuencia.

Independientemente de si la autenticación es exitosa o no, los filtros de Spring Security almacenan el objeto Authentication en el SecurityContext. Este SecurityContext asocia el objeto Authentication con un ID de sesión creado para el navegador del usuario. Si el usuario intenta acceder al mismo recurso protegido desde el mismo navegador en solicitudes posteriores, los filtros de Spring Security recuperan el objeto Authentication del SecurityContext utilizando el ID de sesión. Esto elimina la necesidad de invocar nuevamente al AuthenticationManager, ya que los detalles de autenticación ya están disponibles en el SecurityContext.

Finalmente, se envía una respuesta de vuelta a la aplicación cliente. Si la autenticación es exitosa, el cliente recibe el recurso solicitado o un mensaje de éxito. Por otro lado, si la autenticación falla, el cliente recibe una respuesta de error, como un código de estado HTTP 401 Unauthorized o 403 Forbidden.

Este flujo completo asegura que el proceso de autenticación sea seguro, eficiente y reutilizable en múltiples solicitudes. También destaca la modularidad del marco de Spring Security, permitiendo a los desarrolladores personalizar componentes como AuthenticationProvider, UserDetailsService y PasswordEncoder para adaptarse a sus necesidades específicas.


2. Configuración de Spring Security

Por defecto, Spring Security protege todas las rutas dentro de una aplicación web. Este comportamiento está definido en el método defaultSecurityFilterChain() de la clase SecurityFilterChainConfiguration y es el que se puede observar en la Figura 3. El método crea un bean de tipo SecurityFilterChain y acepta como input un objeto de tipo HttpSecurity. Es justamente este objeto a través del que se configura la seguridad de las peticiones, específicamente a través del método authorizedHttprequest.

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
        http.formLogin(withDefaults());
        http.httpBasic(withDefaults());
        return http.build();
    }
}

Figura 3. Configuración por defecto de la seguridad de las peticiones en Spring Security

Además, en la configuración por defecto se habilitan dos mecanismos de autenticación:

  1. FormLogin(), que permite al usuario ingresar sus credenciales a través de un formulario
  2. HttpBasic(), que permite al usuario enviar credenciales en los headers de la solicitud HTTP, codificada en base64.

Por supuesto se podrán configurar otros estilos de logins más avanzados, como el OAuth2 login. En definitiva, podemos personalizar el comportamiento de Spring Security, definiendo un método similar para crear y configurar el bean de SecurityFilterChain.

Por ejemplo, en ciertos escenarios, es posible que queramos permitir el acceso a un conjunto específico de APIs sin restricciones de seguridad. Para estos casos, podemos utilizar el método permitAll() (ver Figura 4). De manera opuesta, también existe el método denyAll(), que se puede usar para bloquear el acceso a APIs específicas. Adicionalmente, en lugar de usar anyRequest(), que permite aplicar la política de restricción a todas las llamadas del API, podemos utilizar el método requestMatchers(), que posibilita especificar múltiples rutas de API como entrada, proporcionando una mayor flexibilidad al definir reglas de acceso para endpoints específicos.

Un caso especial que vale la pena mencionar es la ruta/error. Por defecto, el framework Spring Security redirige a los usuarios a la página de error siempre que ocurre una excepción en tiempo de ejecución. Sin embargo, esta página de error también está protegida por defecto, lo que significa que requiere autorización adecuada para acceder. Para asegurarnos de que podamos ver las excepciones en tiempo de ejecución en cualquier momento, es importante permitir explícitamente el acceso a la página de error para todos. De esta manera, podemos depurar e identificar problemas dentro de nuestra aplicación con facilidad.

http.authorizeHttpRequests((requests) -> requests
    .requestMatchers("/public/**", "/error").permitAll()
    .requestMatchers("/admin/**").denyAll()
    .anyRequest().authenticated());

Figura 4. Ejemplo de configuración de reglas de autorización de solicitudes HTTP

Asegurar tu aplicación con HTTPS es esencial para proteger la confidencialidad e integridad de los datos intercambiados entre clientes y servidores. HTTPS cifra los datos transmitidos, previniendo eficazmente el acceso no autorizado y los ataques de intermediarios. Spring Security ofrece una integración fluida para imponer exclusivamente el tráfico HTTPS en tu aplicación. A continuación, en la Figura 5, se presentan las configuraciones de Spring Security para restringir el tráfico únicamente a HTTPS. Si necesitas permitir tráfico HTTP en su lugar, puedes usar el método requiresInsecure() en lugar del método requiresSecure().

http.requiresChannel(rcf ->
    rcf.anyRequest().requiresSecure())
    .authorizeHttpRequests...

Figura 5. Configuración en Spring Security para que todas las solicitudes HTTP entrantes utilicen un canal seguro (HTTPS)

Para gestionar las sesiones inválidas de manera efectiva, los usuarios pueden ser redirigidos a una URL o página específica que proporcione información sobre el tiempo de espera de la sesión. El método sessionManagement() permite personalizar la configuración de gestión de sesiones, donde se utiliza una expresión lambda para definir el comportamiento, incluyendo la configuración de la URL para redirigir a los usuarios cuando su sesión se vuelve inválida (ver Figura 6).

Por otro lado, Spring Security, por defecto, no impone restricciones sobre la cantidad de sesiones concurrentes que un usuario puede tener. Sin embargo, es posible que se desee limitar a los usuarios a tener solo una sesión activa a la vez. Mediante el método maximumSessions() dentro de la configuración de sessionManagement() es posible definir el número máximo de sesiones permitidas. Además, si un usuario inicia una segunda sesión, la primera sesión se invalida automáticamente. Se puede modificar este comportamiento para evitar completamente la creación de una segunda sesión llamando al método `maximumSessionsPreventsLogin()` pasando `true` como argumento. Esto asegura que los usuarios estén restringidos a una sola sesión y no puedan iniciar sesiones adicionales.

http.sessionManagement(sessionConfig ->
    sessionConfig
        .invalidSessionUrl("/invalid-session")
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true));

Figura 6. Ejemplo de configuración de la gestión de sesiones en Spring Security

Si un cliente intenta acceder a un recurso sin estar autenticado o autorizado, se le debe solicitar que proporcione credenciales para obtener acceso. Esto se puede lograr redirigiendo al cliente a una página de inicio de sesión en la interfaz de usuario o enviando una respuesta de API para informarle del problema. En Spring Security, las excepciones relacionadas con la autenticación (AuthenticationException) y la autorización (AccessDeniedException) son gestionadas por el ExceptionTranslationFilter. La interfaz AuthenticationEntryPoint es la responsable de manejar los intentos de acceso no autenticados, decidiendo cómo responder cuando alguien accede a la API sin autenticación. Para crear una implementación personalizada de AuthenticationEntryPoint es necesario sobrescribir el método commence() tal y como se observa en la figura 7.

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setHeader("custom-reason", "Authentication Required");
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication Required");
    }
}

Figura 7. Clase personalizada que implementa la interfaz AuthenticationEntryPoint

A continuación, esta implementación personalizada del AuthenticationEntryPoint se debe configurar para un determinado flujo de autenticación, como por ejemplo el flujo HTTP Basic (ver Figura 8), o aplicarse globalmente a todos los tipos de inicio de sesión.

http.httpBasic(hb -> hb
    .authenticationEntryPoint(new CustomAuthenticationEntryPoint()));

Figura 8. Ejemplo de personalización del comportamiento de Spring Security cuando existe un error de autenticación

Por otro lado, AccessDeniedHandler es una interfaz en Spring Security que se utiliza para manejar situaciones en las que un usuario intenta acceder a un recurso protegido sin los permisos necesarios. Cuando ocurre un error de acceso denegado (403 Forbidden), esta interfaz permite personalizar la respuesta que se envía al cliente. En este caso, un AccessDeniedHandler personalizado puede crearse sobrescribiendo el método handle() y configurarse utilizando exceptionHandling().

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setHeader("custom-reason", "Access Denied");
        response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
    }
}

Figura 9. Clase personalizada que implementa la interfaz AccessDeniedHandler

Como se muestra en la Figura 10, mediante el método exceptionHandling, se debe configurar el AccessDeniedHandler personalizado para los casos en que un usuario autenticado intenta acceder a un recurso sin los permisos necesarios.

http.exceptionHandling(exh -> exh
    .accessDeniedHandler(new CustomAccessDeniedHandler()));

Figura 10. Configuración de Spring Security para gestionar el control sobre los errores de autorización

Spring Security proporciona eventos relacionados con el proceso de autenticación y autorización permiten realizar auditorías, monitoreo o lógica personalizada. Los principales eventos de autenticación incluyen AuthenticationSuccessEvent para autenticaciones exitosas y AbstractAuthenticationFailureEvent para fallos en la autenticación.  Mediante el uso de la anotación @EventListener de Spring, es posible estar a la escucha tanto los eventos de éxito como los de fallo en la autenticación. En la Figura 11 se expone un ejemplo de una clase que permite registrar y manejar eventos de éxito de fallo de autenticación.

@Component
public class AuthenticationEventListener {
    @EventListener
    public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
        Authentication authentication = event.getAuthentication();
        System.out.println("Authentication successful for user: " + authentication.getName());
    }
    @EventListener
    public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
        Authentication authentication = event.getAuthentication();
        AuthenticationException exception = event.getException();
        System.out.println("Authentication failed for user: " + authentication.getName());
        System.out.println("Reason: " + exception.getMessage());
    }
}

Figura11. Componente de Spring encargado de escuchar eventos relacionados con la autenticación en la aplicación

De igual manera, existen eventos de autorización permiten capturar y manejar acciones relacionadas con el acceso a recursos protegidos. En este sentido la clase abstracta AbstractAuthorizationEvent sirve como base para eventos más específicos, como AuthorizationFailureEvent y AuthorizedEvent. Estos eventos se disparan cuando un intento de autorización falla o es exitoso, respectivamente.


3. Autorización y roles

Una vez verificada la identidad de los usuarios como parte del proceso de autenticación se procede con la autorización para permitir el acceso al sistema. Esto implica validar las autoridades, privilegios o roles del usuario para alcanzar un recurso específico. Durante la autenticación, los sistemas solicitarán las credenciales de inicio de sesión del usuario, mientras que, en la autorización, se requerirán los privilegios del usuario. En caso de que la autenticación falle, es común devolver un error HTTP 401 como respuesta. Sin embargo, si ocurre un fallo en la autorización, el estándar es retornar un código de error HTTP 403.

En Spring Security, los requisitos de autoridad pueden configurarse usando los métodos hasAuthority() o hasAnyAuthority(). Mientras que el primero permite configurar un endpoint validando el usuario contra una autoridad especifica, el segundo lo permite validar contra cualquiera de las autoridades especificadas y, solo los usuarios que tengan al menos una de las autoridades configuradas, podrán acceder al endpoint.

Existe una tercera forma de configurar los requisitos de autoridad y es a través del método access(). Este formato hace uso del lenguaje de expresiones de Spring (SpEL) y ofrece una flexibilidad ilimitada para configurar autoridades, permitiendo combinaciones que no son posibles con los métodos anteriores. Se pueden usar operadores como OR y AND dentro de este método. En la Figura 12 se observa las reglas que determinan qué permisos o combinaciones de permisos son necesarios para acceder a ciertas rutas. Por ejemplo, se especifica que, cualquier solicitud que coincida con el patrón “/user/**” requiere que el usuario tenga la autoridad USER_READ.

http.authorizeHttpRequests((requests) -> requests
    .requestMatchers("/user/**").hasAuthority("USER_READ")
    .requestMatchers("/admin/**").hasAnyAuthority("ADMIN_READ", "ADMIN_WRITE")
    .requestMatchers("/custom/**").access(new WebExpressionAuthorizationManager(
        "hasAuthority('ADMIN_READ') && hasAuthority('ADMIN_WRITE')")
    )
);

Figura 12. Configuración de reglas de autorización específica para diferentes rutas en la aplicación

Spring Security proporciona mecanismos para aplicar la autorización utilizando tanto autoridades como roles. Aunque son similares, existe una pequeña diferencia entre ambos. Las autoridades en Spring Security representan privilegios o acciones específicas que un usuario puede realizar dentro del sistema. Están vinculadas a permisos u operaciones individuales. Los roles, por otro lado, son una colección o agrupación de múltiples autoridades, que representan niveles de acceso más amplios o conjuntos de acciones. La agrupación de autoridades en roles permite simplificar la configuración de los privilegios sobre los recursos del sistema.

Para diferenciar entre autoridades y roles, Spring Security requiere que los roles tengan un prefijo común, típicamente ROLE_. Esta convención ayuda a distinguir los roles de las autoridades individuales en la configuración. De forma similar a los requisitos de autoridad, Spring Security ofrece varios métodos para configurar el control de acceso basado en roles. Los métodos hasRole(), hasAnyRole(), que validan a un usuario contra un único o varios roles, respectivamente, y access() que permite configuraciones más avanzadas de roles usando SpEL. En la Figura 13 se muestra un ejemplo de las reglas que determinan qué roles debe tener el usuario para acceder a una determinada ruta.

http.authorizeHttpRequests((requests) -> requests
    .requestMatchers("/admin/**").hasRole("ROLE_ADMIN")
    .requestMatchers("/user/**").hasAnyRole("ROLE_USER", "ROLE_ADMIN")
    .requestMatchers("/custom/**").access(new WebExpressionAuthorizationManager(
        "hasRole('ROLE_ADMIN') and hasAuthority('MANAGER_PRIVILEGE')")));

Figura 13. Ejemplo de configuración para determina que sólo los usuarios con determinados roles pueden acceder a rutas protegidas


4. Filtros en Spring Security

Es común en proyectos encontrarse con escenarios donde se necesitan realizar ciertas tareas durante el proceso de autenticación y autorización. Estas tareas pueden incluir actividades como validación, auditoría, etc. Spring Security proporciona una forma de manejar estos requisitos utilizando filtros HTTP.

Un filtro es un componente que intercepta solicitudes, procesa su lógica y pasa la solicitud al siguiente filtro en la cadena. Cada filtro tiene una responsabilidad distinta, y los filtros pueden ser añadidos o eliminados según la configuración. Spring Security utiliza una cadena de filtros conocida como Filter Chain, donde cada filtro tiene una responsabilidad específica. Algunos filtros comunes incluyen:

  • UsernamePasswordAuthenticationFilter: Maneja la autenticación basada en nombre de usuario y contraseña.
  • BasicAuthenticationFilter: Procesa la autenticación HTTP básica.
  • SecurityContextPersistenceFilter: Administra el contexto de seguridad entre solicitudes.
  • ExceptionTranslationFilter: Maneja excepciones relacionadas con la seguridad.

Los filtros se configuran automáticamente en aplicaciones Spring Boot mediante la clase SecurityFilterChain, pero también pueden personalizarse para adaptarse a necesidades específicas.  Los filtros personalizados pueden agregarse a la cadena de filtros en posiciones específicas: antes, después o en la ubicación de un filtro existente mediante los métodos addFilterBefore(filter, class),  addFilterAfter(filter, class) y addFilterAt(filter, class), respectivamente. En el código mostrado en la Figura 14 se personaliza el comportamiento de la cadena de filtros de seguridad haciendo uso de los métodos mencionados anteriormente.

http
    .addFilterBefore(new CustomFilterBefore(), UsernamePasswordAuthenticationFilter.class)
    .addFilterAfter(new CustomFilterAfter(), UsernamePasswordAuthenticationFilter.class)
    .addFilterAt(new CustomFilterAt(), UsernamePasswordAuthenticationFilter.class);

Figura 14. Configuración de filtros personalizados en la cadena de filtros de seguridad de Spring Security


5. Seguridad de métodos

Spring Security proporciona mecanismos de seguridad no solo a nivel de API, sino también a nivel de métodos. La seguridad a nivel de método en Spring Security se utiliza para restringir el acceso a métodos específicos dentro de una aplicación, basándose en roles, permisos o condiciones personalizadas. Esto permite aplicar controles de acceso granulares directamente en los métodos de los servicios o controladores, asegurando que solo los usuarios autorizados puedan ejecutar ciertas operaciones.

Para habilitar la seguridad a nivel de métodos en una aplicación Spring Boot, se debe utilizar la anotación @EnableMethodSecurity y debe establecerse en la clase principal de SpringBoot o en una clase de configuración. La seguridad a nivel de método permite determinar si un usuario está autorizado para ejecutar un método, ya sea antes de su ejecución (pre-autorización) o después de que haya finalizado (post-autorización), utilizando anotaciones como @PreAuthorize y @PostAuthorize para definir las reglas de acceso directamente en el código (ver Figura 15).

@PreAuthorize("hasRole('ADMIN')")
@PostAuthorize("returnObject.username == authentication.principal.name")
public CustomUser getUser(String username) {
    return userRepository.get(username);
}

Figura 15. Ejemplo de método con anotaciones para aplicar reglas de autorización antes y después de su ejecución

Además, se puede aplicar filtrado de parámetros para controlar los datos que se pasan a los métodos o que se devuelven de ellos, siempre que el filterObject implemente la interfaz Collection. Para filtrar parámetros antes de la ejecución del método, se utiliza la anotación @PreFilter. De manera similar, para filtrar parámetros después de la ejecución del método, se aplica la anotación @PostFilter, y el filterObject también debe ser del tipo Collection. La anotación @PostFilter puede utilizarse en métodos de repositorios de Spring Data para filtrar datos no deseados recuperados de la base de datos. En la siguiente figura se puede observar un ejemplo de uso de estas anotaciones donde @PreFilter filtra los elementos de entrada antes de ejecutar el método y solo permite elementos que no sean nulos. La anotación @PostFilter, por otro lado, filtra los elementos de salida después de ejecutar el método y únicamente devuelve los elementos que contengan la palabra “valid”.

@PreFilter("filterObject != null")
public void processItems(List<String> items) {
    items.forEach(item -> System.out.println("Processing: " + item));
}
@PostFilter("filterObject.contains('valid')")
public List<String> getValidItems(List<String> items) {
    return items;
}

Figura 16.  Ejemplo de uso de las anotaciones @Prefilter y @PostFilter en métodos que aceptan y devuelven una lista.


6. CORS y CSRF

En esta sección nos centramos en explicar qué representan los conceptos de CORS y CSRF, qué implicaciones tienen en la seguridad de nuestras aplicaciones con Spring y cómo podemos tratarlos de la mejor forma posible, viendo algún ejemplo.

6.1 CORS

Cross-Origin Resource Sharing o CORS por sus siglas es una característica de seguridad utilizada por los navegadores web para prevenir peticiones con “origen cruzado” no autorizadas. Se basa en la cabecera Origin y con ella el servidor decide si permite la petición o la deniega. Esto permite asegurar que una aplicación web no accede a recursos almacenados en otro dominio.

A continuación, presentamos un ejemplo del uso de un filtro CORS sencillo en SpringBoot:

@Bean("corsFilter")
CorsFilter simpleCorsFilter(@Qualifier("simpleCorsFilter") FilterRegistrationBean<CorsFilter> bean,
                             EdicomPropertiesManager properties) {
    var configuration = new CorsConfiguration();
    configuration.setAllowCredentials(true);
    configuration.setAllowedHeaders(Collections.singletonList(ALLOWED_ALL));
    var allowedString = properties.getProperty(ALLOWED_ORIGINS_KEY);
    var allowedPatternsString = properties.getProperty(ALLOWED_ORIGIN_PATTERNS_KEY);
    String[] allowedOrigins = WebStringUtils.getArrayFromStringByComa(allowedString);
    String[] allowedOriginPatterns = WebStringUtils.getArrayFromStringByComa(allowedPatternsString);
    configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
    configuration.setAllowedOriginPatterns(Arrays.asList(allowedOriginPatterns));
    configuration.setAllowedMethods(Collections.singletonList(ALLOWED_ALL));
    configuration.setExposedHeaders(Collections.singletonList("Content-Disposition"));
}

Figura 17. Configuración del CorsFilter

Usando la implementación CorsFilter de SpringBoot, registramos el filtro en una clase de configuración (ver Figura 17) con la anotación @EnableWebSecurity y aquí podemos configurar tanto los orígenes, los patrones de los orígenes, los métodos permitidos, headers y los endpoints para los que se aplica el filtro. Todo esto lo recuperamos de las propiedades de la aplicación. Por ejemplo, el allowedPatternsString tendría la siguiente forma para una aplicación de Edicom:

”https://*.edicomgroup.com,https://*.sedeb2b.com,https://*.edicom.eu,https://*.businessmail.net”

6.2 CSRF

Por otro lado, el Cross-Site Request Forgery o CSRF es un tipo de ataque donde un sitio web malicioso engaña al navegador para hacer peticiones en otro sitio web donde se está autenticado. Por ejemplo, si un usuario entra en su banco y se autentica, recibe una cookie de sesión. Si no está aplicada la protección CSRF, un sitio malicioso puede provocar que el usuario haga una acción que desencadene, sin darse cuenta, una petición autenticada contra su banco, ya que se mantiene esa cookie.

Por defecto, Spring Security tiene habilitada esta protección, que genera un token único por sesión y lo espera en cada petición modificadora (POST, PUT o DELETE). A veces es necesario deshabilitar (Figura 18) esta protección y es tan sencillo como añadir esta línea en nuestra configuración de seguridad:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable);
    [...]
}

Figura 18. Configuración donde se deshabilita CSRF

Pero ¿Por qué querríamos deshabilitar la protección CSRF? Hay dos casos claros, si la aplicación no mantiene el estado y se autentica por ejemplo con tokens JWT o si es una API pública.


7. JWT

Los tokens JWT o JSON Web Token son tokens compactos y seguros que se utilizan principalmente para transmitir información de forma segura entre las partes involucradas en una aplicación, por ejemplo, para autenticación, entre front y back, etc.

7.1 Estructura de JWT

Los tokens JWT cuentan con una estructura definida por la RFC 7519 y separada en tres partes:

  • Cabecera: Contiene metadatos sobre el tipo de token y de algoritmo usado.
  • Contenido: El propio contenido que incluye la información que se transmite y algunos campos de la entidad. Estos se denominan como “claims” que son piezas de información afirmadas sobre el “subject” o sujeto del token, son pares clave-valor y los nombres deben ser únicos.
  • Firma: Utilizada para verificar que el token no ha sido manipulado y que la información transmitida es íntegra.

Veamos un ejemplo, generando un token con EdicomAccounts y explorándolo con la herramienta online jwt.io:

  • Generamos un JWT válido, en este caso un token de acceso generado con EdicomAccounts para un servicio de prueba y un usuario en concreto.
  • Lo pegamos en la página web, en la parte del debugger y observamos lo siguiente en la Figura 19.

Figura 19. Debugger jwt.io con nuestro token JWT

Se diferencian las tres partes del token y podemos ver los valores decodificados en la parte derecha. En primer lugar, la cabecera que contiene metadatos sobre la encriptación y el tipo de token. Seguidamente vemos el contenido del token, los llamados claims del subject, que en este caso es “wsadmin”.  Además de información básica como dónde y cuándo se ha generado el token, cuándo expira, roles del usuario, etc.

  • Verificamos que el token no ha sido alterado. Usando la clave pública proporcionada en este caso por EdicomAccounts en forma de JWK (JSON Web Key), verificamos que la firma es válida (Figura 20):

Figura 20. Verificación correcta de firma en jwt.io


8. OAuth2 y OpenID: Ejemplo completo con SpringBoot

En esta sección exploraremos brevemente los conceptos clave para entender OAuth2 y OpenID y veremos un ejemplo de su uso con Spring Security.

En primer lugar, OAuth2 es un framework de autorización (responde a ¿Qué puedo hacer?) definido en la RFC6749 que permite a aplicaciones obtener acceso limitado a cuentas de usuarios. Se delega la autenticación al servicio que aloja la cuenta del usuario y este autoriza el acceso de terceras aplicaciones a esa cuenta. Para entender mejor esta interacción debemos definir los autores involucrados:

  • Propietario del recurso (resource owner): El usuario propietario de los datos.
  • Cliente (client): La aplicación que pide acceso.
  • Servidor de autorización (authorization server): Genera tokens de acceso después de autenticar al propietario.
  • Servidor de recursos (resource server): Contiene los datos protegidos.

El flujo más comúnmente utilizado es el “Authorization Code” (Figura 21), veamos un ejemplo:

Figura 21. Esquema del flujo “Authorization Code”

En primer lugar, la aplicación (client) inicia la acción redirigiendo al usuario a autenticarse contra el servidor (A), este decide si acepta o deniega el acceso (B) y, en caso afirmativo, responde con la redirección de vuelta a la aplicación con el Code y el estado anteriormente proporcionado que se verifica en este momento. Seguidamente, la aplicación pide el Access Token mediante el Code, directamente contra el servidor de autorización, incluyendo la URI de redirección como verificación (D). En caso de que esté todo correcto, se retorna el Acces Token (E).

OpenID en su caso gestiona la autenticación (responde a ¿quién es este usuario?) mediante ID tokens, que son JWTs que contienen información de la identidad del usuario.

8.1 Ejemplo completo en SpringSecurity

A continuación, trataremos un ejemplo sencillo que usamos en Edicom con SpringSecurity para disponer de Webservices con cierto nivel de acceso, mediante nuestro servidor de autorización EdicomAccounts.

En primer lugar, configuramos la dependencia de oauth2 (Figura 22) en nuestro pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Figura 22. Dependencia Oauth2 de Spring en pom.xml

Y en las propiedades de la aplicación debemos establecer la URL del JWK (Figura 23) que se usará para validar los tokens de acceso:

spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://accounts.edicomgroup.com/jwk

Figura 23. Configuración URL JWK

También debemos configurar en una clase a parte el filtro (Figura 24) que queremos que se aplique, es decir qué endpoints están protegidos y de qué manera, estableciendo qué roles se les permite acceso o incluso qué IPs.

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public").permitAll()
                .requestMatchers("/api/private").hasAnyRole(ADMIN)
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(new GrantedAuthoritiesExtractor(properties.getProperty(CLIENT_ID_KEY), edicomAccountsManager, log))
                )
            );
        return http.build();
    }
}

Figura 24. Configuración del filtro de seguridad

Además, se establece de qué manera se decodifica el JWT, mediante la implementación de .GrantedAuthoritiesExtractor y en este caso su función extractAuthoritiesCustom que observamos en la Figura 25 y con ella extraemos si el usuario al que hace referencia el token es o no un administrador, comprobando si la información de usuario que viene en el token (ya validado) corresponde con alguno de los usuarios administradores que tenemos registrados.

protected Collection<GrantedAuthority> extractAuthoritiesCustom(Jwt jwt) {
    var isAdmin = false;
    var tokenClientId = jwt.getClaimAsString("azp");
    String user = jwt.getSubject();
    this.log.debug(this, "extractAuthorities", "Recibida petición de usuario " + user);
    try {
        isAdmin = this.clientId.equals(tokenClientId);
        if (!isAdmin) {
            List<String> grantedUsers = this.accountsManager.getAdminUsers();
            isAdmin = grantedUsers.stream()
                .anyMatch(adminUsers -> StringUtils.equals(adminUsers, user));
        }
    } catch (Exception e) {
        this.log.error(this, "extractAuthorities", "Error al obtener los usuarios administradores", e);
    }
    return Collections.singletonList(new SimpleGrantedAuthority((isAdmin) ? ROLE_ADMIN : ROLE_USER));
}

Figura 25. Extractor de autoridades del JWT


9. Conclusiones

Spring Security ofrece un marco coherente y extensible para proteger aplicaciones desde la capa web hasta el nivel de método. La clave es comprender su arquitectura y la cadena de filtros para aplicar defensa en profundidad: reglas HTTP claras, autorización granular e incluso seguridad declarativa con anotaciones. Configurar CORS de forma restrictiva y tratar CSRF según el contexto evita brechas comunes. El uso de JWT, que facilita la transmisión de información de forma segura y compacta entre partes facilitando la autenticaciones y autorización en aplicaciones web. Delegar el acceso seguro a recursos mediante OAuth2, permitiendo a los usuarios autorizar aplicaciones sin compartir sus credenciales y mejorando la escalabilidad en sistemas distribuidos y microservicios. En definitiva, Spring Security permite proteger aplicaciones de manera eficiente, adaptándose a las diferentes necesidades y escenarios de seguridad empresarial.