spring security filter와 jwt token exception 처리

ᄋᄌᄒ·2024년 3월 14일
0

Spring_Security

목록 보기
5/5
post-thumbnail

✅ 글 쓰기 전에

또프링 또큐리티에 대한 글을 쓰려고 한다. 공부하고 이해한 것 같아서 넘어가면 다음에 무언가가 안되는 신기한 스프링 시큐리티. 김영한 강사님 혹시 스프링 시큐리티 관련해서는 강의 만들어주실 수 없나요. 허허. 이번 리팩토링은 짧지만 얘기하고자 하는 것은 간단하지 않은. 또 예외 처리니 그냥 넘어갈 수 없는 문제다.


✅ 본문

📌 Problem

뭐가 문제냐 부터 확인해봅시다.
[TokenServiceImpl.class]

	@Override
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

jwtAuthenticationFilter.class에서 토큰의 유효성을 확인할 때 사용되는 메서드이다. 이제 프론트에서도 토큰의 유효성을 확인하고 문제가 생기면 400에러가 아닌 예외 발생 객체를 받아야한다. (참고로 우리는 대부분 결과가 ApiResponseDto를 통해 전달이 되기 때문에 프론트에서 성공했는지 실패했는지 알 수 있으며 실패 결과도 백엔드에서 메세지와 번호를 보내주고 있다.)

통일된 양식을 전달받아야하는 프론트이기 때문에 여기서 로그를 작성시키도록 하는 것이 아닌 ApiResponseDto에 실패를 담아 보내야하는데 보통 우리는 이걸 다음과 같은 방식으로 처리했다.

예외 처리

[예시] (코드 내용은 무관합니다.)

	if (!SecurityUtil.getCurrentUsername().equals(storyWriteDto.getUsername())) {
            throw new StoryHandler(ErrorStatus.BOARD_CANNOT_EDIT_OTHERS);
        }

이 handler라 함은. RuntimeException을 상속받은 커스텀 에러이다. 따라서 ControllerAdvice를 통해 서버 전역의 에러를 관리하게 되는데 해당 커스텀 에러를 받아 ApiResponseDto에 맞게 작성이 될 수 있는 것이다. (controllerAdvice의 역할은 예외 전역 관리이다.) 좋은점은 당연히 통일된 결과를 보여줄 수 있다는 것이다.

컨트롤러어드바이스까지 설명하면 너무 다른 길로 새는것 같으니 아래 프로젝트 깃허브 확인하면 도움이 될 것이다.
Waggle_BackEnd_Github

정리하면 예외(및 api result) 처리는 다음과 같다.

  1. 컨트롤러 어드바이스를 통해 예외 전역 처리를 해준다.
  2. 개발자가 코드를 작성하면서 예외가 발생할 수 있는 지점에 throw new [CustomException객체]를 작성해준다.
  3. 예외가 발생하든 정상적으로 작동하든 ApiResponseDto라는 양식에 결과가 들어간다.

그래서

필자는 당연히 catch부분에 throw new [CustomException객체]를 해주었다. 다시 던져준 예외가 컨트롤러 어드바이스에서 잡아주고, 같은 양식의 결과를 보여줄 것이라 생각했기 때문이다. 근데 우리가 원하는

[예시] (메시지 내용은 무관합니다.)

{
  "isSuccess": false,
  "code": 4057,
  "message": "해당 uri는 권한 인증이 필수입니다. 만료된 토큰이거나 인증 정보가 없습니다.",
  "result": "Full authentication is required to access this resource"
}

이런 구체적인 에러 메시지가 발생하지 않았다. 다시 위로 가서 throw부분을 자세히 보면 ErrorStatus.BOARD_CANNOT_EDIT_OTHERS라고 되어있는데, BOARD_CANNOT_EDIT_OTHERS부분이 예외의 자세한 상황을 설명해준다. 그리고 이것이 message에 들어가야하는데 서버 에러(500)만 발생하는 것이다. 이것은 잘못된 결과이다.

📌 Security Constructure


위 사진이 spring의 전체적인 flow다. 하지만 spring security를 보면 client와 servlet 사이에 다음과 같은 과정이 들어간다.

filterChain 속에 여러 servlet Filter를 거치게된다. 사진에서도 얘기해주듯이 ControllerAdvice가 처리해주는 것은 Servlet단이다. 필자가 TokenService.validateToken()메서드를 어디서 실행하는지 다시 생각해보자. 분명 JwtAuthenticationFilter에서 사용한다고 했다.

그니까 예외 발생은 servlet 이전인 Filter에서 발생하는 것인데 처리를 Servlet에서 하려니 예외 처리 자체가 안되는 것이다.

📌 Result

Plan

  1. filter에서 발생하는 예외 처리 전용 필터 생성
  2. 결과에 ApiResponseDto 양식으로 리턴해주기

위 두가지를 핵심으로 한번 코드를 작성해보자.

public class CustomErrorSend {
    public static void handleException(HttpServletResponse response, ErrorStatus errorStatus, String errorPoint) throws IOException {
        ApiResponseDto<Object> apiResponseEntity = ApiResponseDto.onFailure(errorStatus.getCode(), errorStatus.getMessage(), errorPoint);

        response.setStatus(errorStatus.getHttpStatus().value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponseEntity));
    }
}

이 친구는 우리가 controllerAdvice나 controller에서 return 하는 방식으로는 동일한 양식으로 예외 처리를 해줄 수 없기 때문에 추가한 부분이다. response에 ApiResponseDto를 json 형식으로 출력해줄 것을 요청하는 메서드이다.

@Slf4j
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (JwtAuthenticationException authException) {
            String errorCodeName = authException.getMessage();
            ErrorStatus errorStatus = ErrorStatus.valueOf(errorCodeName);

            CustomErrorSend.handleException(response, errorStatus, errorCodeName);
        }
    }
}

이 클래스가 새로운 예외처리 필터다. filter단에서 발생하는, JwtAuthenticationException 예외를 처리해주는 업무를 담당한다. 그래서 예외가 발생한 catch 코드를 보면 CustomErrorSend.handleException()메서드가 사용되고 있음을 확인할 수 있다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        //생략

        if (token != null && tokenService.validateToken(token)) {
            //생략
        }

        chain.doFilter(request, response);
    }
		//생략

}

예외가 발생할 지점이다. 우리 예상은 validateToken(token)에서 문제가 발생하면 예외가 생길 것인데,

	@Override
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            throw new JwtAuthenticationException(ErrorStatus.AUTH_INVALID_TOKEN);
        } catch (ExpiredJwtException e) {
            throw new JwtAuthenticationException(ErrorStatus.AUTH_TOKEN_HAS_EXPIRED);
        } catch (UnsupportedJwtException e) {
            throw new JwtAuthenticationException(ErrorStatus.AUTH_TOKEN_IS_UNSUPPORTED);
        } catch (IllegalArgumentException e) {
            throw new JwtAuthenticationException(ErrorStatus.AUTH_IS_NULL);
        }
    }

와 같이 throw new JwtAuthenticationException이 되고 있다. 그러면 앞서 보여줬던 JwtExceptionFilter에서 예외처리를 하게 될 것이다.

	private void addFilter(HttpSecurity httpSecurity) {
        httpSecurity
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class);
    }

JwtExceptionFilter -> JwtAuthenticationFilter -> UsernamePasswordAuthenticationFilter 순으로 진행된다. JwtExceptionFilter에서 먼저 doFilter를 호출해야 다음 JwtAuthenticationFilter에서 생기는 예외를 잡아줄 수 있기 때문에 이렇게 설계했다.


✅ 글을 마치면서

비슷한 실수를 범한 것이 oauth2에서 예외 발생이었다. 하지만 다른 것은 oauth2를 사용할 때는 failureHandler가 있기 때문에 그 안에서 비슷한 예외 처리를 해주어야한다. 덕분에 이 부분도 금방 해결했다! 구조를 알아야하는 문제였기에 더 큰 의미가 있다고 생각한다.

0개의 댓글