본문 바로가기
데브코스

Spring Security 멀티 필터체인으로 철벽 보안 구성하기 - multiple SecurityFilterChain, multiple Spring Security Configurations

by Rena B 2024. 1. 17.

[서론]

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

https://docs.spring.io/spring-security/reference/servlet/architecture.html

[코드]

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