CodeGym /Cursos /Spring Security /Spring AOP: proxy y la ruta de invocación

Spring AOP: proxy y la ruta de invocación

Spring Security
Nivel 19, Lección 2
Disponible

1. Seguridad de métodos y AOP

En cuanto se activa @EnableMethodSecurity, surge una pregunta legítima: ¿cómo consigue la anotación detener la llamada al método antes de entrar en el código de negocio?

Cuando ves por primera vez @PreAuthorize, piensas de forma natural: «Vale, la anotación está en el método, así que Java de alguna manera hará una comprobación antes de invocar el método». Y aquí está el giro importante: Java no puede hacer eso por defecto. Una llamada a método normal es un salto directo al cuerpo del método. En Java puro no hay nada como «preguntar al guardia antes de entrar». Spring lo añade artificialmente, y lo hace a través de un mecanismo llamado AOP (Programación Orientada a Aspectos).

AOP en Spring se trata de la situación «quiero ejecutar un comportamiento adicional alrededor de la llamada al método». Por ejemplo, comprobar permisos antes del método, registrar un evento después del método, envolver la ejecución en una transacción, etc. Punto importante: AOP no modifica el bytecode de tus clases para siempre, sino que normalmente funciona mediante una envoltura alrededor del objeto.

La seguridad de métodos precisamente requiere un comportamiento alrededor del método: antes de ejecutar el cuerpo del método hay que evaluar la expresión de @PreAuthorize, compararla con el usuario actual, y si la regla no se cumple, ni siquiera iniciar el código de negocio. Es decir, necesitamos un interceptor de llamadas. Y Spring lo resuelve mediante un AOP-proxy: «no recibes el servicio directamente, lo recibes empaquetado».

Una reflexión pequeña pero útil: si entiendes el modelo de proxy de la seguridad de métodos, automáticamente se vuelve más comprensible otra magia de Spring, por ejemplo @Transactional. Aquí solo necesitamos un aspecto de AOP: cómo se intercala la verificación de acceso antes de entrar en el método.

El proxy de Spring en palabras sencillas

La palabra proxy suena como algo del mundo de los dinosaurios corporativos, pero en la práctica es una idea muy simple. Un proxy es un objeto intermediario que para ti parece un servicio, pero en realidad controla la puerta y decide qué hacer con cada llamada a método. Si todo está bien, pasa la llamada al objeto real. Si está mal, detiene la llamada y lanza una excepción o realiza otro comportamiento.

Imagina que el servicio ReviewService es un director que puede «publicar un borrador». Y @PreAuthorize es la regla: «solo dejar pasar al director a quienes tengan el pase draft:publish». El proxy en esta analogía es el secretario del director. Puedes llamar a la puerta todo lo que quieras, pero el secretario primero mirará tu pase. Si no tienes pase, no llegarás al director y no se ejecutará ninguna «publicación».

En Spring esto tiene un efecto práctico: cuando en el código escribes esto:

private final ReviewService reviewService;

dentro del contenedor de Spring, en este campo a menudo no aparece el «puro» ReviewService, sino un objeto proxy que se parece a ReviewService, pero que internamente contiene una referencia al ReviewService real y un conjunto de interceptores (interceptors). Uno de esos interceptores es la seguridad de métodos.

Por eso a veces al hacer logging ves nombres de clase extraños como $$SpringCGLIB$$.... No es porque Spring sea malo. Es porque Spring muestra honestamente: sí, he creado una envoltura.

3. Ruta de invocación de un método protegido

Desde fuera todo parece perfectamente simple: el controlador llama al servicio, el servicio hace su trabajo. Pero cuando la seguridad de métodos está activada, entre la llamada y el cuerpo del método aparece una pista de obstáculos. Y está bien: la seguridad es como el cinturón de seguridad. También molesta, hasta que salva.

Imaginemos nuestro escenario del proyecto: endpoint de publicación de un borrador. El controlador recibe la petición y la delega en el servicio, donde ocurre la operación de negocio. El código, simplificado, podría verse así:

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EditorController {
    // Importante: aquí NO se inyecta necesariamente el servicio "puro", sino a menudo un objeto proxy
    private final ReviewService reviewService;

    EditorController(ReviewService reviewService) {
        // Spring pasará aquí el bean del contexto (y podría ser un proxy)
        this.reviewService = reviewService;
    }

    @PostMapping("/api/editor/drafts/{id}/publish")
    void publish(@PathVariable Long id) {
        // Punto clave de la lección: la llamada va al proxy, y él decide si se puede seguir adelante
        reviewService.publish(id);
    }
}

Y el servicio:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class ReviewService {

    // La regla de seguridad se verifica ANTES de entrar en el método, pero solo si la llamada pasó por el proxy
    @PreAuthorize("hasAuthority('draft:publish')")
    public void publish(Long draftId) {
        // Aquí iría la lógica de negocio de publicación.
        // Si no hay acceso, la ejecución no llegará hasta esta línea.
    }
}

Ahora lo más importante: cuando el controlador llama a reviewService.publish(id), está llamando al método sobre el proxy, no sobre el objeto «desnudo». Y la ruta se convierte aproximadamente en esto, muy simplificado, pero fiel en esencia:

flowchart TD
    A[Petición HTTP] --> B[Controller]
    B --> C[ReviewService proxy]
    C --> D[Interceptor de seguridad de métodos]
    D -->|permitido| E["ReviewService.publish(...) cuerpo del método"]
    D -->|denegado| F["AccessDeniedException / 403"]
    E --> G[Repository / DB]

El proxy llama al interceptor de seguridad. El interceptor toma la autenticación actual del SecurityContext y verifica la regla. Si la regla no se cumple, el método ni siquiera comienza.

Como resultado, se produce un efecto muy práctico: si el usuario no tiene draft:publish, el estado del borrador no cambiará, incluso si alguien accidentalmente abre un segundo endpoint o llama al servicio no a través del controlador esperado. Y eso es exactamente por lo que incorporamos la seguridad de métodos en la capa de servicio.

4. De dónde viene el proxy

Después de entender «entre la llamada y el método hay un proxy», la pregunta lógica es: «Vale, ¿y quién crea ese proxy?» Respuesta: el contenedor de Spring. El proxy aparece solo para aquellos objetos que Spring crea como beans y de los que gestiona su ciclo de vida.

Por eso hay una regla de oro que suena demasiado simple para ser verdad, pero lo es: si creas un servicio manualmente con new, la seguridad de métodos no funcionará. Porque has evitado a Spring, y por tanto has evitado la creación del proxy y la conexión de los interceptores.

Compara los dos enfoques.

Así es normal (Spring crea el bean, lo envuelve con un proxy, tú lo inyectas):

import org.springframework.stereotype.Service;

@Service
class AdminUserService {
    // Spring gestiona este objeto: puede envolverlo en un proxy y añadir interceptores
}

Y así es «parece que funciona» hasta la primera sorpresa:

class SomeUtility {
    void doStuff() {
        // Creado manualmente -> Spring no participó -> no habrá proxies ni interceptores aquí
        AdminUserService s = new AdminUserService(); // no habrá proxy

        // La seguridad de métodos no funcionará, aunque los métodos tengan anotaciones,
        // porque Spring no intercepta llamadas sobre objetos "manuales".
    }
}

En un proyecto real, ciertamente rara vez creas un @Service con new directamente en el controlador. Pero un problema similar aparece de forma más sutil: cuando alguien saca la lógica de negocio a una clase simple sin anotaciones y la crea manualmente, y luego se sorprende de que las anotaciones de seguridad no funcionen.

Otro matiz práctico: Spring AOP, y por tanto la seguridad de métodos, solo intercepta aquellos métodos que se pueden envolver. En el mundo típico del proxy, los métodos private no se interceptan, y los métodos final y las clases final pueden ser un problema, especialmente si el proxy se construye mediante CGLIB. Por lo tanto, «poner @PreAuthorize en un helper privado» es a menudo una estrategia que produce un código bonito, pero una protección nula.

5. Auto-invocación y elusión del proxy

Aquí es donde comienzan los bugs más divertidos. La auto-invocación es la situación en la que un método dentro de una clase llama a otro método de la misma clase. En Java normal es simple: un método llama a otro. Pero en Spring AOP esto significa: la llamada se produce a través de this, es decir, directamente, sin el proxy.

Mostrémoslo con un ejemplo. Supón que proteges publish(...), pero haces otro método que lo llama dentro de la misma clase:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class ReviewService {

    @PreAuthorize("hasAuthority('draft:publish')")
    public void publish(Long draftId) {
        // publicar borrador
        // En un escenario normal, solo se llegará aquí después de la verificación de permisos (si la llamada pasó por el proxy)
    }

    public void publishFromSameClass(Long draftId) {
        // Auto-invocación: la llamada ocurre dentro del objeto a través de this, el proxy no participa
        publish(draftId); // auto-invocación: puede eludir el proxy
    }
}

¿Por qué «puede eludir»? Porque el proxy intercepta las llamadas que pasan a través de él. Y publishFromSameClass(...) llama a publish(...) directamente, como si hubieras escrito this.publish(draftId). Y si algún código llama a publishFromSameClass(...), entonces publish(...) puede ejecutarse sin la verificación de @PreAuthorize — no porque Spring se olvidó, sino porque Spring físicamente no vio esa llamada: ocurría dentro del objeto.

Esta es una lección de ingeniería muy importante: la seguridad de métodos no es magia de anotaciones, sino mecánica de invocación a través de un proxy.

A menudo, la auto-invocación no aparece intencionadamente, sino como resultado de buenas intenciones: intentas reutilizar código, separas parte de la lógica en un método aparte, y de repente la seguridad deja de funcionar. Sin misterio: simplemente moviste la llamada al interior de la clase y saliste accidentalmente de la zona de acción del proxy.

6. Diseño de servicios para seguridad de métodos

Después de la auto-invocación surge la pregunta: «¿Y ahora qué? ¿No se pueden llamar métodos dentro del servicio?» Sí se puede. Solo hay que recordar dónde está el límite que realmente ve el proxy.

Una estrategia práctica y cómoda para nuestra Secure Content Platform API se ve así: protegemos con anotaciones los métodos de entrada públicos, que se llaman desde fuera, normalmente desde controladores u otros servicios, y dejamos los métodos helper internos sin anotaciones de seguridad y los usamos como cocina de negocio pura.

Por ejemplo, así:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class ReviewService {

    @PreAuthorize("hasAuthority('draft:publish')")
    public void publish(Long draftId) {
        // Llamada externa (a través de proxy) -> verificación de permisos -> solo luego lógica interna
        doPublish(draftId); // helper interno sin anotaciones
    }

    void doPublish(Long draftId) {
        // Lógica real de publicación sin anotaciones.
        // Este método solo debe ser llamado desde métodos de entrada ya verificados.
    }
}

Ahora la verificación funcionará con seguridad, porque el código externo llama a publish(...) a través del proxy, el proxy hace la comprobación, y solo entonces llega a doPublish(...).

Si necesitas que dos métodos de entrada diferentes tengan reglas de acceso distintas y ambos deban llamar a una parte común de la lógica, también se resuelve: la parte común sigue siendo un método helper o se extrae en un componente separado sin reglas, si es necesario.

Y si la situación es más compleja y realmente quieres que un método protegido llame a otro método protegido, entonces el camino más sencillo y didácticamente correcto es separar esos métodos en diferentes beans de Spring, para que la llamada entre ellos pase por el proxy.

Por ejemplo, extraemos la mecánica de publicación en un servicio separado:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class PublishService {

    @PreAuthorize("hasAuthority('draft:publish')")
    public void publish(Long draftId) {
        // Aquí está la operación protegida.
        // Importante: solo se verificará cuando se llame a través del proxy (es decir, desde fuera del bean).
    }
}

Y dejamos la orquestación en otro servicio:

import org.springframework.stereotype.Service;

@Service
class ModerationFlowService {
    private final PublishService publishService;

    ModerationFlowService(PublishService publishService) {
        // Se inyecta el bean PublishService, que en realidad puede ser un proxy
        this.publishService = publishService;
    }

    public void approveAndPublish(Long draftId) {
        // Llamada entre diferentes beans -> posibilidad de pasar por el proxy y no eludir la seguridad
        publishService.publish(draftId); // llamada a través de otro bean -> el proxy funciona
    }
}

La idea es simple: mientras la llamada vaya entre dos servicios gestionados por Spring, Spring tiene la oportunidad de intervenir mediante el proxy. Dentro de una misma clase, no hay oportunidad, porque literalmente estás hablando contigo mismo.

A veces es mejor ver una vez que leer diez veces. Y aquí nos ayuda un diagnóstico simple: imprimir en el log la clase del objeto que realmente se inyecta en el controlador o servicio. En la era de la seguridad de métodos, a menudo verás no un hermoso ReviewService, sino una clase generada.

Puedes añadir un pequeño ApplicationRunner en el proyecto de aprendizaje para que al inicio imprima la clase:

import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class ProxyDebugConfig {

    @Bean
    ApplicationRunner showReviewServiceClass(ReviewService reviewService) {
        // En el argumento llegará el objeto que realmente está en el contexto (a menudo un proxy)
        return args -> System.out.println(reviewService.getClass());
        // Por ejemplo:
        // class com.example.securecontent.content.ReviewService$$SpringCGLIB$$0
    }
}

Es importante tratar esto como un microscopio, no como código permanente en producción. Pero para el aprendizaje es muy útil: ves directamente que en reviewService hay una envoltura.

Si de repente ves una clase normal sin $$... y la seguridad de métodos no funciona, puede ser una pista: o la seguridad de métodos no está activada, o el objeto no es un bean de Spring, o no estás pasando por el proxy al llamar, por ejemplo debido a una auto-invocación. Y aquí tienes el arma principal del desarrollador: no la fe, sino el diagnóstico.

7. Errores típicos con proxy y seguridad de métodos

Error nº 1: percibir @PreAuthorize como una capacidad integrada de Java.
Los principiantes a menudo piensan que la anotación vive por sí sola en el método y se ejecutará garantizadamente antes de la llamada. En realidad, @PreAuthorize funciona solo porque Spring intercepta la llamada mediante AOP y un proxy. Si la llamada no entra en la mecánica del proxy, la anotación se convierte en un adorno, no en una protección.

Error nº 2: llamar a un método protegido mediante auto-invocación y esperar que la verificación funcione.
Cuando un método de una clase llama a otro método de la misma clase, la llamada suele ir directamente a través de this, es decir, el proxy no participa. Como resultado, puedes tener una situación de «anotación presente, pero sin verificación». Esto no se cura con conjuros, sino con arquitectura: protege los métodos de entrada, extrae la lógica común en helpers sin anotaciones o separa las llamadas en diferentes beans.

Error nº 3: crear un servicio manualmente con new o sacar la lógica de negocio a un no-bean y ponerle anotaciones de seguridad.
La seguridad de métodos solo funciona en objetos gestionados por Spring. Si el objeto se crea manualmente, Spring no crea un proxy para él, no conecta interceptores y no sabe que esperas seguridad. En un proyecto de aprendizaje, esto es especialmente peligroso: parece que funciona, pero luego las reglas de acceso desaparecen de repente.

Error nº 4: poner @PreAuthorize en métodos private y sorprenderse de que no pase nada.
A menudo se quiere ocultar los internos y anotar un pequeño helper privado para que el método público se mantenga limpio. Pero el modelo de proxy de Spring AOP normalmente no intercepta métodos private. Como resultado, obtienes un código bonito y una protección nula. Es más fiable anotar el método público de entrada y mantener los detalles internos como métodos normales sin anotaciones.

Error nº 5: hacer final la clase o el método del servicio por disciplina y romper accidentalmente la interceptación del proxy.
A veces a los desarrolladores les gusta final como símbolo de «todo bajo control». Pero muchas mecánicas de proxy se basan en la sobrescritura de métodos, especialmente los proxies basados en clases. Las clases y métodos finales pueden impedir que Spring intervenga en la llamada. A nivel del curso, es más sencillo seguir la práctica básica: los servicios normalmente no se hacen final, y la protección se logra con disciplina arquitectónica y pruebas, no con prohibiciones de herencia.

1
Tarea
Spring Security, nivel 19, lección 2
Bloqueada
Mostrar el proxy de un servicio protegido
Mostrar el proxy de un servicio protegido
1
Tarea
Spring Security, nivel 19, lección 2
Bloqueada
Mostrar self-invocation en un solo servicio
Mostrar self-invocation en un solo servicio
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION