[JWT] 스프링 시큐리티 + JWT 예외처리하기 및 고찰

YoungHo-Cha·2022년 3월 7일
6

운동 매칭 시스템

목록 보기
7/17

백엔드를 구현하다보면 예외처리를 할 일이 너무나도 많다.
오늘은 그 많은 예외처리 중에서 JWT관련 예외처리를 공부한 흔적을 찾아볼 것이다.


🔍목차

  • 구현 목적

  • 실패한 예외들

    • Advice를 이용한 예외처리
    • ExceptionFilter를 이용한 예외처리
  • 성공한 예외


📌구현 목적

JWT를 구현을 하면 jjwt 라이브러리 내부에서 여러가지 내용으로 예외를 던지고 있다. 그렇게 예외가 던져지게 되고, 내가 별도의 처리를 해주지 않으면 Response로 "Internal Server Error", "Forbbiden"과 같은 값들이 Response가 된다.

나는 옳지않은 요청에 대한 응답도 내가 원하는 값으로 응답하고 싶다.


📌실패한 예외들

여러가지의 방법으로 시도하면서 실패를 많이 했다. 실패를 하면서 배운것 또한 많다. 그것을 먼저 보자!

Advice를 이용한 예외처리

  1. 먼저 Advice를 작성하자.

@RestControllerAdvice
public class ErrorControllerAdvice {

    @ExceptionHandler(value = NoSuchElementException.class)
    protected ResponseEntity<ErrorResponse> handleNoSuchElementException(Exception e) {

        log.info("NoSuch 시작");
        ErrorResponse response = new ErrorResponse();
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);

    }

    @ExceptionHandler(value = UsernameNotFoundException.class)
    protected ResponseEntity<ErrorResponse> handleUserNameNotFoundException(){
        log.info("여기 에러 터짐 UserException");


        ErrorResponse response = new ErrorResponse("403","사용자를 찾을 수 없습니다.","403");
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(value = CustomJwtExpiredException.class)
    protected ResponseEntity<ErrorResponse> handleExpiredJwtException(){
        log.info("여기 에러 터짐 UserException");


        ErrorResponse response = new ErrorResponse("403","만료된 토큰입니다.","403");
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(value = CustomJwtMalformedException.class)
    protected ResponseEntity<ErrorResponse> handleMalformedJwtException(){
        ErrorResponse response = new ErrorResponse("403","변조된 토큰입니다.","403");
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(value = CustomJwtSignatureException.class)
    protected ResponseEntity<ErrorResponse> handleSignatureJwtException(){
        ErrorResponse response = new ErrorResponse("403","변조된 토큰입니다.","403");
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
}
  1. JWTExceptionFilter에서 에러를 터트려주자.
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        response.setCharacterEncoding("utf-8");

        log.info("jwtExceptionFilter 실행");
        try{
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e){

            throw new CustomJwtExpiredException("만료된 토큰입니다.");

        } catch (MalformedJwtException e){

            throw new CustomJwtMalformedException("변조된 토큰입니다.");
        } catch (SignatureException e){

            throw new CustomJwtSignatureException("잘못된 토큰입니다.");
        }

    }
}
  1. CustomFilter들을 만들어주자. 이유는 기본적으로 구현되어있는 JWT토큰의 생성자들은 파라미터를 받는데 여기서 처리해주기 빡세다.
public class CustomJwtExpiredException extends RuntimeException{

    public CustomJwtExpiredException(String msg){
        super(msg);
    }

}

3개를 만들었는데 대표적으로 1개만 작성했다.

advice에 대한 이해도가 높은 사람은 이미 문제점을 파악하셨겠지만.. 나는 이때까지만 해도 몰랐다.

해당 방식으로 구현한 이유

Custom으로 만든 예외를 강제로 터트려 Advice에서 처리해주기를 원했다. 하지만 치명적인 실수가 있었다. Advice는 Controller에서만 작동하는 내용이며, "Service, Security Filter, Repository"등등 에서는 동작하지 않는다고 한다.

특히, JWTExceptionFilter는 Spring Security Filter에서 동작하기 때문에 스프링으로 진입자체가 막혀서 Bean또한 생성되지 않는다고 판단이 된다.

그래서 실패..

ExceptionFilter에서 자체 예외처리

바로 위에서 진행했던 throw부분에서 response에 자체적으로 제작을 해주면 된다.

JWTExceptionFilter를 수정해주자.


@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        response.setCharacterEncoding("utf-8");

        log.info("jwtExceptionFilter 실행");
        try{
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e){

            log.info("expiredJwtException 터짐");
            Map<String, String> map = new HashMap<>();

            map.put("errortype", "Forbidden");
            map.put("code", "402");
            map.put("message", "만료된 토큰입니다. Refresh 토큰이 필요합니다.");

            log.error("만료된 토큰");
            response.getWriter().write(objectMapper.writeValueAsString(map));

            log.info("생성된 response = {}", response);
        } catch (JwtException e){
            log.info("JwtException 터짐");
            Map<String, String> map = new HashMap<>();

            map.put("errortype", "Forbidden");
            map.put("code", "400");
            map.put("message", "변조된 토큰입니다. 로그인이 필요합니다.");

            log.error("변조된 토큰");
            response.getWriter().write(objectMapper.writeValueAsString(map));
        }
    }
}

실패 이유

사실상 실패는 아니다.. 근데 매우매우 객체지향적으로 코드가 생성되지 않은 기분이다. 그리고 나는 ResponseEntity 방식으로 Return을 하고 싶다.

객체지향적으로 코드가 생성되지 않은 기분이 든 이유는, 해당 Filter는 예외를 감지하고 예외에 따른 후처리를 한번에 하기 때문이다. 후처리는 별도의 메소드나 다른 객체가 해야한다고 판단되기 때문이다.


📌성공한 예외

AuthenticationEntryPoint 사용

후처리를 별도의 객체가 하도록 하는 방법을 찾았다! 역시 스프링 최고

AuthenticationEntryPoint란?

해당 내용을 설명은 공식홈페이지에 있는데, 나는 영어를 잘 모른다.. 그래서 내 마음대로 해석한 것이다 ㅠ

AuthenticationEntryPoint는 Spring Security에서 예외가 발생한 후, 반환되는 AuthenticationException을 감지하여 후처리를 수행해주는 인터페이스이다. 해당 인터페이스를 implement하여, "commence 메소드"를 완성하면 된다. 한번 해보자.

  1. 먼저 AuthenticationEntryPoint를 구현할 클래스를 생성하자.

CustomAuthenticationEntryPoint.java 생성

package com.togethersports.tosproejct.exception;

@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        String exception = (String)request.getAttribute("exception");

        if(exception == null) {
            setResponse(response, Code.UNKNOWN_ERROR);
        }
        //잘못된 타입의 토큰인 경우
        else if(exception.equals(Code.WRONG_TYPE_TOKEN.getCode())) {
            setResponse(response, Code.WRONG_TYPE_TOKEN);
        }
        //토큰 만료된 경우
        else if(exception.equals(Code.EXPIRED_TOKEN.getCode())) {
            setResponse(response, Code.EXPIRED_TOKEN);
        }
        //지원되지 않는 토큰인 경우
        else if(exception.equals(Code.UNSUPPORTED_TOKEN.getCode())) {
            setResponse(response, Code.UNSUPPORTED_TOKEN);
        }
        else {
            setResponse(response, Code.ACCESS_DENIED);
        }
    }
    //한글 출력을 위해 getWriter() 사용
    private void setResponse(HttpServletResponse response, Code code) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        JSONObject responseJson = new JSONObject();
        responseJson.put("message", code.getMessage());
        responseJson.put("code", code.getCode());

        response.getWriter().print(responseJson);
    }
}

"Code"에 대한 부분은 다음 글에 포스팅할 예정이다!

이제
2. Spring Security에 등록해주자.

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;
    private final ObjectMapper objectMapper;

    // authenticationManager를 Bean 등록합니다.
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable();
        http.httpBasic().disable()
                .authorizeRequests()// 요청에 대한 사용권한 체크
                .antMatchers("/test").authenticated()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/**").permitAll()
                .and()
                .cors()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtExceptionFilter(objectMapper), JwtAuthenticationFilter.class);

".authenticationEntryPoint" 로 등록해주었다. 이제 JWTExceptionFilter에서 EntryPoint로 해당 클래스가 동작할 것이다.

  1. 이제 JWTExceptionFilter에서 발생하는 예외를 담아주고, 필터체인에 등록해주자.
@Slf4j
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        response.setCharacterEncoding("utf-8");

        try{
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e){
            //만료 에러
            request.setAttribute("exception", Code.EXPIRED_TOKEN.getCode());

        } catch (MalformedJwtException e){

            //변조 에러
            request.setAttribute("exception", Code.WRONG_TYPE_TOKEN.getCode());


        } catch (SignatureException e){
            //형식, 길이 에러
            request.setAttribute("exception", Code.WRONG_TYPE_TOKEN.getCode());
        }
        filterChain.doFilter(request, response);


    }
}

완료했다!


📎참고자료

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

0개의 댓글