👏

Spring Securityで、SecurityFilterChainを複数作る

に公開

「一般ユーザーと管理者でセキュリティ設定を分けたい」「画面とWeb APIでセキュリティ設定を分けたい」という要件を実現する方法を調査しました。

Spring Securityとは何ぞやという人は、まず『プロになるためのSpring入門』を読むことをおすすめします。

環境

  • JDK 21
  • Spring Boot 3.5
  • Spring Security 6.5

まずは上手くいかなかった方法

今回は一般ユーザーと管理者でセキュリティ設定を分けることを考えます。

こんな感じでSecurityFilterChainを一般ユーザー用と管理者用で2つ作ります。

SecurityFilterChain@Orderを付けて、指定した整数の昇順にSecurityFilterChainが評価されるようにします。

SecurityConfig.java(上手くいかない例)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
  @Bean
  @Order(1)  // こっちが先に実行される
  public SecurityFilterChain userSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authz -> authz
            .requestMatchers("/user").hasRole("USER")
        )
        .httpBasic(basic -> basic
            .realmName("User-Realm")
        );
    return http.build();
  }

  @Bean
  @Order(2)  // こっちが後に実行される
  public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authz -> authz
            .requestMatchers("/admin").hasRole("ADMIN")
        )
        .httpBasic(basic -> basic
            .realmName("Admin-Realm")
        );
    return http.build();
  }

  @Bean
  @Order(3)  // これが最後に実行される
  public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authz -> authz
            .requestMatchers("/**").authenticated()
        )
        .httpBasic(Customizer.withDefaults());
    return http.build();
  }

  @Bean
  public UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager(
        User.withUsername("user")
            .password("user")
            .roles("USER")
            .build(),
        User.withUsername("admin")
            .password("admin")
            .roles("ADMIN")
            .build()
    );
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    // NoOpPasswordEncoderはパスワードをエンコードしないため、本番環境では使用しないでください。
    return NoOpPasswordEncoder.getInstance();
  }
}

コントローラーはこんな感じ。

HelloController.java
@RestController
public class HelloRestController {
  @GetMapping("/user")
  public String user() {
    return "Hello user!";
  }

  @GetMapping("/admin")
  public String admin() {
    return "Hello admin!";
  }

  @GetMapping("/other")
  public String other() {
    return "Hello other!";
  }
}

さてこれでアプリケーションを起動すると、例外で起動に失敗します。

起動時のエラーメッセージ(読みやすいよう改行を入れています)
Caused by: org.springframework.beans.factory.BeanCreationException:
 Error creating bean with name '(inner bean)#1526f71' defined in class path resource [org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class]:
  Failed to instantiate [jakarta.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception with message:
   A filter chain that matches any request [DefaultSecurityFilterChain defined as 'userSecurityFilterChain' in [class path resource [com/example/multisecurityfilterchainsample/SecurityConfig.class]] matching [any request] and having filters
    [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]] has already been configured,
     which means that this filter chain [DefaultSecurityFilterChain defined as 'adminSecurityFilterChain' in [class path resource [com/example/multisecurityfilterchainsample/SecurityConfig.class]] matching [any request] and having filters
      [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]] will never get invoked.
       Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured
        for 'any request' and that the 'any request' filter chain is published last.

最後の行にHttpSecurity#securityMatcherを使ってね、みたいなことが書いてあります。

修正

Spring Security公式リファレンスのこのへんを読むと、複数のSecurityFilterChainがある場合は、順番にURLでどのSecurityFilterChainを使うかを判定していることが説明されています。

以下、公式リファレンスの図を引用します。

加えて、公式リファレンスのこのへんにソースコードの例が載っていて、さっきのエラーメッセージにも出ていたsecurityMatcher()メソッドを使っていることが分かります。

そしてsecurityMatcher()メソッドのJavadocによると、

Allows configuring the HttpSecurity to only be invoked when matching the provided pattern.

「与えられたパターンにマッチする時のみ実行されるよう、HttpSecurityを設定する」と説明されています。多分これで行けそうな気がします。

ということで、SecurityConfigを次のように修正します。

SecurityConfig.java(修正後、一部)
  @Bean
  @Order(1)
  public SecurityFilterChain userSecurityFilterChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/user")  // /userでのみ、このSecurityFilterChainが実行されるよう設定
        .authorizeHttpRequests(authz -> authz
        ...
  }

  @Bean
  @Order(2)
  public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/admin")  // /adminでのみ、このSecurityFilterChainが実行されるよう設定
        .authorizeHttpRequests(authz -> authz
        ...
  }

  // defaultSecurityFilterChainは変更なし

テストで確認

JUnitでテストを書いて、動作確認してみます。

SecurityIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SecurityIntegrationTest {

  RestClient restClient;

  @LocalServerPort
  int port;

  @BeforeEach
  void beforeEach(@Autowired RestClient.Builder restClientBuilder) {
    // RestClientの初期化や必要な設定を行う
    restClient = restClientBuilder
        .baseUrl("http://localhost:" + port)
        .build();
  }

  @Nested
  @DisplayName("userロール")
  class UserRoleTest {
    @Test
    @DisplayName("/userで200")
    void userOk() {
      ResponseEntity<String> responseEntity = restClient.get()
          .uri("/user")
          .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("user", "user"))
          .retrieve()
          .toEntity(String.class);
      assertAll(
          () -> assertEquals(200, responseEntity.getStatusCode().value()),
          () -> assertEquals("Hello user!", responseEntity.getBody())
      );
    }

    @Test
    @DisplayName("/adminで403")
    void adminForbidden() {
      assertThrows(
          HttpClientErrorException.Forbidden.class,
          () -> restClient.get()
              .uri("/admin")
              .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("user", "user"))
              .retrieve()
              .toEntity(String.class)
      );
    }

    @Test
    @DisplayName("/otherで200")
    void otherOk() {
      ResponseEntity<String> responseEntity = restClient.get()
          .uri("/other")
          .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("user", "user"))
          .retrieve()
          .toEntity(String.class);
      assertAll(
          () -> assertEquals(200, responseEntity.getStatusCode().value()),
          () -> assertEquals("Hello other!", responseEntity.getBody())
      );
    }
  }

  @Nested
  @DisplayName("adminロール")
  class AdminRoleTest {
    @Test
    @DisplayName("/adminで200")
    void adminOk() {
      ResponseEntity<String> responseEntity = restClient.get()
          .uri("/admin")
          .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("admin", "admin"))
          .retrieve()
          .toEntity(String.class);
      assertAll(
          () -> assertEquals(200, responseEntity.getStatusCode().value()),
          () -> assertEquals("Hello admin!", responseEntity.getBody())
      );
    }

    @Test
    @DisplayName("/userで403")
    void userForbidden() {
      assertThrows(
          HttpClientErrorException.Forbidden.class,
          () -> restClient.get()
              .uri("/user")
              .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("admin", "admin"))
              .retrieve()
              .toEntity(String.class)
      );
    }

    @Test
    @DisplayName("/otherで200")
    void otherOk() {
      ResponseEntity<String> responseEntity = restClient.get()
          .uri("/other")
          .header(HttpHeaders.AUTHORIZATION, basicAuthHeader("admin", "admin"))
          .retrieve()
          .toEntity(String.class);
      assertAll(
          () -> assertEquals(200, responseEntity.getStatusCode().value()),
          () -> assertEquals("Hello other!", responseEntity.getBody())
      );
    }
  }

  @Nested
  @DisplayName("匿名ユーザー")
  class AnonymousTest {
    @Test
    @DisplayName("/userで401")
    void userUnauthorized() {
      assertThrows(
          HttpClientErrorException.Unauthorized.class,
          () -> restClient.get()
              .uri("/user")
              .retrieve()
              .toEntity(String.class)
      );
    }

    @Test
    @DisplayName("/adminで401")
    void adminUnauthorized() {
      assertThrows(
          HttpClientErrorException.Unauthorized.class,
          () -> restClient.get()
              .uri("/admin")
              .retrieve()
              .toEntity(String.class)
      );
    }

    @Test
    @DisplayName("/otherで401")
    void otherUnauthorized() {
      assertThrows(
          HttpClientErrorException.Unauthorized.class,
          () -> restClient.get()
              .uri("/other")
              .retrieve()
              .toEntity(String.class)
      );
    }
  }

  static String basicAuthHeader(String username, String password) {
    String auth = username + ":" + password;
    return "Basic " + Base64.getEncoder().encodeToString(auth.getBytes());
  }
}

このテストを実行した結果、全部グリーン(成功)になったのでセキュリティ設定は正しかったようです!

実は

securityMatcher()メソッドを使えばいいということは、GitHub Copilot Chatに教えてもらいました。

実際のやり取り👇

ただし、AIの言うことは9割嘘だと僕は思っているので、公式リファレンスで裏を取ったり、実際にテストを書いて動作確認したりした過程が、この記事の内容です。

実際、HttpSecuritysecurityMatcher()メソッドは存在しますがrequestMatcher()メソッドは存在しません。

現在のところでは、AIはヒント程度だと捉えるくらいがちょうどいいと思っています。とは言え、そのヒント程度でも調査時間がかなり短縮されますね。

Discussion