Servlet 기반 애플리케이션에서의 Spring Security의 상위 수준 아키텍처에 대해 정리한 글입니다. Java 서블릿에 대한 기본 개념은 알고 있다는 전제하에 작성됐습니다.
Spring WebFlux가 나오기 전에는 'Servlet 기반'이라거나 'Spring MVC 기반'이라는 용어가 필요 없었는데, 리액티브 애플리케이션쪽으로 Spring WebFlux가 나옴에 따라 명확한 구분이 필요해졌습니다. 이 글의 내용은 Java의 서버쪽 기능을 확장하는 소프트웨어 컴포넌트인 Servlet 기반의 MVC 프레임워크 Spring MVC 상에서의 Spring Security 아키텍처라고 보시면 됩니다.
Servlet Fliter
Servlet 기반 애플리케이션에 대한 Spring Security의 구조를 알기 위해서는 먼저 Servlet Filter에 대해서 이해를 해야 합니다.
Servlet Filter는 Servlet 규약 2.3부터 추가된 기능으로 Servlet 앞단에서 요청(request)이나 응답(response)을 가로채서 변형하거나 그 것으로부터 정보를 얻어 특정한 작업을 할 수 있는 기능을 제공합니다. 클라이언트가 애플리케이션으로 특정 URI에 대해 요청을 보내면 Servler 콘테이너는 요청된 URI 상응하는 Servlet과 그 앞단에 설정된 필터들을 하나의 필터 체인(Filter Chain)으로 만듭니다. 이렇게 필터 체인이 형성됐을 때 하나의 필터는 구체적으로 다음의 두 작업 중에 하나를 하게 됩니다.
- 하위의 필터나 서블릿 호출을 막습니다. 이런 경우에는 해당 필터에서 응답(HttpServletResponse)을 작성합니다.
- 하위 필터나 서블릿에서 사용할 HttpServletRequest와 HttpServletResponse를 변경합니다.
하나의 필터는 아래 방향으로의 필터나 서블릿에만 영향을 미치기 때문에 필터의 순서는 매우 중요합니다. 그리고, Servlet Filter는 서블릿 규약에 맞게 jakarta.servlet.Filter 인터페이스를 구현해야 합니다.
Spring의 Servlet Filter에 대한 지원
Spring MVC에서는 Servlet Filter에서도 Spring Bean을 사용할 수 있도록 DelegatingFilterProxy라는 jakarta.servlet.Filter 인터페이를 구현한 구현체를 제공합니다. Spring MVC를 사용하면서 Servlet Fliter를 만들고 싶은 데, Spring이 제공하는 다양한 기능들을 그대로 사용하고 싶은 경우, Servlet Filter 표준대로 jakarta.servlet.Filter를 구현하는 Spring Bean을 만든 다음 이를 DelegatingFilterProxy로 감싸서 실행되게 설정합니다.
여기까지가 일반적인 Servlet Filter와 Spring MVC에서의 Filter 지원 내용이고, 다음부터는 이런 구조에서의 Spring Security에 대한 이야기입니다.
FilterChainProxy와 SecurityFilterChain
Spring Security의 필터 전략은 다음과 같습니다.
- Spring의 기능을 사용하기 위해 DelegatingFilterProxy의 힘을 빌려 FilterChainProxy라는 필터 빈을 시작점으로 합니다.
- Spring Security와 관련된 필터들은 별도로 SecurityFilterChain 단위로 묶어서 FilterChainProxy와 연계 시킵니다.
이렇게 함으로써 Spring Security를 사용하는 개발자는 Servlet Filter 계층을 특별히 신경 쓰지 않고 Security Filter들만 신경써도 Servlet Filter 단에서 행해지기를 원하는 작업을 모두 할 수 있게 됩니다.
다르게 다시 한 번 표현하면, Spring Security는 표준 Servlet Flter 체계와 이를 지원하는 Spring MVC의 DelegatingFilterProxy에 맞게 FilterChainProxy라는 빈(필터)을 제공하고, Spring Security와 관련된 필터들은 SeucrityFilterChain으로 묶어 하나로 일원화되어 관리하는 구조를 갖습니다.
이러한 구조는 갖는 이점을 좀 더 구체적으로 얘기하면 다음과 같습니다.
- Spring Security의 Servlet 지원에 대한 단일화된 시작 지점을 갖습니다. 예를 들어 Spring Security와 관련된 디비깅이 필요한 경우 FilterChainProxy에서 시작하면 됩니다.
- FilterChainProxy가 중심에 있기 때문에 Spring Security 관련된 전체적인 작업들, 예를 들어 메모리 누스를 피하기 위해 SecurityContext를 비운다거나 특정 유형의 공격을 방어하는 Spring Security의 HttpFirewall 같은 것을 한 번에 적용할 수 있습니다.
- Servlet Filter는 특정 Servlet에 종속되므로 해당 Servlet에 설정된 URI 패턴에 종속되게 합니다. 하지만, FilterChainProxy는 SecurityFilterChain을 특정 URI 패턴에 따라 개별적으로 관리할 수 있어서 보다 유연성을 갖습니다.
Security Filters
기본적으로 필요한 Security 필터들은 Spring Security에서 대부분 제공하기 때문에 새로운 필터를 만들기 보다는 제공되는 필터들 중에 필요한 것들만 설정하고 SecurityFilterChain으로 묶어 FilterChainProxy에 추가하게 됩니다.
제공되는 필터들은 논리적으로 순서가 맞아야 합니다. 예를 들어 인증(authentication) 필터는 인가(authorization) 필터보다 앞에 와야 합니다. 이 순서와 관련해서는 크게 신경 쓸 필요가 없는데 Spring Security 내부적으로 순서가 잘 정의돼 있기 때문입니다. 만약 필터 순서가 궁금한 경우에는 여기를 참고하면 됩니다. 한 번쯤은 봐 두거나 위치만 기억해 뒀다가 순서를 알아야 되는 경우 참조하면 좋습니다.
Security 필터 설정 및 순서와 관련해서 예를 들어 보면 다음과 같습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
}
위의 코드는 다음과 같은 Security 필터들을 설정하고 있습니다.
- CsrfFilter : HttpSecurity#csrf
- UsernamePasswordAuthenticatonFilter: HttpSecurity#fromLogin
- BasicAuthenticationFilter: HttpSecurity#httpBasic
- AuthorizationFilter: HttpSecurity#authorizeHttpRequests
이러한 필터들은 코드에서 설정한 순서를 따르는 것이 아니라 앞서 얘기한 것처럼 미리 정의된 순서를 따르게 됩니다. 그러므로, 설정은 CsrfFilter, AuthorizationFilter ... 순이지만 필터는 정해진 순서대로 CSRF 필터 -> 인증 관련 필터 2개 -> 인가 필터 순으로 적용됩니다.
맞춤화(Custom) 필터 추가
자신만의 Security 필터가 필요한 경우 필터를 정의한 후에 필터 체인에 추가할 수 있습니다. 예를 들어 특정 IP 주소만 허용하는 Security 필터를 추가한다고 해 보겠습니다. 코드는 대략적으로 이런 모습일 겁니다(예시 코드이기 때문에 최대한 단순하게 만들었습니다).
public class IpAddressFilter implements Filter {
private static final List<String> ALLOWED_IP_ADDRESSES = List.of(
"127.0.0.1"
);
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String remoteAddress = request.getRemoteAddr();
if (ALLOWED_IP_ADDRESSES.contains(remoteAddress)) {
filterChain.doFilter(request, response);
return;
}
throw new AccessDeniedException("Access Denied");
}
}
이제 이 필터를 다음과 같은 식으로 등록합니다.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterBefore(new IpAddressFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
이 예에서는 인증 필터 앞단에 IpAddressFilter를 등록함으로써 인증 전에 먼저 IP 주소가 접근 허락된 것인지를 확인하게 합니다.
Spring Boot에서 맞춤화 필터 사용시 주의점
Spring Boot에서 맞춤화 Security 필터를 만들 때 주의 사항이 있습니다. 의존 주입 같은 기능을 사용하고 싶어 필터를 스프링 빈으로 등록하는 경우 Spring Boot는 이 빈(bean) 필터를 임베디드 컨테이너의 필터 중 하나로 등록합니다. 즉, Servlet Filter로 등록하게 됩니다. 이렇게 되면 해당 필터는 Servlet 필터 체인에서 한 번, SecurityFilterChain에서 한 번, 이렇게 두 번 동작하게 됩니다. 이를 방지하기 위해서는 다음과 같이 합니다.
@Bean
public FilterRegistrationBean<IpAddressFilter> ipAddressFilterRegistration(IpAddressFilter filter) {
FilterRegistrationBean<IpAddressFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
IpAddressFilter에 대한 FiterRegistrationBean을 등록해 주는데, enabled를 false로 지정해 주는 겁니다. 그러면, 자동으로 임베디드 컨테이너에 Servlet Filter로 등록되지 않습니다.
참고
'이거저거' 카테고리의 다른 글
Upstage Solar API 사용해 보기 2 - Open WebUI 에서 사용 (2) | 2024.11.14 |
---|---|
유니코드 한글 자소 분리 방법 (0) | 2024.10.26 |
앤트로픽의 프롬프트 라이브러리 (0) | 2024.03.22 |
Upstage Solar API 사용해 보기 (1) | 2024.02.25 |