[JWT를 활용한 로그인, 로그아웃, 회원탈퇴 API 구현 방법]에 대해 설명하기 전, 공식 문서를 보면서 Spring Security의 기본 개념(Filter)에 대해 학습해보려고 한다.
API를 구현할 때 사용한 자바, 스프링 부트, 라이브러리 버전은 아래와 같다.
Java: 17
Spring Boot: 3.3.1
Spring Security: 6.1.9 → 공식 문서 버전도 동일
Spring Security 기본 개념
Spring Security란?
Spring Security는 인증 및 인가, 보안을 위해 사용하는 프레임워크로, 자바 8 이상인 환경에서만 동작한다. build.gradle 의존성에 아래 한 줄을 추가하면 사용할 수 있다.
- 추가하고 localhost:8080에 접속하면 로그인 화면이 뜨게 된다. 아이디는 user고, 비밀번호는 Spring 콘솔에 로그로 찍힌다. (ex. 8e885747-93k2-3926-01l1-mm36bk053463)
dependencies {
implementation "org.springframework.boot:spring-boot-starter-security"
}
기능은 크게 아래 4가지로 나눌 수 있다.
- 인증(Authentication): 특정 자원에 접근하려고 시도하는 사용자의 신원 확인
- 인가(Authorization): 인증 이후 특정 자원에 접근하도록 허용된 사용자 결정
- 보안(Protection Against Exploits): CSRF, HTTP Headers, HTTP Requests 등 공격에 대한 보호
- 통합(Integrations): Cryptography, Spring Data, Java's Concurrency APIs, Jackson, Localization 등 수많은 프레임워크와 API와의 통합
Spring Security는 표준 Servlet 필터를 사용해 Servlet 컨테이너와 통합한다. 즉, Servlet 컨테이너에서 실행되는 모든 애플리케이션과 함께 동작한다. 따라서 Servlet 기반 애플리케이션이라면 Spring을 사용하지 않아도 Spring Security를 활용할 수 있다. (딱히 그럴 일은 없을 것 같지만)
Spring Boot Security Auto Configuration
- @EnableWebSecurity 애노테이션을 사용하면 Spring Security의 기본 Filter Chain(SecurityFilterChain)을 @Bean으로 등록할 수 있다.
- Spring Boot는 애플리케이션의 Filter Chain에 어떤 Filter든 @Bean으로 추가할 수 있다.
@EnableWebSecurity
@Configuration
public class DefaultSecurityConfig {
@Bean
@ConditionalOnMissingBean(UserDetailsService.class)
InMemoryUserDetailsManager inMemoryUserDetailsManager() {
String generatedPassword = // ...;
return new InMemoryUserDetailsManager(User.withUsername("user")
.password(generatedPassword).roles("ROLE_USER").build());
}
@Bean
@ConditionalOnMissingBean(AuthenticationEventPublisher.class)
DefaultAuthenticationEventPublisher defaultAuthenticationEventPublisher(ApplicationEventPublisher delegate) {
return new DefaultAuthenticationEventPublisher(delegate);
}
}
Spring Security 구조
Servlet 기반 애플리케이션 안에서 동작하는 Spring Security의 구조를 알아보자.
우선, Spring Security의 Servlet 지원은 Servlet Filters를 기반으로 한다. 아래 그림은 단일 HTTP 요청에 대한 핸들러가 어떻게 계층화되는지 보여준다.
- 클라이언트가 애플리케이션에 요청을 보내면, Servlet 컨테이너에서 FilterChain을 만든다.
- FilterChain엔 Filter 인스턴스와 HttpServletRequest를 처리할 Servlet이 포함된다. Spring MVC 애플리케이션에서 Servlet은 DispatcherServlet의 인스턴스라고 보면 된다.
- 이후 요청은 많은 Filter를 거쳐 아래로 전달(Downstream)되며, 마지막엔 Servlet에 도달해 요청이 처리된다. 따라서 어떤 순서로 각각의 Filter가 호출되는지가 가장 중요하다.
- 맨 밑에 존재하는 Servlet은 한 번에 하나의 HttpServletRequest와 HttpServletResponse를 처리할 수 있다.
- Filter를 하나씩 지나 내려오면서, 각각의 Filter마다 HttpServletResponse를 작성하고 다음 Filter로 넘긴다. 또, Filter마다 상단 Filter 인스턴스에서 작성한 HttpServletRequest(Response)를 수정할 수 있다.
코드로는 아래처럼 나타낼 수 있다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
참고
Transport 계층에서 Application 계층에서 보낸 HTTP 메시지에 Header를 붙여 Network 계층으로 보내는 것과 비슷하다고 보면 된다. 물론 Transport 계층에서 Application 계층에서 보낸 HTTP 메시지를 수정할 수 없다는 점에서 차이가 있다. (모든 계층에 적용)
DelegatingFilterProxy
Spring에선 Filter 구현체로 DelegatingFilterProxy를 제공한다. DelegatingFilterProxy는 Servlet 컨테이너의 라이프 사이클과 Spring의 ApplicationContext를 연결하는 역할을 한다.
- Servlet 컨테이너는 자체 표준을 사용해 Filter 인스턴스를 등록하는 걸 허용한다. 규격만 잘 지키면 Custom Filter만 만들어 @Bean으로 등록할 수 있다는 것이다. 그런데 여기서 등록한 빈은 Spring에서 정의한 빈으로 인식되지 않는다.
- 표준 Servlet 컨테이너 메커니즘을 통해 DelegatingFilterProxy를 등록하면, Filter를 구현한 Spring Bean(Bean Filter0)에 모든 일을 위임(Delegate)할 수 있다.
DelegatingFilterProxy는 Spring의 ApplicationContext에서 Bean Filter0을 찾아 호출한다. Pseudo 코드로 보면 아래와 같다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
DelegatingFilterProxy의 또 다른 장점은 Filter 빈 인스턴스를 찾는 걸 지연시킬 수 있다는 점이다.
- 컨테이너는 컨테이너가 시작되기 전에 Filter 인스턴스를 등록해야 한다.
- 그러나 Spring은 ContextLoaderListener를 사용해 Spring 빈을 로드하는데, 이 과정은 Filter 인스턴스 등록해야 할 때까지 완료되지 않는다.
FilterChainProxy
FilterChainProxy는 Spring Security에서 제공하는 특별한 Filter로, SecurityFilterChain을 통해 많은 Filter 인스턴스에 위임할 수 있게 해 준다. FilterChainProxy도 빈이기 때문에, 보통은 DelegatingFilterProxy로 감싸져 있다.
SecurityFilterChain
SecurityFilterChain은 FilterChainProxy가 현재 요청에 대해 어떤 Spring Security Filter 인스턴스가 호출돼야 하는지 결정하기 위해 사용한다.
SecurityFilterChain에 존재하는 Security Filters는 보통 빈이지만, DelegatingFilterProxy를 대신해 FilterChainProxy와 함께 등록된다. Servlet 컨테이너나 DelegatingFilterProxy에 직접 등록하는 것보다 FilterChainProxy를 쓰는 것이 더 많은 장점을 갖고 있다. 아래에서 몇 가지 이유를 알아보자.
- Spring Security의 Servlet 지원의 모든 것에 대한 시작점을 제공한다. 따라서 Spring Security의 Servlet 지원에 대한 트러블슈팅을 하려면, FilterChainProxy에 디버깅 포인트를 추가하면 된다.
- FilterChainProxy는 Spring Security 사용의 핵심이라고 볼 수 있기 때문에, 필수적인 일을 수행할 수 있다. 예를 들어, SecurityContext가 막으려고 하는 메모리 누수를 막을 수 있다. 또, Spring Security의 HttpFirewall을 적용해 애플리케이션을 보호할 수 있다.
- SecurityFilterChain이 언제 호출돼야 하는지를 유연하게 결정할 수 있다. Servlet 컨테이너에서는 URL에 따라 Filter 인스턴스를 호출할 수 있다. 그러나 FilterChainProxy는 RequestMatcher 인터페이스를 사용해 HttpServletRequest에 있는 것들에 따라 호출을 결정할 수 있다. 그림으로는 아래와 같이 나타낼 수 있다.
- FilterChainProxy는 어떤 SecurityFilterChain이 사용될지 결정한다. 이때 가장 먼저 매칭되는 SecurityFilterChain이 호출된다. 예를 들어 /api/message URL이라면, SecurityFilterChain0만 호출된다. 0번 Chain에서 매칭되지 않았다면, 1번부터 N번까지의 모든 Chain에서 URL 매칭을 계속해서 시도한다.
- 각각의 SecurityFilterChain은 고유하며 고립된 상태로 설정될 수 있다. 예를 들어, 특정 URL에 대한 요청을 무시하고 싶다면, Filter 인스턴스가 아무것도 없는 SecurityFilterChain을 만들면 된다.
Security Filters
Security Filters는 SecurityFilterChain API와 함께 FilterChainProxy에 주입된다. Filters마다 인증, 인가, 보안 등의 다양한 다른 목적을 가질 수 있다. Filters는 적절한 시기에 호출되도록 특별한 순서로 실행된다. 예를 들어, 인증을 수행하는 Filter는 인가를 수행하는 Filter보다 이전에 호출돼야 한다. 필수는 아니지만 Spring Security의 Filter 순서를 대략적으로 알고 있으면 좋다. 자세한 순서는 여기서 확인할 수 있다. 아래 코드를 통해 Filter의 대략적인 순서를 알아보자. 물론 코드에서 순서대로 적을 필요는 없다.
Filter | Added by | Description |
CsrfFilter | HttpSecurity#csrf | CSRF 공격에 대한 보안 |
UsernamePasswordAuthenticationFilter | HttpSecurity#formLogin | 요청 인증 |
BasicAuthenticationFilter | HttpSecurity#httpBasic | |
AuthorizationFilter | HttpSecurity#authorizeHttpRequests | 요청 인가 |
@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();
}
}
Printing the Security Filters
위에 없는 Filter 인스턴스는 DEBUG나 TRACE로 로깅을 찍어서 확인할 수 있다. application.yml에 아래 한 줄을 추가하자.
logging.level:
org.springframework.security=TRACE
Adding a Custom Filter to the Filter Chain
원한다면 커스텀 Filter를 만들어 SecurityFilterChain에 추가할 수 있다.
// Custom Filter 예시
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String tenantId = request.getHeader("X-Tenant-Id"); (1)
boolean hasAccess = isUserAllowed(tenantId); (2)
if (hasAccess) {
filterChain.doFilter(request, response); (3)
return;
}
throw new AccessDeniedException("Access denied"); (4)
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterBefore(new TenantFilter(), AuthorizationFilter.class); // Custom Filter 등록
return http.build();
}
Adding a Custom Filter to the Filter Chain as Spring Bean
Custom Filter에 @Component 애노테이션을 사용하거나 Configuration에서 해당 Filter를 빈으로 선언할 때 주의해야 한다. Spring Boot에서 embedded 컨테이너로 자동 등록하기 때문에 Filter가 2번 호출될 수 있다. (컨테이너에 의해 1번, Spring Security에 의해 1번)
따라서 중복 호출을 막기 위해 아래와 같이 구현하면 된다. Filter를 빈으로 등록할 때, embedded 컨테이너에는 등록하지 않도록 설정하는 코드다.
@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
Handling Security Exceptions
ExceptionTranslationFilter를 통해 AccessDeniedException과 AuthenticationException을 HTTP 응답으로 변환할 수 있다. ExceptionTranslationFilter는 Security Filters 중 하나로 FilterChainProxy에 주입돼 있다. 아래 그림을 보며 관계를 알아보자.
- ExceptionTranslationFilter에서 FilterChain.doFilter(request, response)를 호출해 애플리케이션의 나머지를 호출한다.
- try-catch문처럼 진행되기 때문에 애플리케이션이 AccessDeniedException이나 AuthenticationException를 발생시키지 않는다면 아무것도 하지 않는다.
- 요청한 사용자가 인증되지 않거나 AuthenticationException이 발생하면 인증을 시작한다.
- SecurityContextHolder가 지워진다.
- HttpServletRequest가 RequestCache에 저장되고, 인증이 성공적으로 마무리됐다면 원래 Request가 다시 진행된다.
- AuthenticationEntryPoint를 사용해 클라이언트로부터 자격 증명을 요청한다. 예를 들어, 로그인 페이지로 리다이렉션 되거나 WWW-Authenticate 헤더를 응답에 담아 전송한다.
- AccessDeniedException이 발생했다면, 접근이 거부된다. 이때 AccessDeniedHandler가 거부된 접근을 처리하기 위해 호출된다.
Pseudo 코드로 보면 아래와 같이 나타낼 수 있다.
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
Saving Requests Between Authentication
요청(HttpServletRequest)이 인증되지 않았다면, 요청은 잠시 미뤄두고 인증을 위한 과정을 진행해야 한다. 인증이 성공적으로 끝나면 미뤄뒀던 요청을 가져와 다시 진행해야 한다. 이때 요청을 미뤄두기 위해 사용하는 것이 RequestCache다. 이 모든 과정은 RequestCacheAwareFilter에서 진행된다.
인증되지 않은 요청이 저장되는 걸 막으려면, NullRequestCache 구현체를 사용하면 된다.
@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache(); // 기본값
requestCache.setMatchingRequestParameterName("continue");
http
// ...
.requestCache((cache) -> cache
.requestCache(requestCache)
);
return http.build();
}
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
RequestCache nullRequestCache = new NullRequestCache();
http
// ...
.requestCache((cache) -> cache
.requestCache(nullRequestCache)
);
return http.build();
}
RequestCacheAwareFilter
RequestCache를 사용해 HttpServletRequest를 저장한다.
참고자료