Type something to search...
Una Propuesta para Estandarizar la Seguridad en APIs REST con Arquitectura Hexagonal y Spring Security

Una Propuesta para Estandarizar la Seguridad en APIs REST con Arquitectura Hexagonal y Spring Security

En el desarrollo de aplicaciones empresariales modernas, la seguridad es un pilar fundamental. Sin embargo, lograr una arquitectura de seguridad que sea reutilizable, desacoplada y, al mismo tiempo, compatible con los estándares de la industria (como JWT y Spring Security) puede ser un reto. Este artículo explora una solución basada en la arquitectura hexagonal, que permite centralizar la lógica de autorización en el dominio, sin perder la integración con las capacidades avanzadas de Spring Security, como el uso de anotaciones (@PreAuthorize) y la inyección del usuario autenticado en los controladores.

Contexto y Desafío

La mayoría de los frameworks modernos, como Spring Boot, ofrecen mecanismos de seguridad robustos y listos para usar. Sin embargo, estos suelen acoplar la lógica de autenticación y autorización a la infraestructura, dificultando la reutilización y el testeo independiente del dominio. Por otro lado, la arquitectura hexagonal promueve la separación de responsabilidades, permitiendo que la lógica de negocio (incluida la autorización) permanezca independiente de los detalles tecnológicos.

El desafío surge cuando se requiere que la lógica de autorización, implementada en el dominio, pueda interactuar con el ecosistema de Spring Security, permitiendo el uso de anotaciones como @PreAuthorize y la inyección del usuario autenticado en los controladores. La solución propuesta en este artículo aborda este reto, permitiendo una integración fluida y flexible.

Dependencias Requeridas

Para implementar esta solución, se requieren las siguientes dependencias en el archivo build.gradle:

dependencies {
    // Spring Boot Core
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-configuration-processor'
    
    // Lombok para reducir boilerplate
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    
    // JWT (opcional, para parsing de tokens)
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    
    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

Estructura de Directorios

La librería sigue una estructura de directorios que respeta los principios de la arquitectura hexagonal:

src/
├── main/
   ├── java/
   └── com/
       └── tuempresa/
           ├── dominio/
   └── autorizacion/
       ├── AuthorizationContext.java
       ├── AuthorizationException.java
       ├── AuthorizationService.java
       └── AuthorizationServiceImpl.java
           └── infraestructura/
               ├── config/
   ├── AuthorizationConfig.java
   └── AuthorizationFilterConfig.java
               └── filtros/
                   └── StandardAuthorizationFilter.java
   └── resources/
       └── META-INF/
           └── spring.factories (para auto-configuración)
└── test/
    └── java/
        └── com/
            └── tuempresa/
                ├── dominio/
   └── autorizacion/
       └── AuthorizationServiceTest.java
                └── infraestructura/
                    └── filtros/
                        └── StandardAuthorizationFilterTest.java

Solución: Librería de Seguridad Hexagonal Integrada

La solución se basa en una serie de clases y componentes que pueden ser empaquetados como una librería reutilizable. Esta librería permite:

  • Centralizar la lógica de autorización en el dominio, desacoplada de la infraestructura.
  • Configurar rutas excluidas, parámetros y comportamiento desde archivos de configuración.
  • Integrar con Spring Security para habilitar anotaciones y acceso al usuario autenticado.
  • Validar JWT y extraer roles/claims para el contexto de seguridad.

A continuación, se presentan las clases clave de la solución.

1. Contexto de Autorización (Dominio)

package com.tuempresa.dominio.autorizacion;

import lombok.Builder;
import lombok.Value;
import java.util.Map;

@Value
@Builder
public class AuthorizationContext {
    String method;
    String uri;
    Map<String, String> headers;
    Map<String, String> queryParams;
    String body;
    String remoteAddress;
}

2. Excepción de Dominio

package com.tuempresa.dominio.autorizacion;

public class AuthorizationException extends RuntimeException {
    public AuthorizationException(String message) {
        super(message);
    }
}

3. Puerto de Dominio (Interface)

package com.tuempresa.dominio.autorizacion;

public interface AuthorizationService {
    void authorize(AuthorizationContext context) throws AuthorizationException;
}

4. Implementación Base del Servicio de Dominio

package com.tuempresa.dominio.autorizacion;

public class AuthorizationServiceImpl implements AuthorizationService {
    @Override
    public void authorize(AuthorizationContext context) {
        String token = context.getHeaders().get("authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            throw new AuthorizationException("Token inválido o ausente");
        }
        // Más lógica de dominio...
    }
}

5. Configuración del Filtro

package com.tuempresa.infraestructura.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Set;
import java.util.HashSet;

@Data
@Component
@ConfigurationProperties(prefix = "app.security.authorization")
public class AuthorizationFilterConfig {
    
    private Set<String> excludedPaths = new HashSet<>();
    private boolean enabled = true;
    private int order = 1;
    private boolean includeQueryParams = true;
    private boolean includeBody = false;
    private boolean enableSpringSecurityIntegration = true;
    
    public AuthorizationFilterConfig() {
        excludedPaths.add("/actuator/health");
        excludedPaths.add("/actuator/info");
        excludedPaths.add("/swagger-ui");
        excludedPaths.add("/v3/api-docs");
        excludedPaths.add("/error");
    }
}

6. Filtro Principal Consolidado

package com.tuempresa.infraestructura.filtros;

import com.tuempresa.dominio.autorizacion.AuthorizationContext;
import com.tuempresa.dominio.autorizacion.AuthorizationService;
import com.tuempresa.dominio.autorizacion.AuthorizationException;
import com.tuempresa.infraestructura.config.AuthorizationFilterConfig;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;

@Component
@Order(1)
@ConditionalOnProperty(name = "app.security.authorization.enabled", havingValue = "true", matchIfMissing = true)
@RequiredArgsConstructor
public class StandardAuthorizationFilter extends OncePerRequestFilter {

    private final AuthorizationFilterConfig config;
    private final AuthorizationService authorizationService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        if (isExcludedPath(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            HttpServletRequest requestToUse = request;
            if (config.isIncludeBody()) {
                requestToUse = new ContentCachingRequestWrapper(request);
            }

            Map<String, String> headers = extractHeaders(requestToUse);
            Map<String, String> queryParams = config.isIncludeQueryParams() ? extractQueryParams(requestToUse) : Collections.emptyMap();
            String requestBody = (config.isIncludeBody() && requestToUse instanceof ContentCachingRequestWrapper)
                    ? extractRequestBody((ContentCachingRequestWrapper) requestToUse)
                    : null;

            AuthorizationContext context = AuthorizationContext.builder()
                    .method(requestToUse.getMethod())
                    .uri(requestToUse.getRequestURI())
                    .headers(headers)
                    .queryParams(queryParams)
                    .body(requestBody)
                    .remoteAddress(requestToUse.getRemoteAddr())
                    .build();

            authorizationService.authorize(context);

            // Integración con Spring Security (si está habilitada)
            if (config.isEnableSpringSecurityIntegration()) {
                setSpringSecurityContext(context);
            }

            filterChain.doFilter(requestToUse, response);

        } catch (AuthorizationException e) {
            handleSecurityException(response, e);
        } catch (Exception e) {
            logger.error("Error in authorization filter", e);
            handleGenericError(response);
        }
    }

    private Map<String, String> extractHeaders(HttpServletRequest request) {
        Map<String, String> headers = new HashMap<>();
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String headerName = headerNames.nextElement();
            String headerValue = request.getHeader(headerName);
            headers.put(headerName.toLowerCase(), headerValue);
        }
        return headers;
    }

    private Map<String, String> extractQueryParams(HttpServletRequest request) {
        Map<String, String> queryParams = new HashMap<>();
        Enumeration<String> paramNames = request.getParameterNames();
        while (paramNames.hasMoreElements()) {
            String paramName = paramNames.nextElement();
            String paramValue = request.getParameter(paramName);
            queryParams.put(paramName, paramValue);
        }
        return queryParams;
    }

    private String extractRequestBody(ContentCachingRequestWrapper request) throws IOException {
        byte[] content = request.getContentAsByteArray();
        if (content.length > 0) {
            return new String(content, StandardCharsets.UTF_8);
        }
        return null;
    }

    private void setSpringSecurityContext(AuthorizationContext context) {
        try {
            String authHeader = context.getHeaders().get("authorization");
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                String username = extractUsernameFromToken(authHeader);
                List<String> roles = extractRolesFromToken(authHeader);
                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                        .toList();

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(username, null, authorities);

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.warn("Could not set Spring Security context", e);
        }
    }

    private String extractUsernameFromToken(String authHeader) {
        // Implementar parsing del JWT aquí
        return "user_from_jwt"; // Placeholder
    }

    private List<String> extractRolesFromToken(String authHeader) {
        // Implementar parsing del JWT aquí
        return List.of("USER"); // Placeholder
    }

    private boolean isExcludedPath(String requestURI) {
        return config.getExcludedPaths().stream()
                .anyMatch(excluded -> requestURI.startsWith(excluded));
    }

    private void handleSecurityException(HttpServletResponse response, AuthorizationException e) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write(String.format(
                "{\"error\":\"Unauthorized\",\"message\":\"%s\",\"timestamp\":\"%s\"}",
                e.getMessage(), new Date()
        ));
    }

    private void handleGenericError(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        response.setContentType("application/json");
        response.getWriter().write(String.format(
                "{\"error\":\"Internal Server Error\",\"message\":\"Authorization check failed\",\"timestamp\":\"%s\"}",
                new Date()
        ));
    }
}

7. Configuración de Beans

package com.tuempresa.infraestructura.config;

import com.tuempresa.dominio.autorizacion.AuthorizationService;
import com.tuempresa.dominio.autorizacion.AuthorizationServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AuthorizationConfig {
    @Bean
    public AuthorizationService authorizationService() {
        return new AuthorizationServiceImpl();
    }
}

8. Configuración en application.yml

app:
  security:
    authorization:
      enabled: true
      order: 1
      include-query-params: true
      include-body: false
      enable-spring-security-integration: true
      excluded-paths:
        - "/actuator/health"
        - "/actuator/info"
        - "/swagger-ui"
        - "/v3/api-docs"
        - "/public"
        - "/auth/login"

9. Ejemplo de Uso en el Proyecto Cliente

@Service
public class MyCustomAuthorizationService implements AuthorizationService {

    @Override
    public void authorize(AuthorizationContext context) throws AuthorizationException {
        String token = context.getHeaders().get("authorization");
        if (!isValidJWT(token)) {
            throw new AuthorizationException("JWT inválido");
        }
        if (context.getUri().startsWith("/admin") && !hasAdminRole(token)) {
            throw new AuthorizationException("Acceso denegado a área administrativa");
        }
    }

    private boolean isValidJWT(String token) {
        // Implementar validación JWT
        return true;
    }

    private boolean hasAdminRole(String token) {
        // Verificar roles en el JWT
        return false;
    }
}

10. Uso en Controllers

@RestController
public class MyController {

    @GetMapping("/protected")
    @PreAuthorize("hasRole('USER')")
    public String protectedEndpoint(@AuthenticationPrincipal String username) {
        return "Hello " + username + "! You are authenticated.";
    }

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminEndpoint() {
        return "Admin area";
    }
}

Consideraciones Finales

  • Separación de responsabilidades: El dominio se mantiene puro y desacoplado de la infraestructura.
  • Integración total: Se habilita el uso de anotaciones y la inyección del usuario autenticado gracias a la integración con el contexto de Spring Security.
  • Configurabilidad: La solución es fácilmente adaptable a distintos proyectos mediante configuración externa.
  • Reutilización: El diseño modular permite empaquetar la solución como una librería para múltiples aplicaciones.

Conclusión

La estandarización de la seguridad bajo una arquitectura hexagonal, combinada con la integración de Spring Security y JWT, permite construir aplicaciones robustas, mantenibles y alineadas con las mejores prácticas de la industria. Esta aproximación no solo facilita la reutilización y el testeo, sino que también habilita la evolución futura del sistema, permitiendo incorporar nuevas estrategias de autenticación o autorización sin comprometer la arquitectura.

Related Posts

Diseñando un Wrapper de Respuesta en Java con Funcionalidades de Optional y Gestión de Estado

Diseñando un Wrapper de Respuesta en Java con Funcionalidades de Optional y Gestión de Estado

En el desarrollo de aplicaciones Java, el manejo de respuestas a solicitudes —especialmente aquellas que involucran operaciones asincrónicas, procesamiento de datos o comunicación con servicios extern

Leer más
Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯

Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯

En el desarrollo de software moderno, escribir código que funciona perfectamente en escenarios ideales es solo el primer paso. La verdadera fortaleza de un sistema se manifiesta cuando debe enfrentar

Leer más
El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot

El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot

En el vertiginoso universo del desarrollo de software, donde la seguridad es un pilar innegociable y la agilidad es la moneda de cambio, nos enfrentamos a desafíos que van más allá de la simple lógica

Leer más
El Arte del Contexto: Diseño Flexible en Arquitecturas DDD con Java y Spring Boot

El Arte del Contexto: Diseño Flexible en Arquitecturas DDD con Java y Spring Boot

En el universo del desarrollo de software empresarial, nos enfrentamos a un dilema constante: cómo manejar información transversal —ese rastro de datos vitales como IDs de correlación, información del

Leer más
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC

El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC

El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC Imagina a un desarrollador frontend consumiendo tu API. En un endpoint, recibe un objeto JSON. En otro, una simple lista. S

Leer más
Transformando Colecciones con Java Streams: 15 Métodos Esenciales

Transformando Colecciones con Java Streams: 15 Métodos Esenciales

Introducción En el mundo de Java, trabajar con colecciones de datos solía ser sinónimo de bucles interminables, condicionales anidados y código repetitivo. Pero con la llegada de Java Streams

Leer más
Optimizando el Acceso a Datos: La Importancia de las Proyecciones JPA en Spring Boot

Optimizando el Acceso a Datos: La Importancia de las Proyecciones JPA en Spring Boot

El Costo Oculto de Traer Demasiada Información En el desarrollo de aplicaciones que interactúan con bases de datos, una tarea fundamental es la recuperación de datos. Al usar Object-Relational Map

Leer más
Desmitificando Gradle: El Primer Paso para Automatizar tu Mundo Java

Desmitificando Gradle: El Primer Paso para Automatizar tu Mundo Java

En el vertiginoso universo del desarrollo de software, la eficiencia no es un lujo, es una necesidad. Dedicar tiempo a tareas repetitivas como compilar código, ejecutar pruebas, empaquetar la aplicaci

Leer más
Del Dicho al Hecho: Generando Proyectos Java con Plantillas y FreeMarker

Del Dicho al Hecho: Generando Proyectos Java con Plantillas y FreeMarker

En el artículo anterior, alcanzamos un hito crucial: construimos un plugin binario funcional en Java, completo con su propia configuración y tarea. Nuestro plugin "saludador" demostró que dominamos la

Leer más
De Consumidor a Creador: Construyendo tu Primer Plugin Binario de Gradle

De Consumidor a Creador: Construyendo tu Primer Plugin Binario de Gradle

En nuestro artículo anterior, desmitificamos Gradle y sentamos las bases para entender su funcionamiento. Aprendimos a crear proyectos, ejecutar tareas y comprendimos el rol fundamental de los plugins

Leer más