13 min to read
Spring-Security-6 다중 필터
각 도메인 마다 필터를 적용하고 싶다면? HttpSecurity multi filter 적용법!
spring-security-6 framework 에서 제공하는 filter 를 정책에 맞게 다양하게 다루는 방법을 소개한다. 기존에 방식과 달라진 방법을 소개하고 다중 필터를 적용하고 결과를 확인한다.
HttpSecurity
spring-security-6 이전 버전에서는 security filter 를 설정하기 위해서 WebSecurityConfigurerAdapter
를 상속하는 설정 클래스를 구현했는데 이는 deprecated 되었다.(사용자가 컴포넌트 기반 보안 구성으로 전환하도록 권장하기 때문.)
만약 기존에 아래와 같이 설정을 했다면,
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
}
}
앞으로 아래와 같이 설정하기를 권장한다.
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
}
참고: Spring Security without the WebSecurityConfigurerAdapter
시나리오
여러 도메인들이 존재하고 RESTApi 로 클라이언트의 요청을 받아 수행한다 가정한다. 모두에게 허용되는 앤드-포인트가 있고 인증이 필요한 앤드-포인트가 있을 때, 각 도메인마다 다른 filter 를 적용시키는 방법을 소개한다.
예시 도메인 Api
두 개의 도메인이 RESTApi 를 가지고 있다. Apple
과 Banana
도메인이 존재하고 문자열을 리턴하는 RestController 가 있다고 가정한다.
아래는 Apple 도메인과 Banana 도메인의 RESTApi 앤드-포인트다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/apple")
public class AppleApi {
@GetMapping("/get")
public String getAppleGetApi() {
System.out.println("AppleApi.getAppleApi");
return "I'm Apple api";
}
@GetMapping("/secret")
public String getAppleSecretApi() {
System.out.println("AppleApi.getAppleSecretApi");
return "Invalid";
}
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/banana")
public class BananaApi {
@GetMapping("/get")
public String getBananaGetApi() {
System.out.println("BananaApi.getBananaApi");
return "I'm Banana Api";
}
@GetMapping("/secret")
public String getBananaSecretApi() {
System.out.println("BananaApi.getBananaSecretApi");
return "Invalid";
}
}
위의 Api 네이밍으로 유추할 수 있듯이 접근 권한은 아래와 같다.
/apple/get
: 모든 접근이 허용된다./apple/secret
: 인증된 접근만 허용된다./banana/get
: 모든 접근이 허용된다./banana/secret
: 인증된 접근만 허용된다.
공통 설정 HttpSecurity Filter
각 도메인에 알맞는 필터들을 구현하기에 앞서 공통적으로 설정되는 부분을 따로 추출해내어 관리할 수 있다. AbstractHttpConfigurer
클래스를 통해 HttpSecurity 설정을 유연하고 모듈화된 방식으로 사용할 수 있다.
AbstractHttpConfigurer
를 상속받아 공통 설정 클래스를 구현한다.
@Component
@RequiredArgsConstructor
public class BaseSecurity extends AbstractHttpConfigurer<BaseSecurity, HttpSecurity> {
private final BaseAccessDeniedHandler baseAccessDeniedHandler;
private final BaseAuthenticationEntryPoint baseAuthenticationEntryPoint;
private boolean flag;
@Override
public void init(HttpSecurity http) throws Exception {
if (flag) {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.exceptionHandling(exceptionHandlingCustomizer -> exceptionHandlingCustomizer
.accessDeniedHandler(baseAccessDeniedHandler)
.authenticationEntryPoint(baseAuthenticationEntryPoint))
;
}
}
public void active() {
this.flag = true;
}
}
위와 같이 AbstractHttpConfigurer
를 상속받은 클래스는 여러 HttpSecurity 구성에 재활용될 수 있다. 어떤 설정들이 있는지 하나씩 살펴보자.
BaseAccessDeniedHandler
:AccessDeniedHandler
를 상속받은 클래스로, 인증된 사용자가 권한이 없는 리소스에 접근하려 할 때 활성화되며AuthenticationException
을 처리한다.-
BaseAuthenticationEntryPoint
:AuthenticationEntryPoint
를 상속받은 클래스로, 인증되지 않은 사용자가 보호된 리소스에 접근하려고 할 때 활성화되며AccessDeniedException
을 처리한다. .csrf(AbstractHttpConfigurer::disable)
: CSRF (Cross-Site Request Forgery) 보호 기능 비활성화한다. RESTApi 서버라면 session session 기반 인증과는 다르게 stateless하기 때문에 비활성화 한다..cors(AbstractHttpConfigurer::disable)
: CORS 정책을 비활성화한다. 추후에 재정의하는 필터 클래스를 따로 만들어 명시적으로 관리하는 것이 용이하다..httpBasic(AbstractHttpConfigurer::disable)
: HTTP 기본 인증을 비활성화한다..headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
:Clickjacking
공격 방지 기능을 비활성화 한다. 브라우저에서 h2-console 을 사용하기 위한 옵션으로 로컬 개발 환경이 h2-console 을 사용하지 않는다면 해당 설정은 없어도 무방하다.-
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
: 세션 관리 정책을STATELESS
로 설정한다. 주로 토큰 기반 인증을 사용하는 RESTApi 서버에서 해당 옵션을 사용한다. 각 요청은 자체 인증 정보를 포함해야 하며, 서버는 상태를 유지하지 않는다. active()
: 해당 메서드를 통해 기본 설정 클래스를 적용 여부를 선택할 수 있다.
BaseAccessDeniedHandler
인증된 사용자가 권한이 없는 리소스에 접근하려 할 때 활성화되며 AuthenticationException
을 처리한다. 정책에 따라 알맞게 구현하면 된다.
@Component
@RequiredArgsConstructor
public class BaseAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) {
// logic...
System.out.println("BaseAccessDeniedHandler.handle");
}
}
BaseAuthenticationEntryPoint
인증되지 않은 사용자가 보호된 리소스에 접근하려고 할 때 활성화되며 AccessDeniedException
을 처리한다. 마찬가지로 정책에 따라 알맞게 구현하면 된다.
@Component
@RequiredArgsConstructor
public class BaseAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) {
// logic...
System.out.println("BaseAuthenticationEntryPoint.commence");
}
}
HttpSecurity 에 공통으로 적용될 설정들은 이외에도 정책 등에 따라 자유롭게 변경할 수 있다.
SecurityFilterChain
해당 프로젝트에 적용할 HttpSecurity 설정을 통해 필터 교체, 선/후행 될 필터 정의, 인증 주체 설정 등 자유롭게 필터 설정을 할 수 있다. 본 글에서는 허용된 앤드-포인트 혹은 인증이 필요한 앤드-포인트만 다루어 본다.
Apple Domain SecurityFilterChain
어떤 앤드-포인트를 타겟팅해서 필터링을 할 것인가? 허용된 앤드-포인트와 인증이 필요한 앤드-포인트는 무엇인가? 를 정의해서 크게 3가지 설정을 설명한다.
Apple Domain 에 RESTApi 요청에 대해 수행할 필터를 정의한다.
@Component
@RequiredArgsConstructor
public class AppleSecurityFilter {
private static final String APPLE_API_PREFIX = "/apple";
private static final String API = APPLE_API_PREFIX + "/**";
private static final String[] ALLOW_LIST = {
APPLE_API_PREFIX + "/get",
};
private static final String[] AUTHENTICATED = {
APPLE_API_PREFIX + "/secret",
};
private final BaseSecurity baseSecurity;
public SecurityFilterChain doFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(API)
.with(baseSecurity, BaseSecurity::active)
.authorizeHttpRequests(auth -> auth
.requestMatchers(ALLOW_LIST).permitAll()
.requestMatchers(AUTHENTICATED).authenticated()
.anyRequest().authenticated())
.build();
}
}
.securityMatcher(API)
: 타겟팅할 앤드-포인트를 정의한다. 현재 설정에 따르면/apple/**
에 대한 요청은AppleSecurityFilter.doFilterChain();
가 실행된다..with(baseSecurity, BaseSecurity::active)
: 위에서 정의한 공통 설정 클래스다. 해당 클래스를 주입 받아 활성화하는 메서드 참조이다..authorizeHttpRequests();
: 해당 메서드를 통해 어떤 요청에 대해 허용할 것이며, 어떤 요청에 대해 인증이 필요한 것인지 설정할 수 있다. 위의 설정은/apple/get
은 허용하며,/apple/secret
은 인증이 필요하다, 그 외에 모든 요청(/apple/**
)은 인증이 필요하다는 설정이다.
Banana Domain SecurityFilterChain
Apple Domain 에서 설명과 같다. (앤드-포인트만 다를뿐)
@Component
@RequiredArgsConstructor
public class BananaSecurityFilter {
private static final String BANANA_API_PREFIX = "/banana";
private static final String API = BANANA_API_PREFIX + "/**";
private static final String[] ALLOW_LIST = {
BANANA_API_PREFIX + "/get",
};
private static final String[] AUTHENTICATED = {
BANANA_API_PREFIX + "/secret",
};
private final BaseSecurity baseSecurity;
public SecurityFilterChain doFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(API)
.with(baseSecurity, BaseSecurity::active)
.authorizeHttpRequests(auth -> auth
.requestMatchers(ALLOW_LIST).permitAll()
.requestMatchers(AUTHENTICATED).authenticated()
.anyRequest().authenticated())
.build();
}
}
필터의 순서
필터가 동작하는 순서가 매우 중요하다. 여러 필터들 중 하나의 필터가 요청에 대한 처리를 수행하게 되면 다른 필터는 동작하지 않는다. 더 넓은 범위의 요청을 .securityMatcher(API)
로 설정한다면 이후에 더 좁은 범위의 요청을 수행하는 필터를 설정한 의미가 없다. 예컨대, .securityMatcher("/**")
먼저 수행할 필터의 범위를 와일드 카드 2개를 사용해서 앤드-포인트를 매칭시킨다면 이후에 사용할 .securityMatcher("/apple")
apple filter 는 수행되지 않는다.
이처럼 순서는 매우 중요하다. 올바르게 필터를 동작하게 하기 위해선 @Component
어노테이션을 사용하여 스프링 빈으로 자동 등록하고 순서를 정의할 것이다.
필터 실행 순서 정의하기
순서 정의를 위한 클래스를 만든다. 각 필터들을 한데 모아 순서만 설정해주면 된다. @Order
어노테이션을 통해 순서를 설정해준다. 기본 설정 값은 Integer.MAX_VALUE;
이다.
@Configuration
@RequiredArgsConstructor
public class FilterConfig {
private final AppleSecurityFilter appleSecurityFilter;
private final BananaSecurityFilter bananaSecurityFilter;
private final FinalSecurityFilter finalSecurityFilter;
@Bean
@Order(1)
public SecurityFilterChain appleSecurityFilterChain(HttpSecurity http) throws Exception {
return appleSecurityFilter.doFilterChain(http);
}
@Bean
@Order(2)
public SecurityFilterChain bananaSecurityFilterChain(HttpSecurity http) throws Exception {
return bananaSecurityFilter.doFilterChain(http);
}
@Bean
@Order(3)
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
return finalSecurityFilter.doFilterChain(http);
}
}
FinalSecurityFilter
: 마지막 수행될 필터로 모든 요청에 대해 인증이 필요하다는 설정이 담겨있다..securityMatcher("/**")
설정과.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
설정이 담겨있다.
apple filter 를 1번으로 banana filter 를 2번으로 등록했다. final filter 를 3번으로 등록했다.
Filter 적용 결과 보기
postman 으로 요청을 보낸 후 어떤 필터가 작동되었는지 확인해 본다. 우선 스프링부트 애플리케이션을 실행시키면 SecurityFilterChain 이 등록되는 순서를 확인할 수 있다.
세부 정보를 콘솔에서 보기위해 application.yml
에서 logging 을 설정해준다. security 와 관련된 모든 로깅에 대해 TRACE 수준으로 설정한다.
logging:
level:
org.springframework.security: TRACE
Apple Domain RESTApi
/apple/get
요청을 보내게 되면 정상 응답과 함께 문자열을 리턴받으면 된다. 해당 요청에는 AppleFilter
가 작동해야 한다. 요청에 대한 응답으로는 postman 에서 확인할 수 있다.
어떤 필터가 동작했는지 console 에서 확인할 수 있다. 로깅 수준을 TRACE 로 할 경우 상세한 내역을 console 에서 확인할 수 있다. Trying to match request against DefaultSecurityFilterChain [RequestMatcher=Or [Mvc [pattern='/apple/**']],
요청에 대해 일치하는 패턴을 찾고 해당 필터를 수행시킨다.
주황 박스를 통해 여러 필터 체인을 확인할 수 있다. spring-security 에서 충분한 보안을 제공하며 사용자 정의 추가할 수 있다. (추후 자세히 다룰 예정)
인증이 필요한 요청에 대해서는 AccessDeniedException
이 발생하고 BaseAuthenticationEntryPoint.commence();
가 수행하게 된다.
Banana Domain RESTApi
apple api 와 같은 설정을 가지고 있으며 console 에 출력되는 내용으로 필터가 어떻게 수행되는지 알아볼 수 있다. postman 요청 결과는 같으며 console 에 출력된 내용을 살펴본다.
요청을 보낸 앤드-포인트는 /banana/get
이며 해당 요청에 대한 매핑을 위해 필터가 2개 동작했다. 위에서 설정한 방법대로 1번 필터는 /apple/**
에서 매칭이 되고 2번 필터는 /banana/**
에서 매칭된다. 요청 매칭을 위해 1번 필터부터 차례대로 꺼냈다는 뜻이 된다. /banana/get
요청은 /apple/**
과 매칭되지 않으므로 두 번째 필터를 꺼냈고 매칭이 된 것이다. 해당 앤드-포인트는 허용된 앤드-포인트이므로 콘솔에 결과가 출력되는 것을 확인할 수 있다.
인증이 필요한 요청을 인증 없이 보낸다면 아래와 같이 console 에 출력된다.
앤드-포인트 매칭을 위해 첫 번째 필터부터 꺼내어 확인했으며 결과적으로 두 번째 필터에서 매칭이 되었고 필터링을 수행했다. 마지막에 AccessDeniedException
이 발생하고 BaseAuthenticationEntryPoint.commence();
를 실행한다.
final security
apple 과 banana 가 정의하지 않은 앤드-포인트는 finalSecurityFilter 에서 매칭이 된다. (/**
) 유효하지 않은 요청을 보내고 console 에 출력물을 확인해 보자.
첫 번째 필터부터 두 번째 필터를 지나 마지막 필터에 매칭이 되어 필터링을 수행하는 것을 확인할 수 있다. 해당 요청은 인증이 필요하므로 AccessDeniedException
이 발생하고 BaseAuthenticationEntryPoint.commence();
를 실행한다.
Comments