Skip to content

Spring Security 멀티 필터체인으로 철벽 보안 구성하기

조은비 edited this page Jan 18, 2024 · 2 revisions

스크린샷 2024-01-18 오전 12 21 39

[서론]

빙터파크 프로젝트는 멀티 모듈 구조로 되어 있으며, 각 모듈의 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) 상태일 것을 요구한다. 따라서 인증 여부를 검사한다. ​

스크린샷 2024-01-18 오전 12 49 29

[코드]

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);
    }
}

스크린샷 2024-01-18 오전 10 34 38

프로젝트 실행하면 어떤 엔드포인트에 어떤 필터가 적용되는지 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