프론트엔드 작업을 하다가 불편한 점이 하나 생겼다. JWT관련 예외가 발생하였을 때 에러 코드가 ERR_BAD_REQUEST로만 넘어오니 이 예외가 토큰이 만료되어 발생하였는지 토큰이 잘못되어(변형되어) 발생하였는지 알 수가 없다는 것이었다. 그래서 백엔드에서 이 예외처리를 세분화하여 응답하는 과정을 진행하고 정리해보려 한다.

💻 기존 코드

세분화를 하기 전에 우선 기존 코드부터 보자.

JwtTokenProvider

TokenProvider의 validateToken 메서드에서는 Bearer 검증을 하고 토큰이 만료되었는지 확인을 한다.

// jwt/JwtTokenProvider.java

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    public boolean validateToken(String token) {
        try {
            // Bearer 검증
            if(!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return false;
            } else {
                token = token.split(" ")[1].trim();
            }
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date()); // 만료되었다면 false return
        } catch(Exception e) {
            return false;
        }
    }
}

JwtAuthenticationFilter

JwtAuthenticationFilter에서는 단순히 요청이 Servlet으로 도달하기 전에 가로채어 토큰을 검증한 후 UsernamePasswordAuthenticationToken 객체를 만들어 이를 AuthenticationManager에게 전달한다.

// jwt/JwtAuthenticationFilter.java

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);

        if(token != null && jwtTokenProvider.validateToken(token)) {
            token = token.split(" ")[1].trim();
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}

SecurityConfig

SecurityConfig에서는 인증에 관련된 문제가 발생하였을 때 "Unauthenticated User."라는 응답을 보내도록 하였다.

// configuration/SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // JWT 인증 필터
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                // error handling
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("Unauthorized User.");
                    }
                })
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        response.setStatus(401);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("Unauthenticated User.");
                    }
                });
                
        return http.build();
    }
}

테스트

위의 코드들을 가지고 간단한 테스트를 해본다.

위와 같이 올바른 응답을 보내주는 정상적인 토큰을 만료시간이 지난 후 요청을 보내보고 훼손하여 요청을 보내보는 테스트이다.

만료시간이 지난 후 요청을 보냈을 때

토큰을 훼손하여 요청을 보냈을 때

이처럼 예외를 세분화하지 않아 예외가 발생하였을 때 동일하게 "Unauthentication User."라는 응답을 보낸다. 이러면 어떤 예외로 인해 발생한 오류인지 알 수가 없어 프론트엔드 쪽에서 대처하기가 불편하다. 이제 예외들을 세분화하여 이와 같은 불편함이 생기지 않게 하려고 한다.


🔨 수정

수정은 다음과 같은 방향으로 진행한다.

  1. TokenProvider에서 발생하는 Exception을 AuthenticationFilter에서 catch할 수 있도록 TokenProvider의 try-catch문을 제거한다.

  2. AuthenticationFilter에서 Exception에 따라 catch를 나눠주고 각 Exception에 맞게 처리한다.

  3. 클라이언트로 전달할 response는 AuthenticationEntryPoint의 commence 메서드를 오버라이드(Override)하여 set한다.

각 단계의 자세한 설명은 코드를 수정하면서 추가로 하려 한다.

JwtTokenProvider

TokenProvider에서는 위에서 언급한대로 try-catch문을 제거하여 기본적인 로직만 남겨둔다. AuthenticationFilter에서 토큰의 Expiration도 체크할 예정이기에 이 부분도 제거한다.

// jwt/JwtTokenProvider.java

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    public boolean validateToken(String token) {
        if(token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
            token = token.split(" ")[1].trim();
        }
        Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
        return true;
    }
}

ErrorCode enum

AuthenticationFilter와 AuthenticationEntryPoint에서 사용할 상태 코드와 메시지를 간편하게 관리하기 위해 enum을 작성해준다.

// jwt/ErrorCode.java

@Getter
public enum ErrorCode {
    UNKNOWN_ERROR(4001, "Unknown Error"),
    WRONG_TYPE_TOKEN(4002, "Wrong type Token"),
    EXPIRED_TOKEN(4003, "Expired Token"),
    UNSUPPORTED_TOKEN(4004, "Unsupported Token"),
    ACCESS_DENIED(4005, "Unauthenticated User");

    private int status;
    private String message;

    ErrorCode(int status, String message) {
        this.status = status;
        this.message = message;
    }
}

AuthenticationFilter

AuthenticationFilter에서는 try-catch문을 작성하여 Exception을 세부적으로 나누어준다. 각 Exception에서는 상황에 맞게 ErrorCode의 상태 코드 값을 request의 attribute로 set해준다. setAttribute를 해주는 이유는 AuthenticationEntryPoint에서 이 attribute의 값에 따라 response의 메시지를 다르게 작성하기 위해서이다.

여담으로 왜 request의 attribute를 사용하냐면 request객체는 요청의 처리부터 끝날 때까지 사용자의 파라미터 + 내부에서 사용하는 파라미터를 저장하는 역할을 하기 때문이다.
출처 : 김영한님

// jwt/JwtAuthenticationFilter.java

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);

        try {
            if(token != null && jwtTokenProvider.validateToken(token)) {
                token = token.split(" ")[1].trim();
                Authentication auth = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch(SecurityException | MalformedJwtException | IllegalArgumentException e) {
            request.setAttribute("exception", ErrorCode.WRONG_TYPE_TOKEN.getStatus());
            log.error("Exception [Err_Msg]: {}", e.getMessage());
        } catch(ExpiredJwtException e) {
            request.setAttribute("exception", ErrorCode.EXPIRED_TOKEN.getStatus());
            log.error("Exception [Err_Msg]: {}", e.getMessage());
        } catch(UnsupportedJwtException e) {
            request.setAttribute("exception", ErrorCode.UNSUPPORTED_TOKEN.getStatus());
            log.error("Exception [Err_Msg]: {}", e.getMessage());
        } catch(Exception e) {
            request.setAttribute("exception", ErrorCode.UNKNOWN_ERROR.getStatus());
            log.error("Exception [Err_Msg]: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}
  • SecurityException : 보안 관련한 권한이나 제한을 위반하려고 할 때 발생하는 예외
  • MalformedException : 구조적인 문제가 있는 JWT인 경우 발생하는 예외
  • IllegalArgumentException : 인자로 전달되는 값 자체가 잘못된 경우 발생하는 예외
  • ExpiredJwtException : 유효 기간이 지난(만료된) JWT를 수신한 경우 발생하는 예외
  • UnsupportedJwtException : 수신한 JWT의 형식이 애플리케이션에서 원하는 형식과 맞지 않는 경우 발생하는 예외

CustomAuthenticationEntryPoint

인증이 되지 않은 사용자가 인증이 필요한 API 엔드포인트에 접근한다면 기본적으로 401 HTTP Status Code를 응답으로 받게된다. 이 로직을 담당하는 것이 AuthenticationEntryPoint라는 인터페이스이다.

기본적인 응답이 아닌 JSON 데이터로 응답해야 하거나 특정 로직을 수행해야 하는 경우 AuthenticationEntryPoint는 인터페이스이기 때문에 구현체를 작성하여 수행할 수 있다. 이 때 commence 메서드를 오버라이드(Override)해야 한다.

이렇게 작성한 구현체는 Spring Security Configuration에 등록하여 사용한다.

CustomAuthenticationEntryPoint에서는 AuthenticationFilter에서 설정한 request의 attribute에 따라 response의 Status Code와 Message를 설정하여 준다.

// jwt/CustomAuthenticationEntryPoint.java

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        Integer exception = (Integer) request.getAttribute("exception");

        if (exception == null || exception == 4001) {
            setResponse(response, ErrorCode.UNKNOWN_ERROR);
        } else if (exception == 4002) {
            setResponse(response, ErrorCode.WRONG_TYPE_TOKEN);
        } else if (exception == 4003) {
            setResponse(response, ErrorCode.EXPIRED_TOKEN);
        } else if (exception == 4004) {
            setResponse(response, ErrorCode.UNSUPPORTED_TOKEN);
        } else {
            setResponse(response, ErrorCode.ACCESS_DENIED);
        }
    }

    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setStatus(401);
        response.setContentType("application/json; charset=UTF-8");

        JSONObject responseJson = new JSONObject();
        responseJson.put("status code", errorCode.getStatus());
        responseJson.put("error message", errorCode.getMessage());

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

SecurityConfig

SecurityConfig에서는 위에서 작성한 CustomAuthenticationEntryPoint를 authenticationEntryPoint로 설정한다.

// configuration/SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // JWT 인증 필터
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                // error handling
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("Unauthorized User.");
                    }
                })
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint());
                
        return http.build();
    }
}

🧪 테스트

여느 때처럼 올바르게 작동하는지 테스트할 차례이다.
간단하게 세 가지 경우에 대해 테스트 해보려 한다.

  1. 유효 기간이 지난 토큰을 헤더에 실어서 보냈을 때
  1. 훼손된 토큰을 보냈을 때
  1. 토큰을 싣지 않고 요청을 보냈을 때

모두 의도한대로 응답을 보내는 것을 확인할 수 있었다.

UNSUPPORTED_TOKEN과 ACCESS_DENIED에 대한 오류는 테스트할 방법을 알지 못해 아직 작성하지 못하였다. 추후 검색을 통해 방법을 숙지하고 테스트하여 내용을 추가할 예정이다.


🔍 Reference

https://velog.io/@seung7152/Spring-Security-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC

https://medium.com/@OutOfBedlam/jwt-%EC%9E%90%EB%B0%94-%EA%B0%80%EC%9D%B4%EB%93%9C-53ccd7b2ba10

https://www.inflearn.com/questions/753943/request-setattribute-%EA%B4%80%EB%A0%A8%ED%95%98%EC%97%AC-%EC%A7%88%EB%AC%B8-%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

https://sas-study.tistory.com/362

https://yoo-dev.tistory.com/28

profile
웹 백엔드 개발자가 되는 그날까지

0개의 댓글