-
Notifications
You must be signed in to change notification settings - Fork 5
Spring Security 멀티 필터체인으로 철벽 보안 구성하기
빙터파크 프로젝트는 멀티 모듈 구조로 되어 있으며, 각 모듈의 API 서버가 독립적으로 운영된다. 이 구조에서 중요한 역할을 하는 것이 core-security 모듈인데, 이 모듈은 우리 서버로 들어오는 모든 API 요청의 수문장 역할을 한다. 이 글에서는 이러한 보안 설정의 핵심 요소인 SecurityFilterChain들을 소개하고, 각각이 어떻게 우리 시스템을 지키는지 설명하고자 한다.
우리의 보안 체계를 네 명의 문지기 비유로 설명해보자. 이 네 명의 문지기 중 어느 문지기에게 갈지 결정하는 것은 대왕 문지기, 즉 FilterChainProxy이다. 이 대왕 문지기는 먼저 당신이 어느 엔드포인트로 가고자 하는지 묻는다. 그리고 당신의 응답에 따라 적절한 문지기에게 당신을 안내한다. 한번 어느 문지기에게 안내되면, 다른 문지기를 만날 기회는 없다. 각 문지기는 고유의 질문 리스트(Filters)를 가지고 있으며, 이 모든 질문에 만족해야만 우리의 왕국, 즉 DispatcherServlet에 들어갈 수 있다. 문지기는 SecurityFilterChain을 의미한다.
1번 문지기 - permitAll
- 첫 번째 문지기는 기본적인 검증을 한다. 주로 이상한 문자가 이름에 섞이지는 않았는지 등을 확인한다. 신분증 검사는 하지 않는다.
2번 문지기 - hasRole("XXX")
- 두 번째 문지기는 훨씬 엄격하다. 당신이 이 왕국의 신분증을 가지고 있는지, 허용된 계급인지를 검사한다.
- JWT 토큰의 유효성과 권한을 철저히 검사한다.
- 토큰 인증에 실패하여 '인증 예외'가 발생하면 JwtAuthenticationEntryPoint로 처리되어 401 UNAUTHORIZED 응답으로 반환한다.
- 토큰 인증은 성공하였으나 권한이 없어 '인가 예외'가 발생하면 JwtAccessDeniedHandler에 의해 처리되어 403 FORBIDDEN 응답으로 반환한다.
**3번 문지기 - oauth**
- 세 번째 문지기는 OAuth 관련 엔드포인트를 전담한다. 이 문지기는 OAuth 인증 과정을 관리한다.
4번 문지기 - 이외 기타 엔드포인트
- 마지막 문지기는 1,2,3번에 해당되지 않는 나머지 모든 엔드포인트를 담당한다.
- 나머지 모든 엔드포인트에 대해서는 기본적으로 인증된(authenticated) 상태일 것을 요구한다. 따라서 인증 여부를 검사한다.
WebSecurityConfig.java
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
/**
* permitAll 권한을 가진 엔드포인트에 적용되는 SecurityFilterChain 입니다.
*/
@Bean
public SecurityFilterChain securityFilterChainPermitAll(HttpSecurity http) throws Exception {
configureCommonSecuritySettings(http);
http.securityMatchers(matchers -> matchers.requestMatchers(requestPermitAll()))
.authorizeHttpRequests()
.anyRequest()
.permitAll();
return http.build();
}
private RequestMatcher[] requestPermitAll() {
List<RequestMatcher> requestMatchers = List.of(
antMatcher("/api/*/auth/**"),
antMatcher("/api/*/members/signup");
...
);
return requestMatchers.toArray(RequestMatcher[]::new);
}
/**
* OAuth 관련 엔드포인트에 적용되는 SecurityFilterChain 입니다.
*/
@Bean
public SecurityFilterChain securityFilterChainOAuth(HttpSecurity http) throws Exception {
configureCommonSecuritySettings(http);
http
.securityMatchers(matchers -> matchers
.requestMatchers(
antMatcher("/login"),
antMatcher("/login/oauth2/code/kakao"),
antMatcher("/oauth2/authorization/kakao")
))
.authorizeHttpRequests().anyRequest().permitAll().and()
.oauth2Login(oauth2Configurer -> oauth2Configurer
.loginPage("/login")
.successHandler(oauthSuccessHandler)
.userInfoEndpoint()
.userService(oAuth2UserService));
return http.build();
}
/**
* 인증 및 인가가 필요한 엔드포인트에 적용되는 SecurityFilterChain 입니다.
*/
@Bean
public SecurityFilterChain securityFilterChainAuthorized(HttpSecurity http) throws Exception {
configureCommonSecuritySettings(http);
http
.securityMatchers(matchers -> matchers
.requestMatchers(requestHasRoleUser())
.requestMatchers(requestHasRoleAdmin())
.requestMatchers(requestHasRoleSuperAdmin())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(requestHasRoleSuperAdmin()).hasAuthority(ROLE_SUPERADMIN.name())
.requestMatchers(requestHasRoleAdmin()).hasAuthority(ROLE_ADMIN.name())
.requestMatchers(requestHasRoleUser()).hasAuthority(ROLE_USER.name()))
.exceptionHandling(exception -> {
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint);
exception.accessDeniedHandler(jwtAccessDeniedHandler);
})
.addFilterAfter(jwtAuthenticationFilter, ExceptionTranslationFilter.class);
return http.build();
}
// SUPERADMIN 권한이 필요한 엔드포인트
private RequestMatcher[] requestHasRoleSuperAdmin() {
List<RequestMatcher> requestMatchers = List.of(antMatcher("/api/*/admin/management/**"));
return requestMatchers.toArray(RequestMatcher[]::new);
}
// ADMIN 권한이 필요한 엔드포인트
private RequestMatcher[] requestHasRoleAdmin() {
List<RequestMatcher> requestMatchers = List.of(
antMatcher(POST, "/api/*/events"),
...
);
return requestMatchers.toArray(RequestMatcher[]::new);
}
// USER 권한이 필요한 엔드포인트
private RequestMatcher[] requestHasRoleUser() {
List<RequestMatcher> requestMatchers = List.of(
antMatcher(DELETE, "/api/*/event-reviews/*"),
...
);
return requestMatchers.toArray(RequestMatcher[]::new);
}
/**
* 위에서 정의된 엔드포인트 이외에는 authenticated 로 설정
*/
@Bean
public SecurityFilterChain securityFilterChainDefault(HttpSecurity http) throws Exception {
configureCommonSecuritySettings(http);
http.authorizeHttpRequests()
.anyRequest().authenticated()
.and()
.addFilterAfter(jwtAuthenticationFilter, ExceptionTranslationFilter.class)
.exceptionHandling(exception -> {
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint);
exception.accessDeniedHandler(jwtAccessDeniedHandler);
});
return http.build();
}
private void configureCommonSecuritySettings(HttpSecurity http) throws Exception {
http
.csrf().disable()
.anonymous().disable()
.formLogin().disable()
.httpBasic().disable()
.rememberMe().disable()
.headers().frameOptions().disable().and()
.logout().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
프로젝트 실행하면 어떤 엔드포인트에 어떤 필터가 적용되는지 info 레벨 로그가 뜬다. 네 개의 필터 체인을 등록하였으므로 4줄의 로그가 출력된다.
- 명확한 책임 분리 : •각 SecurityFilterChain은 서로 다른 보안 요구사항을 담당한다. 보안 구성의 명확성과 유지보수성을 향상시킨다.
- 확장성 : 특정 SecurityFilterChain에 대한 변경이나 확장이 필요할 때, 다른 필터체인에 영향을 주지 않고 독립적으로 작업할 수 있다.
- 보안 오류의 격리 : 하나의 SecurityFilterChain에서 문제가 발생해도, 다른 필터체인에는 영향을 주지 않는다.
- 보안 로직의 재사용 : 공통적인 보안 설정이나 로직은 여러 필터체인에 걸쳐 재사용될 수 있다.
비유를 통해 SecurityFilterChain의 역할을 설명해보았다. 특히 SecurityFilterChain은 요구사항에 맞게 분리하여 작성하는 접근 방식은 보안 설정을 더욱 직관적이고 관리하기 쉬운 방식으로 만들어준다. 독립적으로 수행되는 SecurityFilterChain의 특성을 이해하면, 가독성과 유지보수성을 챙길 수 있는 코드를 작성할 수 있다. Spring Security의 이러한 유연한 구성 덕분에, 우리는 각기 다른 요구사항을 가진 다양한 API 서버를 효과적으로 관리하고 보호할 수 있다.
참고
https://www.danvega.dev/blog/multiple-spring-security-configs
https://docs.spring.io/spring-security/reference/servlet/architecture.html