ResourceServerConfig.java

package com.albertsilva.dev.asjcatalog.security.oauth2.resource.config;

import java.util.Arrays;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * Configuração do <b>Resource Server</b>: protege a API com JWT, define quais
 * rotas são públicas, habilita a segurança por método e configura o CORS.
 *
 * <p>
 * <b>Cadeias de filtros:</b>
 * </p>
 * <ul>
 * <li>{@code @Order(1)} — H2 Console: só existe se
 * {@code spring.h2.console.enabled=true} (perfil {@code test}); casa somente
 * com o console, sem regras de autorização, com CSRF e {@code frameOptions}
 * desabilitados;</li>
 * <li>{@code @Order(2)} — Authorization Server (em
 * {@code AuthorizationServerConfig}): {@code /oauth2/**} e
 * {@code /.well-known/**};</li>
 * <li>{@code @Order(3)} — Resource Server (este {@link #rsSecurityFilterChain}):
 * sem {@code securityMatcher}, atende todas as demais requisições.</li>
 * </ul>
 *
 * <p>
 * <b>Regras de URL da cadeia do Resource Server</b> (em ordem): {@code GET} em
 * {@code /api/v1/categories/**}, {@code /api/v1/products/**} e
 * {@code /api/v1/accounts/**} — públicos; {@code POST} em
 * {@code /api/v1/accounts/**} — público; documentação
 * ({@code /docs-asjcatalog}, {@code /docs-asjcatalog/**},
 * {@code /docs-asjcatalog.html}, {@code /swagger-ui/**}) — pública; qualquer
 * outra requisição — exige autenticação. Esses caminhos de documentação
 * correspondem ao perfil {@code test}; {@code application-dev.properties}
 * configura outros. A autorização fina é feita por {@code @PreAuthorize} nos
 * controllers ({@code @EnableMethodSecurity}); rotas públicas "por URL" podem
 * ter regra própria no método (por exemplo, {@code GET /api/v1/accounts/me}).
 * </p>
 *
 * <p>
 * <b>Autenticação:</b> {@code oauth2ResourceServer().jwt()} com o
 * {@code JwtDecoder} da aplicação; o {@link #jwtAuthenticationConverter()}
 * transforma o claim {@code authorities} em authorities do Spring Security.
 * CSRF está desabilitado nesta cadeia; a política de sessão não é configurada
 * explicitamente.
 * </p>
 *
 * <p>
 * <b>CORS:</b> origens de {@code cors.origins}; ver
 * {@link #corsConfigurationSource()}.
 * </p>
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

  /** Caminhos públicos da documentação OpenAPI/Swagger (os do perfil {@code test}). */
  private static final String[] DOCUMENTATION_OPENAPI = { "/docs-asjcatalog", "/docs-asjcatalog/**",
      "/docs-asjcatalog.html", "/swagger-ui/**" };
  /** Prefixos liberados sem autenticação para requisições {@code GET}. */
  private static final String[] PUBLIC_GET_ENDPOINTS = { "/api/v1/categories/**", "/api/v1/products/**" , "/api/v1/accounts/**"};

  /** Prefixos liberados sem autenticação para requisições {@code POST}. */
  private static final String[] PUBLIC_POST_ENDPOINTS = { "/api/v1/accounts/**" };

  /** Origens CORS permitidas, separadas por vírgula ({@code cors.origins}). */
  @Value("${cors.origins}")
  private String corsOrigins;

  /**
   * Cadeia do console do H2 ({@code @Order(1)}), criada somente quando
   * {@code spring.h2.console.enabled=true}.
   *
   * <p>
   * Casa apenas com o caminho do console ({@code PathRequest.toH2Console()}),
   * desabilita CSRF e {@code frameOptions} e <b>não define regras de
   * autorização</b>, isto é, o console fica acessível sem autenticação nessa
   * condição.
   * </p>
   *
   * @param http construtor de segurança HTTP
   * @return cadeia do H2 Console
   * @throws Exception se a configuração falhar
   */
  @Bean
  @Order(1)
  @ConditionalOnProperty(prefix = "spring.h2.console", name = "enabled", havingValue = "true")
  public SecurityFilterChain h2SecurityFilterChain(HttpSecurity http) throws Exception {

    http.securityMatcher(PathRequest.toH2Console()).csrf(csrf -> csrf.disable())
        .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
    return http.build();
  }

  /**
   * Cadeia principal do Resource Server ({@code @Order(3)}).
   *
   * <p>
   * Desabilita CSRF; aplica as regras de URL descritas na documentação da classe
   * (públicos por {@code GET}/{@code POST}, documentação pública, demais rotas
   * autenticadas); ativa {@code oauth2ResourceServer().jwt()} e o CORS com
   * {@link #corsConfigurationSource()}. Requisição sem token válido em rota
   * protegida é rejeitada pelo filtro de bearer token (o formato da resposta
   * será detalhado na análise da camada web).
   * </p>
   *
   * @param http construtor de segurança HTTP
   * @return cadeia do Resource Server
   * @throws Exception se a configuração falhar
   */
  @Bean
  @Order(3)
  public SecurityFilterChain rsSecurityFilterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf.disable());
    http.authorizeHttpRequests(authorize -> authorize
        // Public endpoints
        .requestMatchers(HttpMethod.GET, PUBLIC_GET_ENDPOINTS).permitAll()

        // Public POST endpoints
        .requestMatchers(HttpMethod.POST, PUBLIC_POST_ENDPOINTS).permitAll()

        // Swagger / OpenAPI
        .requestMatchers(DOCUMENTATION_OPENAPI).permitAll()

        // Any other request
        .anyRequest().authenticated());
    http.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults()));
    http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
    return http.build();
  }

  /**
   * Converte um JWT validado em {@code JwtAuthenticationToken}, lendo as
   * authorities do claim <b>{@code authorities}</b> com prefixo <b>vazio</b>.
   *
   * <ul>
   * <li>Cada texto do claim vira uma {@code GrantedAuthority} <b>como está</b>
   * (por exemplo, {@code ROLE_ADMIN}); assim {@code hasRole('ADMIN')}, que
   * procura {@code ROLE_ADMIN}, funciona.</li>
   * <li>O claim {@code scope} <b>não</b> é convertido em authorities (o nome do
   * claim foi trocado).</li>
   * <li>O principal do {@code Authentication} é o próprio {@code Jwt}; o nome
   * ({@code getName()}) usa o claim padrão do conversor ({@code sub}), que não é
   * o id nem o e-mail do usuário.</li>
   * </ul>
   *
   * <p>
   * O bean é usado pelo {@code jwt()} da cadeia (o framework o localiza no
   * contexto; os testes de integração de autorização confirmam o efeito).
   * </p>
   *
   * @return conversor de JWT para autenticação
   */
  @Bean
  public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
    grantedAuthoritiesConverter.setAuthorityPrefix("");

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
  }

  /**
   * Configuração de CORS aplicada a todos os caminhos ({@code /**}).
   *
   * <p>
   * Origens: {@code cors.origins} dividido por vírgula, usado como <em>padrões de
   * origem</em> (aceitam curingas). Métodos permitidos: {@code POST, GET, PUT,
   * DELETE, PATCH}. Cabeçalhos permitidos: apenas {@code Authorization} e
   * {@code Content-Type}. Credenciais permitidas. {@code OPTIONS} não consta da
   * lista de métodos; como o CORS confere o método <em>solicitado</em> no
   * pré-voo, isso, em princípio, não impede o pré-voo (a confirmar em testes).
   * </p>
   *
   * @return fonte de configuração de CORS
   */
  @Bean
  CorsConfigurationSource corsConfigurationSource() {

    String[] origins = corsOrigins.split(",");

    CorsConfiguration corsConfig = new CorsConfiguration();
    corsConfig.setAllowedOriginPatterns(Arrays.asList(origins));
    corsConfig.setAllowedMethods(Arrays.asList("POST", "GET", "PUT", "DELETE", "PATCH"));
    corsConfig.setAllowCredentials(true);
    corsConfig.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", corsConfig);
    return source;
  }

  /**
   * Registra um {@code CorsFilter} como filtro de servlet, com a maior
   * precedência, usando a mesma configuração de
   * {@link #corsConfigurationSource()}. Existe além do {@code http.cors(...)} da
   * cadeia de segurança.
   *
   * @return registro do filtro CORS
   */
  @Bean
  FilterRegistrationBean<CorsFilter> filterRegistrationBeanCorsFilter() {
    FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(corsConfigurationSource()));
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return bean;
  }
}