해당 포스팅은 Briefing Service에서 요구 사항에 대한 문제 해결 과정에서 학습한 내용에 대한 내용입니다!
🌐 Spring Security 에 대한 학습의 계기
지금까지 Spring Security를 도입한 프로젝트가 다수 있었지만, 제가 직접 학습을 해서 적용을 하지 않고 친구의 코드와 블로그에서 제공하는 예시를 통해서 사용했습니다.
따라서 추가적인 Spring Security의 기능을 활용해야 하거나 혹은 문제가 생긴 경우 효율적으로 해결하지 못하고 임시방편으로 문제를 해결하는 정도에 그쳤습니다.
이번에 실제로 런칭을 하고 지속적인 서비스를 하기 위해 많은 신경을 쓰고 있는 Briefing 서비스에서 JWT 인증에 추가적으로 Swagger 접속 시에만 Form Login을 구현해야하는 요구사항을 만났습니다.
Spring Security의 동작 원리를 잘 모르는 상태에서는 원하는 동작을 구현할 수 없었고, 미로에서 길을 잃은 것 같았습니다.
이를 계기로 공식문서를 중심으로 Spring Security에 대한 학습을 진행했고 크게 2가지를 학습해야 한다는 것을 알게 되었습니다.
- Spring Security의 필터체인 흐름
- 필터체인 내부에서의 인증, 인가처리에 대한 흐름
위의 2가지를 한번에 포스팅을 하면 너무 글이 길어질 것으로 판단이 되어 2개의 문서로 나눠서 Brieing 서비스에서 문제 해결을 위해 어떠한 고민과 학습을 했는지 적어보려고 합니다.
이번 포스팅은 필터체인의 흐름과 그 중에서 인가되지 않은 사용자에 대한 처리를 중점적으로 다룹니다!
❗해결을 해야 했던 문제 상황
우선 제가 어떤 문제상황을 겪었는지 간단하게 정리하면 아래와 같습니다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) ->
web.ignoring()
.requestMatchers(
"",
"/",
"/schedule",
"/v3/api-docs",
"/v3/api-docs/**",
"/docs/**",
"/briefings/temp");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfiguration()))
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // 비활성화
.sessionManagement(
manage ->
manage.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)) // Session 사용 안함
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests(
authorize -> {
authorize
.requestMatchers("/v2/briefings/**")
.permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/briefings/**").permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/v2/members/auth/**").permitAll();
authorize.requestMatchers("/members/auth/**").permitAll();
authorize.requestMatchers("/chattings/**").permitAll();
authorize
.requestMatchers(HttpMethod.DELETE, "/v2/members/{memberId}")
.authenticated();
authorize
.requestMatchers(HttpMethod.DELETE, "/members/{memberId}")
.authenticated();
authorize.requestMatchers("/v2/scraps/**").authenticated();
authorize.requestMatchers("/scraps/**").authenticated();
authorize.anyRequest().authenticated();
})
.exceptionHandling(
exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(
new JwtRequestFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationExceptionHandler, JwtRequestFilter.class)
.build();
}
이는 당시의 Security 설정이며
자세히 살펴보면
.formLogin(Customizer.withDefaults())
Form Login을 기본 설정을 사용하도록 했습니다. 저는 처음에 이렇게 설정을 해두고 form Login 페이지가 나오는지 확인을 하고 추가적인 작업을 하려고 했습니다.
그러나....
예상
- http://localhost:8080/swagger-ui/index.html로 접근 시 form Login이 보여짐
실제
- json 에러 응답이 옴
제가 실제로 원하는 모습은 아래와 같습니다. (아래의 화면은 최종적인 해결 이후 캡쳐 한 모습입니다.)
저는 대체 어떤 이유로 제가 설정을 해둔 jwt에 대한 예외처리 응답이 오는지 그 원인을 알 수 없었고
최우선적으로 내부적으로 대체 어떤 흐름을 거치는 것인지 파악을 해야겠다는 생각을 했습니다.
따라서 Spring Security 공식문서를 참고해서 흐름을 파악하고자 했습니다.
🔬공식문서를 통한 학습
공식 문서를 읽으면서 학습을 진행 한 결과 아래의 3가지를 알게 되었습니다.
- Spring Security는 DelegatingFilterProxy 를 기반으로 Security Filter Chain 내부 Filter의 연계로 동작함
- 각각 Filter는 Authentication을 위해 AuthenticationManager를 사용한다.
- AuthenticationManager를 구현 한 ProviderManager의 Authentication Providers에서 인증 여부를 결정하고 이를 Authentication 객체에 principal, credentials, authorities를 담고 이를 Security Context에 담아 인증을 수행
우선 해당 포스팅에서는 필터체인의 흐름을 중점으로 문제 상황의 원인을 찾아내는 것에 초점을 맞춰서 진행하겠습니다.
Spring Security Filter 구조
위의 그림은 Spring Security 공식문서에서 확인 할 수 있는 Spring Security의 구조도 입니다.
- DelegatingFilterProxy
- 본래 Filter는 Spring이 아닌 서블릿의 기술이기 때문에 Spring의IOC 컨테이너를 통한 의존성 주입 등 스프링에서 제공하는 여러 편의기능을 사용할 수 없습니다. 그러나 Spring Security는 이를 DelegatingFilterProxy를 통해 서블릿 필터와 스프링의 Application Context간의 다리 역할을 해줍니다. 간단히 생각하면 스프링의 기능을 사용하는 필터를 제공하기 위한 기술이라고 생각하면 될 것 같습니다.
- FilterChainProxy
- FilterChainProxy는 DelegatingFilterProxy가 빈으로 등록 된 필터를 호출할 수 있도록 역시 중간자 역할을 하는데요, 이게 왜 필요한지?에 대해서는 뒤에서 다루지만 Spring Security는 SecurityFilterChain을 여러개를 등록 할 수 있습니다. 이 때, FilterChainProxy에서 여러개의 필터 체인을 효율적으로 사용하기 위해 DelegatingFilterProxy가 직접 필터체인을 의존하지 않아도 되도록 해주는 역할을 한다고 생각하면 될 것 같습니다.
- SecurityFilterChain
- 실제 인증, 인가 처리를 위한 필터들이 등록이 됩니다. 각각의 필터들은 _doFilter_를 통해 다음 필터를 호출하고 마지막 필터는 서블릿을 호출하게 됩니다. (보통 Spring MVC를 사용하기 때문에 디스패처 서블릿이 되겠죠?)
그리고 주의할 점은 기본적으로 저희가 Spring Security에서 사용을 하고자 하는 필터는 빈으로 등록이 되어야 하지만,
.addFilterBefore(jwtAuthenticationExceptionHandler, JwtRequestFilter.class)
위 처럼 Security 설정할 때 필터를 등록하면 빈으로 등록이 됩니다.
따라서 직접 해당 필터를 빈으로 등록하지 않아도 됩니다.
@Component
public class JwtAuthenticationExceptionHandler extends OncePerRequestFilter
위 처럼 @Component를 설정하지 않아야 합니다.
Spring Security의 기본 흐름이 필터 체인 내부의 필터들을 거치면서 인증, 인가처리를 하는 것임을 이해했지만,
실제로 어떻게 동작하는지 직접 관찰을 하지 않으면 머릿속에 정리가 되지 않을 것 같았습니다.
따라서 저는 직접 Spring Security에서 사용되는 몇가지 필터의 실제 코드를 관찰하고 또 디버깅을 하면서 그 흐름을 파악하기 위해 노력 했습니다.
🔧디버깅을 하기위한 준비
처음에는 제가 직접 추가한 JWT 필터에 브레이크 포인트를 걸고, 함수 Call 스택을 통해서 흐름을 파악하려고 했지만 이는 굉장히 불편했고 필터 체인의 순서가 어떻게 되어있는지 전체적인 그림을 먼저 파악하고 싶었습니다.
Spring Security 자체적으로 디버깅을 할 수 있도록 지원을 해주는 것을 확인했고 디버깅이 되도록 설정을 잠시 변경했습니다.
@Slf4j
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
위 처럼 @EnableWebSecurity에 debug = true 설정을 해주고
logging:
level:
org.springframework.security.web.FilterChainProxy: DEBUG
spring:
config:
이렇게 application.yml / application.properties에
spring security에 대한 디버깅을 하겠다는 설정을 해줍니다.
그러면
이렇게 서버 실행 시 주의하라는 알럿이 뜨고
요청이 한번 서버에 닿게 되면
이렇게 요청에 대한 정보와
위 처럼 필터체인 내부의 필터들의 순서를 표시를 해줍니다.
필터들의 순서를 잘 보시면 제가 JwtAuthenticationExceptionHandler를 JwtRequestFilter 앞단에 위치시켰고,
JwtRequestFilter를 UsernamePassswordAuthenticationFilter 앞에 위치시켰기 때문에
실제로도 그런 순서로 필터체인이 형성 된 것을 확인 가능합니다.
(아래의 코드를 통해 확인 가능합니다!)
.addFilterBefore(new JwtRequestFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationExceptionHandler, JwtRequestFilter.class)
그리고 Spring Security 공식문서에서 아래와 같은 문장을 읽었습니다.
from Login을 사용할 경우 인증되지 않은 사용자가 요청을 할 경우 로그인 페이지로 리다이랙트 시킵니다.
저 같은 경우는 기본 설정을 사용한다고 했기 때문에 기본 설정을 따라 /login 페이지로 이동이 되게 됩니다.
그러나... /login 페이지로 이동이 되는게 아니라 json 응답이 오기 때문에...
저는 인증되지 않은 사용자에 대해 어떻게 처리를 하는지 흐름을 파악해야겠다고 생각했습니다.
따라서 ExceptionTranslationFilter 를 직접 디버깅 하면서 어떻게 처리가 되는지 직접 살펴봤습니다.
⚔️ 인증, 인가되지 않은 사용자에 대한 예외처리
위의 사진에 있는 필터체인 내부 필터의 순서를 다시한번 보면
마지막 두개의 필터가 _ExceptionTranslationFilter_와 _AuthorizationFilter_가 있다는 것을 확인 가능합니다.
참고로 _AuthorizationFilter_는 Spring Security 6 버전에서 새로 생긴 필터이며 기존의 _FilterSecurityInterceptor_가 사라지고 그 자리를 대신하는 필터입니다.
인증, 인가되지 않은 사용자에 대한 예외처리를 이해하기 위해서는 저 2개의 필터의 상호작용을 알아야 합니다.
인증에 대한 필터 내부의 더 자세한 흐름은 다음 포스팅때 다루려고 합니다.
대략적인 필터의 흐름
ExceptionTranslationFilter는 우선 try catch로 감싼 채로 doFilter를 통해 다음 필터를 호출합니다.
다음으로 호출되는 AuthorizationFilter는 적절한 AuthorizationManager를 선택하여 Manager의 check 매서드를 통해
요청을 허용할지, 아니면 거부를 할지를 결정하고 거부를 할 경우 AccessDeniedException를 throw합니다.
그리고 throw를 할 경우 AuthorizationFilter를 호출한 ExceptionTranslationFilter가 catch를 하고 등록 된 핸들러를 통해
인증, 인가되지 않은 사용자에 대한 예외처리를 합니다.
즉 아래와 같은 흐름으로 간단하게 생각할 수 있습니다.
- 앞선 필터들을 통해 인증 처리를 진행, 그 과정에서 다음 필터로 넘어갈 필요도 없을 정도의 예외가 생긴다면 바로 throw를 해서 예외 처리를 진행
- ExceptionTranslationFilter까지 도달 할 경우 우선 인가 처리까지 고려를 해야하기에 try, catch로 감싼 상태에서 AuthorizationFilter를 호출함
- AuthorizationFilter는 설정 정보에 따라 적절한 AuthorizationManager를 선택하여 check 매서드를 통해 인증과 인가 처리를 할지, 아니면 하지 않을지 결정, 그리고 check 매서드의 결과를 보고 AuthorizationFilter가 exception을 throw 하거나 허용
- 만약 exception이 throw 될 경우 ExceptionTranslationFilter가 catch를 한 후 등록 된 에러 핸들러를 통해 예외 처리
이제 실제 ExceptionTranslationFilter와 AuthorizationFilter의 코드를 봅시다.
ExceptionTranslationFilter
제가 브레이크 포인트를 걸어둔 부분을 보면, 먼저 try catch로 감싼 상태에서 chain.doFilter를 통해 다음 필터를 호출하죠
이후 다음 필터에서 throw가 된 경우 최종적으로 핸들러를 통해 예외처리를 하는 것을 확인 가능합니다.
handleSpringSecurityException의 코드를 보면 exception의 종류에 따라 다르게 처리를 하고 있으며
handleAccessDeniedException를 보면
최종적으로 등록된 핸들러를 호출하는 것을 확인 할 수 있습니다.
AuthorizationFilter
제가 브레이크 포인트를 걸어둔 부분을 보면, this.authorizationManager.check()를 확인 가능합니다.
그리고 AuthorizationManager를 보면 인터페이스이며 check 매서드를 14개의 구현 클래스가 구현 한 것을 확인 가능하죠
실제 디버깅을 진행해보시면 발견 할수 있지만
처음으로 호출이 되는 check 매서드는 RequestMatcherDelegatingAuthorizationManager 의 check 매서드 입니다.
_RequestMatcherDelegatingAuthorizationManager_에서 for문을 통해 적절한 manager를 찾아서
다시 호출 하는 것을 확인 가능합니다.
그리고 사실 적절한 manager를 찾을 때
authorizeHttpRequests 설정의 url과 그 url에 대한 처리 옵션에 따라서 manager가 선택이 됩니다.
위의 설정에서 POST : /v2/members/{memberId}에 대해서는 authenticated 설정이 되어있고
authenticated는 인증이 된 사용자만 허용한다는 의미입니다.
RequestMatcherDelegatingAuthorizationManager 에서 POST : /v2/members/{memberId}에 대해서는
AuthenticatedAuthorizationManager 가 선택이 되고 아래의 check 매서드를 호출하게 됩니다.
마지막으로 check매서드는 최종적으로 isGranted를 호출하는데요
isGranted를 보면 authentication 객체가 null이 아니고, 익명 사용자가 아니고 동시에 isAuthenticated가 true인
경우에만 허용을 해주는 것을 확인 가능합니다.
💡 문제에 대한 원인
이제 다시 돌아와서 Security 설정을 다시 봐봅시다.
설정을 다시 살펴보면 제가 exceptionHandling에 제가 직접 커스터마이징 한 핸들러가 등록이 된 것을 확인 가능합니다.
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAccessDeniedHandler.class);
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
response.setStatus(403);
PrintWriter writer = response.getWriter();
ApiErrorResult apiErrorResult =
ApiErrorResult.builder()
.isSuccess(false)
.code(ErrorCode._FORBIDDEN.getCode())
.message(ErrorCode._FORBIDDEN.getMessage())
.result(null)
.build();
try {
writer.write(apiErrorResult.toString());
} catch (NullPointerException e) {
LOGGER.error("응답 메시지 작성 에러", e);
} finally {
if (writer != null) {
writer.flush();
writer.close();
}
}
}
}
그리고 AccessDeny에 대한 핸들러를 보면 json 응답을 주는 것을 확인 가능합니다.
그리고 formLogin에 대해 처음 인증되지 않은 사용자에 대해서 로그인 화면으로 이동을 시킨다고 했잖아요?
이제 왜 json 응답이 온 것인지 아래의 흐름이 원인이라는 것을 유추 할 수 있습니다.
- fomLogin을 설정 시 UsernamePasswordAuthenticationFilter 가 필터체인에 추가가 되며 처음 온 사용자는 인증이 되지 않음 (왜 인증이 안되는지 더 자세한 내용은 다음 포스팅때 다룹니다.)
- 사실 중간에 존재하는 AnonymousAuthenticationFilter 가 해당 필터까지 인증이 안된 요청에 대해서는 Anonymous사용자라는 Authentication 객체를 담게 됨 (이 역시 다음 포스팅때 다시 다룰게요)
- 이후 ExceptionTranslationFilter 에서 try catch로 감싼 상태에서 다음 필터인 AuthorizationFilter를 호출합니다.
- AuthorizationFilter는 AthorizationManager를 통해 check 매서드를 호출
- AthorizationManager가 선택되는 과정에서 authorizeHttpRequests의 설정 정보가 영향을 주는데, 위의 authorizeHttpRequests 설정 정보를 확인하면 authorize.anyRequest().authenticated();를 확인 가능
- 따라서 check 과정에서 인증이 된 사용자인지 체크하고 아니기 때문에 결과적으로 AuthorizationFilter 에서 AccessDeniedException를 throw함
- ExceptionTranslationFilter 는 Exception을 catch하고 핸들러를 통해 예외처리를 함
- 이 때, 직접 커스텀해서 등록한 JwtAccessDeniedHandler가 호출이 되어버리게 되고 원치 않는 결과가 발생
다시 한번 authorizeHttpRequests 설정 정보를 보면
authenticated()를 확인 가능하고 스웨거 접속 url은 다른 명시된 url이 아니기 때문에 anyRequest로 판정이 되겠죠
💡 문제 상황에 대한 해결
여기까지 직접 디버깅을 하면서 학습을 진행 한 결과 다양한 해결방법이 떠올랐습니다.
그러나 JWT 인증 과정에서 예외처리를 위한 핸들러를 사용하면서 동시에 Form Login을 같이 하나의 필터체인에
공존하도록 하는 방법이 깔끔하다고 생각이 들지 않았고 저는 다른 접근방법을 고민했습니다.
최종적으로는 아래와 같은 결론을 내렸습니다.
JWT 필터체인을 분리하자
이후 실제로 어떻게 JWT 필터체인을 분리해서 최종적으로 Swagger 로그인을 구현했는지는 아래의 포스팅에서
[Briefing] Spring Security Swagger 로그인 적용
확인 가능합니다! 포스팅이 너무 길어지기 때문에 분리하게 되었습니다 😊
'Spring > Spring Security' 카테고리의 다른 글
[Spring Security] 인증 흐름 알아보기 (0) | 2024.01.05 |
---|