Spring Securityで、SecurityFilterChainを複数作る
「一般ユーザーと管理者でセキュリティ設定を分けたい」「画面とWeb APIでセキュリティ設定を分けたい」という要件を実現する方法を調査しました。
Spring Securityとは何ぞやという人は、まず『プロになるためのSpring入門』を読むことをおすすめします。
環境
- JDK 21
- Spring Boot 3.5
- Spring Security 6.5
まずは上手くいかなかった方法
今回は一般ユーザーと管理者でセキュリティ設定を分けることを考えます。
こんな感じでSecurityFilterChain
を一般ユーザー用と管理者用で2つ作ります。
SecurityFilterChain
に@Order
を付けて、指定した整数の昇順にSecurityFilterChain
が評価されるようにします。
@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();
}
}
コントローラーはこんな感じ。
@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
を次のように修正します。
@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でテストを書いて、動作確認してみます。
@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割嘘だと僕は思っているので、公式リファレンスで裏を取ったり、実際にテストを書いて動作確認したりした過程が、この記事の内容です。
実際、HttpSecurity
にsecurityMatcher()
メソッドは存在しますがrequestMatcher()
メソッドは存在しません。
現在のところでは、AIはヒント程度だと捉えるくらいがちょうどいいと思っています。とは言え、そのヒント程度でも調査時間がかなり短縮されますね。
Discussion