- JDK: openjdk 17 버전
- Spring Boot: 3.0.1
- Spring Security: 6.0.1
- Gradle 언어: Groovy
⛓️ Security Filter Chain
Security Filter Chain은 Spring Security에서 HTTP 요청의 다양한 보안 기능을 제공하기 위한 여러 종류의 필터들의 모음이다. 기본적으로 제공하는 필터들이 있고, 사용자가 만든 커스텀 필터도 필터 체인으로 등록하여 사용할 수 있다.
각 필터는 특정한 보안 작업을 수행하고 다음의 필터로 요청을 전달하는데, FilterChainProxy를 통해 필터 체인이 관리되며 요청이 적절한 필터로 전달된다.
🌲 ApplicationContext 초기화
ApplicationContext 초기화 과정은 Spring 애플리케이션이 시작될 때, Bean 정의를 읽고 생성하여 초기화하고, 이벤트를 발생시키며 애플리케이션을 준비하는 단계이다. 이 과정을 통해 애플리케이션은 실행 준비가 완료된다.
SecurityConfig 클래스는 ApplicationContext 내에서 Spring Security 관련 작업을 수행하는 역할을 하며, ApplicationContext 초기화 과정 중에 Spring Security의 보안 구성이 이루어지게 된다.
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**", "/account/signup").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler));
return http.build();
}
}
🫘 SecurityFilterChain Bean: filterChain(HttpSecurity http)
SecurityFilterChain의 filterChain 메서드는 보안 필터 체인을 정의하고, 이를 구성하는 데에는 HttpSecurity 객체 사용
SecurityFilterChain은 Spring Security에서 보안 필터 체인을 정의하는 인터페이스이다.
filterChain 메서드는 요청이 들어왔을 때 적용되는 보안 필터 체인을 구성하는 역할을 한다.
HttpSecurity 객체는 Spring Security에서 HTTP 요청에 대한 보안 구성을 정의하는데 사용되며, filterChain 메서드의 매개변수로 전달되어 보안 구성을 적용한다.
- 권한 설정: 특정 URL 패턴에 대한 접근 권한 설정
- 세션 관리: 세션 관리 방법 설정
- CSRF 보호: CSRF(Cross-Site Request Forgery) 보호 구성
- CORS(Cross-Origin Resource Sharing) 설정: 다른 출처로부터의 리소스 요청을 허용 또는 거부
- 인증 및 로그인 설정: 사용자 인증 방법 설정, 로그인 페이지 구성
- 예외 처리: 인증 및 권한 에러에 대한 처리 방법 설정
HttpSecurity 객체를 사용해 이러한 작업을 수행하고 보안 구성을 정의한 후에 build() 메서드를 호출하여 최종적으로 보안 구성을 적용한다.
Spring 2.x 버전에서는 WebSecurityConfigurerAdapter을 상속받아 설정을 오버라이딩 하는 방식이었는데, Spring 3.x 이상부터는 Adapter 방식은 Deprecated 되고, SecurityFilterChain을 Bean으로 등록하는 방식이 권장되고 있다.
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
나의 경우 JWT 인증 방식을 사용했기 때문에 CSRF, formLogin, httpBasic을 비활성화하고, session 방식을 stateless로 설정했다.
- CSRF 비활성화하는 이유
CSRF 공격은 사용자가 인증된 상태에서 악의적인 웹 사이트를 통해 요청을 보낼 때 발생할 수 있는 보안 취약점이다. 이때 CSRF 토큰을 사용하여 요청의 유효성을 확인하는 방식이 일반적으로 사용되는데, JWT를 사용하는 경우 토큰 자체가 요청의 인증을 수행하므로 별도의 CSRF 보호가 필요하지 않다.
- formLogin, httpBasic 비활성화하는 이유
JWT를 사용하는 경우 보통 폼 기반 로그인(form-based login) 방식이나 HTTP 기본 인증(Basic Authentication)과 같은 전통적인 인증 방식을 사용하지 않으므로, 이러한 인증 방식을 비활성화하여 보안을 강화할 수 있다.
- session 방식을 stateless로 설정하는 이유
JWT를 사용하는 경우, 사용자의 인증 상태를 서버의 세션에 저장하는 대신 토큰 자체에 사용자 정보를 포함시키므로 세션을 사용하지 않는 것이 좋다. 따라서 세션 관리를 stateless로 설정하여 서버가 클라이언트의 상태를 저장하지 않고 요청을 처리하도록 하는 것이 일반적이다. 이를 통해 서버의 확장성과 부하 분산을 개선할 수 있다.
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
// .requestMatchers("/auth/**", "/account/**").permitAll()
// .anyRequest().permitAll())
.anyRequest().authenticated())
만약 특정 API가 모든 필터 체인을 거쳤는데 인증 정보(SecurityContextHolder안에 있는 SecurityContext에 Authentication 인증 객체)가 없다면, 해당 요청이 인증되지 않았음을 의미한다.
하지만 해당 API에 permitAll()을 적용하면 필터 체인 동작 과정에서 인증 / 인가 예외가 발생해도 ExceptionTranslationFilter를 거치지 않으며, 인증 객체 존재 여부와 상관없이 정상적으로 API 호출이 이루어진다.
📌 permitAll()을 사용해도 Spring Security의 필터 체인은 거쳐감
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
해당 프로젝트는 Spring Security에서 디폴트로 설정되어 있는 세션 기반 인증 방식을 사용하지 않기 때문에 기본적으로 제공되는 UsernamePasswordAuthenticationFilter를 사용하지 않는다. 따라서 addFilterBefore 메서드를 이용해 UsernamePasswordAuthenticationFilter 필터 앞단에 JwtFilter를 커스텀 필터로 등록해준다.
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler));
로그인 시 발생할 수 있는 예외는 크게 인증 / 인가 두 가지로 구분할 수 있다.
등록한 커스텀 필터인 JwtFilter에서 인증 관련 예외(AuthenticationException) 발생 시, 해당 예외를 처리하기 위해 ExceptionTranslationFilter에 의해 호출되는 JwtAuthenticationEntryPoint를 Bean으로 등록한다.
필터 체인 과정에서 인증 예외가 발생하면 ExceptionTranslationFilter에 의해 JwtAuthenticationEntryPoint가 호출되어 일관적인 인증 예외 응답을 보낸다.
🫘 WebSecurityCustomizer
위에서 설명했듯이 permitAll() 메서드에 특정 API을 등록해도 Spring Security의 필터 체인은 무조건 거쳐가게 된다.
따라서 만약 필터 체인 자체를 생략해야 하는 API가 있을 때 Spring Security 설정 클래스에 WebSecurityCustomizer을 Bean으로 등록하면 특정 리소스에 대해 Spring Security 적용을 생략할 수 있다.
WebSecurity(WebSecurityCustomizer)는 HttpSecurity(SecurityFilterChain) 상위에 존재하기 때문에 특정 API를 ignoring()하도록 등록하면, Spring Security의 필터 체인이 적용되지 않는다.
하지만 이 경우 Cross-Site Scripting, XSS 공격 등에 취약해지기 때문에 회원가입과 로그인 페이지와 같은 보안과 전혀 상관없는 API에 사용하고, 이외에는 HttpSecurity의 permitAll()을 사용하는 것이 좋다.
🌲 DelegatingFilterProxy
Spring 애플리케이션에서 Spring Bean을 서블릿 필터로 사용할 수 있게 해 주어 Security Bean이 필터 역할을 수행할 수 있다.
Servlet Filter는 Servlet의 기술이기 때문에 Servlet Container에서만 생성되고 실행되어 스프링의 핵심 기술인 DI(의존성 주입)에 어려움이 있다. 이를 위해 Servlet Container에서 동작하는 DelegatingFilterProxy 클래스가 생성되었는데, 이 클래스는 실제 보안 처리를 하지 않고 위임만 하는 Servlet Filter이다.
🌲 FilterChainProxy
DelegatingFilterProxy로부터 요청을 위임받아 실제 보안 처리를 한다.
DelegatingFilterProxy는 단순히 요청을 위임하는 역할이라면, FilterChainProxy는 실제 보안처리가 시작되는 지점이다.
FilterChainProxy는 Spring Security 초기화 시 생기는 필터와 사용자가 만든 커스텀 필터들을 List로 갖고 있으며, 사용자 요청이 오면 이 필터 리스트에 있는 필터를 차례대로 호출해 보안을 처리한다.
🌿 JwtAuthenticationFilter.class
API 요청 Authorization Header에 존재하는 Access Token의 유효성을 검증하는 필터
인증마다 SecurityContext 생성 후 저장
이전에는 addFilterBefore 메서드를 이용할 경우 필터 적용 전에 설정해 두었던 permitAll()이나 authenticated()에 따라 addFilterBefore를 통과시킬지 정했으나, Spring Boot 3 이후에는 authorizeHttpRequests()에 설정한 값과 관계없이 무조건 커스텀 필터가 동작한다. 따라서 doFilterInternal 메서드에서 특정 url을 포함하는 경우 바로 리턴하는 코드를 작성해주어야 한다.
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveAccessToken(request);
authenticateUser(accessToken);
filterChain.doFilter(request, response);
}
private String resolveAccessToken(HttpServletRequest request) {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
return tokenProvider.extractToken(authHeader);
}
private void authenticateUser(String accessToken) {
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
🌿 JwtAuthenticationEntryPoint.class
인증 관련 예외 처리
@RequiredArgsConstructor
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(StatusCode.UNAUTHORIZED.getCode());
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
ErrorResponse.of(INVALID_TOKEN, authException.getMessage())));
}
}
필터 체인 과정에서 인증 예외가 발생하면 ExceptionTranslationFilter에 감지되고, 해당 필터에 의해 AuthenticationEntryPoint 인터페이스의 사용자 구현 클래스인 JwtAuthenticationEntryPoint가 호출되어 일관적인 인증 예외 응답을 보낸다.
🌿 JwtAccessDeniedHandler.class
인가 관련 예외 처리
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(StatusCode.FORBIDDEN.getCode());
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
ErrorResponse.of(ACCESS_DENIED, accessDeniedException.getMessage())));
}
}
필터 체인 과정에서 인가 예외가 발생하면 ExceptionTranslationFilter에 감지되고, 해당 필터에 의해 AccessDeniedHandler 인터페이스의 사용자 구현 클래스인 JwtAccessDeniedHandler가 호출되어 일관적인 인가 예외 응답을 보낸다.
📌 Flow
1. 사용자의 자원 요청
2. Servlet Container의 Filter들이 처리, 그중 DelegatingFilterProxy가 요청을 받게 될 경우 요청 위임
Spring Security가 클라이언트 요청을 가로채서 보안 관련 처리를 하기 위한 준비 작업
3. 요청 객체는 FilterChainProxy Bean의 특정한 필터(springSecurityFilterChain)에서 받게 됨
FilterChainProxy가 보유하고 있는 SecurityConfig 객체들을 검사해 요청 URL과 매칭되는 SecurityFilterChain을 찾음
4. 일치하는 SecurityFilterChain의 Security Filter 처리
매칭되는 SecurityFilterChain에 속한 보안 필터들을 순서대로 실행하여 인증 및 인가 처리
5. 보안처리가 완료되면 최종 자원에 요청을 전달하여 다음 로직 수행
📚 참고
[Spring Security] Spring Security Filter Chain 에 대해
[Spring Security] 아키텍처 이해 - 위임필터 및 빈 초기화
[우테코] JWT 방식에서 로그아웃, Refresh Token 만들기(1): JWT의 Stateless한 특징을 최대한 살리려면?
OncePerRequestFilter, shouldNotFilter And ignore
'Back-end' 카테고리의 다른 글
[Spring Boot] 멀티모듈에 JaCoCo + JaCoCo Report Aggregation 적용하기 (3) | 2024.07.25 |
---|---|
Jmeter를 사용한 동시성 테스트 (0) | 2024.07.05 |
[Spring Boot] 내가 적용한!!! 멀티 모듈 기준 (0) | 2024.06.12 |
[Gradle] 자바 플러그인, implementation과 api의 차이 (1) | 2024.05.23 |
[Spring Boot] 멀티 모듈 참고 영상 및 정리 (0) | 2024.05.17 |