Documentation for a newer release is available. View Latest

Asegurando endpoints HTTP

Por defecto, las APIs de IPF no autentican las solicitudes entrantes. Esto puede configurarse en la implementación del cliente incluyendo la dependencia spring-boot-starter-security en el proyecto. La documentación completa de Spring Security se puede encontrar aquí.

Ejemplo

Esta sección demostrará cómo una implementación cliente podría añadir validación de tokens de acceso JWT en los endpoints expuestos por ipf-read-starter.

Prerrequisitos

La implementación del lado de lectura debe estar implementada y funcionando, aceptando todas las solicitudes válidas.

Añadir dependencias Maven

Para este ejemplo, se requieren dos dependencias. La primera es el módulo spring-boot-starter-security mencionado, provisto por el framework Spring.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

La segunda es una librería JWT que se usará para validar tokens en las solicitudes entrantes.

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
</dependency>

Servicio para validar JWTs

A continuación, se creará un servicio para validar y decodificar JWTs. El servicio requiere el secreto usado para firmar el token, de modo que pueda verificar la firma del token. El secreto se inyecta usando la anotación @Value provista por Spring.

@Service
public class JWTService {

    private final String secret;

    @Autowired
    public JWTService(@Value("${ipf.security.jwt.secret}") String secret) {
        this.secret = secret;
    }

    public Optional<DecodedJWT> decodeAccessToken(String accessToken) {
        return decode(secret, accessToken);
    }

    public List<GrantedAuthority> getRoles(DecodedJWT decodedJWT) {
        return decodedJWT.getClaim("role").asList(String.class).stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private Optional<DecodedJWT> decode(String signature, String token) {
        try {
            return Optional.of(JWT.require(Algorithm.HMAC512(signature.getBytes(StandardCharsets.UTF_8)))
                    .build()
                    .verify(token.replace("Bearer ", "")));
        } catch (Exception ex) {
            return Optional.empty();
        }
    }
}

Componente filtro de autorización

Cuando se realiza una solicitud, esta pasa a través de una cadena de clases WebFilter. La mayor parte de la cadena la proporciona Spring de forma predeterminada, aunque el cliente puede proporcionar filtros adicionales. En este ejemplo, se implementará uno de estos WebFilter para validar que las solicitudes realizadas a endpoints seguros tengan establecido el header Authorization con un token válido; de lo contrario, la solicitud debería fallar (a menos que se proporcione otro filtro de autenticación que permita autenticar solicitudes de otra manera).

@Component
public class JWTAuthorizationFilter implements WebFilter {

    private final JWTService jwtService;

    public JWTAuthorizationFilter(JWTService jwtService) {
        this.jwtService = jwtService;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, @NotNull WebFilterChain chain) {
        String header = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith("Bearer ")) {
            return chain.filter(exchange);
        }

        return jwtService.decodeAccessToken(header)
                .map(token -> new UsernamePasswordAuthenticationToken(
                        token.getSubject(),
                        null,
                        jwtService.getRoles(token)))
                .map(token -> chain.filter(exchange)
                        .subscriberContext(context -> ReactiveSecurityContextHolder.withAuthentication(token)))
                .orElse(chain.filter(exchange)
                        .subscriberContext(context -> ReactiveSecurityContextHolder.clearContext().apply(context)));
    }
}

El JWTAuthorizationFilter implementa el único método filter de la clase WebFilter. Comprueba que el header Authorization de la solicitud esté prefijado con "Bearer " seguido de un JWT válido.

  • Si el header no está establecido, el filtro simplemente pasa el exchange al siguiente filtro sin hacer nada.

  • Si el token es válido, el filtro establece la autenticación en el contexto de seguridad.

  • En caso contrario, el filtro limpia el contexto de seguridad.

Configurar el SecurityWebFilterChain

Finalmente, se debe configurar el SecurityWebFilterChain como un bean de Spring.

@Configuration
@EnableWebFluxSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityWebFilterChain secureFilterChain(ServerHttpSecurity http,
                                                    JWTAuthorizationFilter jwtAuthorizationFilter) {
        return http
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .logout(ServerHttpSecurity.LogoutSpec::disable)
                .authorizeExchange(spec -> spec
                        .pathMatchers("/actuator/health").permitAll()
                        .pathMatchers("/actuator/**").hasAnyRole("ADMIN")
                        .anyExchange().authenticated()
                )
                .addFilterAt(jwtAuthorizationFilter, SecurityWebFiltersOrder.AUTHORIZATION)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .build();
    }

    @Bean
    public AbstractJackson2Decoder jacksonDecoder(ObjectMapper mapper) {
        return new Jackson2JsonDecoder(mapper);
    }

}

Esta configuración está preparada para deshabilitar algunas funciones que no son necesarias (form login, http basic, csrf y logout). Los dos aspectos más importantes son la especificación authorizeExchange y el método addFilterAt.

El bloque authorizeExchange especifica una serie de path matchers que determinan qué se hará con las solicitudes que coincidan con esos matchers.

  • El primer matcher asegura que cualquier solicitud hecha a "/actuator/health" será permitida; es decir, no se requiere token de autorización.

  • El segundo matcher solo permitirá solicitudes hechas a "/actuator/**" que tengan el rol "ADMIN". Esto implícitamente requiere que el token también sea válido.

  • El último es un matcher catch-all que garantizará que cualquier solicitud no especificada esté autenticada, es decir, que tenga un token válido.

El método addFilterAt simplemente añade el JWTAuthorizationFilter creado anteriormente a la cadena de filtros y lo coloca en la etapa AUTHORIZATION dentro de la cadena. Esto asegura que la solicitud pase por el filtro en el momento correcto.

Pruebas

Para probar la configuración recién añadida, se debe establecer la propiedad ipf.security.jwt.secret. En este ejemplo se usará el valor secret por simplicidad.

ipf.security.jwt.secret=secret

Se requiere una herramienta para crear JWTs válidos; la herramienta usada en esta demostración se puede encontrar en este repositorio de GitHub.

Establece las siguientes variables de entorno.

JWT_SECRET=secret
JWT_EXPIRY_SECONDS=180 # 3 minutes
JWT_ROLE=USER

Luego crea el JWT con el siguiente comando.

JWT=$(jwt encode \
  --alg HS512 \
  --sub user \
  --exp $(expr $(date +%s) + ${JWT_EXPIRY_SECONDS}) \
  --secret ${JWT_SECRET} \
  '{"role": ["ROLE_${ROLE}"]}')

En este punto el servicio debería estar iniciado, y la configuración puede probarse usando curl.

# Verifica que el endpoint "/actuator/health" no necesita autenticación
curl localhost:8080/actuator/health # debería devolver 200

# Verifica que el endpoint "/transactions" necesita autenticación
curl localhost:8080/transactions -i # debería devolver 401
curl -H "Authorization: Bearer $JWT" localhost:8080/transactions # Debería devolver 200

# Verifica que el endpoint "/actuator/info" necesita autenticación y tener el rol ADMIN
curl localhost:8080/actuator/info -i # debería devolver 401
curl -H "Authorization: Bearer $JWT" localhost:8080/actuator/info -i # debería devolver 401
JWT_ROLE=ADMIN
JWT=<same as before>
curl -H "Authorization: Bearer $JWT" localhost:8080/actuator/info -i # debería devolver 200