문제의 시작
Jwt의 인증을 처리하는 필터(JwtAuthenticationFilter)를 커스텀하여 시큐리티 필터체인에 등록을 해놨는데,
이때 Jwt를 검증하는 과정에서 에러가 발생할 시 JwtException이 발생한다.
@Slf4j
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
// ... 생략
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain) throws IOException, ServletException {
// ... 생략
log.debug("디버그 : 인증 객체 만들어짐");
} catch (SecurityException e) {
log.info("Invalid JWT signature.");
throw new JwtException("잘못된 JWT 시그니처");
} catch (MalformedJwtException e) {
log.info("Invalid JWT token.");
throw new JwtException("유효하지 않은 JWT 토큰");
} catch (ExpiredJwtException e) {
log.info("Expired JWT token.");
throw new JwtException("토큰 기한 만료");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token.");
throw new JwtException("지원하지 않는 JWT 토큰");
} catch (IllegalArgumentException e) {
log.info("JWT token compact of handler are invalid.");
throw new JwtException("JWT token compact of handler are invalid.");
// ... 생략
}
}
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
public class SecurityFilterManagerImpl extends AbstractHttpConfigurer<SecurityFilterManagerImpl, HttpSecurity> {
// ... 생략
}
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http,
@Autowired @Qualifier("handlerExceptionResolver") final HandlerExceptionResolver resolver) throws Exception {
// ... 생략
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
log.info(authException.getMessage());
resolver.resolveException(request, response, null, new UnAuthorizedException());
});
http.exceptionHandling().accessDeniedHandler((request, response, accessDeniedException) -> {
log.info(accessDeniedException.getMessage());
resolver.resolveException(request, response, null, new ForbiddenException());
});
// ... 생략
return http.build();
}
}
이때 시큐리티 필터 내에서 발생한 에러기 때문에 GlobalExceptionHandler도 못잡는 상황이 발생하기 때문에 아래처럼 Exception이 빵터져버린다.
첫번째 시도
정해진 응답 형식에 맞춰 예외를 처리하기 위해
JwtAuthenticationFilter 바로 뒤에 커스텀한 JwtExceptionFilter을 하나 더 만들어 앞에서 JwtException 터질 시 처리하고자 했다.
- JwtException이 터질 시 JwtExceptionFilter에서 예외를 정해진 응답에 맞게 처리
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (final JwtException e) {
logger.info(e.getMessage());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print(
new ObjectMapper()
.writeValueAsString(
Response.error(e.getMessage(), HttpStatus.UNAUTHORIZED)));
}
}
}
- JwtExceptionFilter을 필터체인에 등록해둠 (JwtAuthenticcationFilter 뒤)
public void configure(final HttpSecurity builder) throws Exception {
final AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder.addFilter(new JwtAuthenticationFilter(authenticationManager, tokenProvider))
.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);
super.configure(builder);
}
😭 실패
응답이 나가긴 나가나 두번이 나가는 사태가 발생했다.
Status = 401
Error message = null
Headers = [Content-Type:"application/json;charset=UTF-8", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = application/json;charset=UTF-8
Body = {"success":false,"response":null,"error":{"message":"인증되지 않은 사용자입니다.","status":"UNAUTHORIZED"}}{"success":false,"response":null,"error":{"message":"토큰 기한 만료","status":"UNAUTHORIZED"}}
Forwarded URL = null
Redirected URL = null
Cookies = []
- Response Body
{
"success":false,
"response":null,
"error":{
"message":"인증되지 않은 사용자입니다.",
"status":"UNAUTHORIZED"
}
}{
"success":false,
"response":null,
"error":{
"message":"토큰 기한 만료",
"status":"UNAUTHORIZED"
}
}
원인 분석
로그를 보니 일단 JwtAuthenticationFilter에서 토큰이 만료되면서 JwtException이 발생한다.
그리고 SecurityConfig를 거쳐 Full authentication is required to access this resource 가 찍히고 그 후에 JwtExceptionFilter로 이동하는데,,,
내가 생각한 원인은 바로 이 부분 때문이었다.
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// ... 생략
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http,
@Autowired @Qualifier("handlerExceptionResolver") final HandlerExceptionResolver resolver) throws Exception {
// ... 생략
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
log.info(authException.getMessage());
resolver.resolveException(request, response, null, new UnAuthorizedException());
});
// ... 생략
}
}
Jwt 예외가 발생함과 동시에 authenticationEntryPoint에서 잡고 UnAuthorizeException(커스텀처리한 401에러)가 터지고 필터까지 실행되기 때문에 두 번 응답이 찍히는 것이었다...
두번째 시도
그래서 선택은 같은 인증 오류를 처리하는 역할이라 생각해 authenticationEntryPoint 부분을 시큐리티에서 제거했다.
결과는?
😭 실패2
Body는 내 생각대로 찍히나 status가 문제였다.
이제는 403이 찍히는 문제...에러 메시지로는 Access Denied이 뜨는 문제를 해결해야 했다.
잠깐!
위에서 시도한 authenticationEntryPoint를 핸들링하는 부분을 제거하는 것이 맞을까?
냅다 일단 지웠었는데 정리하다보니 의문이 생긴다. JwtException이 아닌 Authentication오류는 없을까??
authenticationEntryPoint를 지우지 않고 authenticationEntryPoint에서 처리하는 예외의 이름을 찍어보니 JwtException가 아닌 InsufficientAuthenticationException
아무튼 저 친구는 지우면 안된다! 처리해줄 게 있기 때문에
세번째 시도
다시 돌아와서 지금 문제는 뭘까?
- authenticationEntryPoint에서는 JwtException를 처리를 못한다.
- JwtException이 터지면 HandlerExceptionResolver가 잡아서 처리한다.(Jwt에 문제가 있다 -> 인증이 안된 것이기 때문에 인증예외도 터짐 -> 이 AuthenticationEntryPoint에게 잡힘)
- 이때 JwtException로 HandlerExceptionResolver에게 전달되는 것이 아닌 InsufficientAuthenticationException로 잡힌다.
☝🏻 내가 헷갈렸던 부분
- JwtException은 AuthenticationException이 아니다.
- 따라서 AuthenticationEntryPoint에서 잡히는 건 JwtException이 아닌 JwtException 때문에 발생된 InsufficientAuthenticationException이다.
그렇다면 어떻게 해야할까?
최고의 선택인지는 모르겠지만 나는 다음의 방법을 선택했다. (최선이라고 생각...)
- AuthenticationEntryPoint에서 잡힐 만한 부분을 최대한 예외처리
- JwtException 등 AuthenticationFilter에서 예외 발생
- sendError 실행
- sendError는 서블릿 컨테이너에 실제 오류가 발생했다고 알려주는 역할
- JwtExceptionFilter가 앞서 발생한 예외에 대해 응답을 처리
// SecurityConfig.class
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http,
@Autowired @Qualifier("handlerExceptionResolver")
final HandlerExceptionResolver resolver) throws Exception {
// 생략
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
log.info(authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
});
// 생략
}
}
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (final JwtException e) {
logger.info(e.getMessage());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
new ObjectMapper()
.writeValueAsString(
Response.error(e.getMessage(), HttpStatus.UNAUTHORIZED)));
}
}
}
로그를 보면 실행 순서는 이전과 동일하다.
JwtAuthenticationFilter에서 JwtException 발생 -> authenticationEntryPoint -> JwtExceptionFilter
authenticationEntryPoint는 오류가 발생했다만 처리하고 응답에 대한 처리는 하지 않기 때문에 두번 Body가 적혀지는 현상은 해결했다.
'카카오테크캠퍼스 > 3단계' 카테고리의 다른 글
클린 코드와 TDD 특강 정리 (0) | 2023.09.26 |
---|---|
설계 및 API 특강 (0) | 2023.09.25 |