Spring

Spring Security JWT 토큰 검증 시 Exception 예외 처리

집관리사 2020. 11. 14. 19:54

Spring Security 예외

Spring Security에서 토큰을 검증할 경우, 예외가 발생한다면 기존에 사용 중이던 Custom Exception으로 처리가 될까?

 

그러면 편하긴 하겠지만 그건 안될 말이지^^

🙃아니🙃 왜 안되는 걸까?

우리가 사용하는 Custom Exception은 Spring의 영역이다. 그에 반해 Spring Security는 Spring 이전에 필터링 한다.

그러니까 아무리 Security단에서 예외가 발생해도 절대 Spring의 DispatcherServlet까지 닿을 수가 없다는 말이다. 

 

이제 귀찮으니 그냥 시큐리티라고 쓰겠다.

시큐리티에서 원하는 예외 처리를 하고 싶다면 귀찮지만🙊 또 추가적인 설정을 해줘야 한다.

 

해당 포스트에서는 JWT 토큰의 네 가지 예외에 대해서 다룬다. 

1. 토큰 없음

2. 시그니처 불일치

3. 토큰 만료

4. 토큰 인증 후 권한 거부

 

또한 아래와 같은 방법으로 예외 처리를 한 이유는 

1. 리다이렉트를 하지 않으려고

2. 내가 원하는 대로 세세한 예외처리를 하려고

라는 요구 사항이 있었기 때문이다.

 

그리고 1~3번까지의 예외와 4번의 예외는 다르게 처리된다.

 

1~3번까지의 예외 처리

요약하자면

1. 시큐리티 필터에서 발생하는 예외를 try-catch로 잡는다.

2. AuthenticationEntryPoint에서 각각 예외를 구분하여 적절한 내용을 response에 담아준다.

3. 그대로 예외가 출력된다.

이다.

 

코드는 아래를 참고하면 된다.

 

1.  JWT 토큰 인증 필터

아래 코드는 JWT 토큰 인증 필터에 있는 메서드이다. 보면 알겠지만 try-catch로 예외들을 분리하였다.

그리고 그 안에서 request.setAttribute()로 해당 예외가 어떤 종류의 예외인지 구분할 수 있도록 내부적으로 사용하는 에러 코드를 넣어주었다. 

    /**
     * jwt 토큰을 확인하고 인증 객체를 만들어 반환한다.
     */
    private Authentication getAuthentication(HttpServletRequest request) {
        String authorization = request.getHeader(JwtProperties.HEADER_STRING);
        if(authorization == null) {
            return null;
        }

        String token = authorization.substring(JwtProperties.TOKEN_PREFIX.length());
        
        Claims claims = null;

        try {
            claims = jwtUtil.parseToken(token);
        } catch (ExpiredJwtException e) {
            e.printStackTrace();
            request.setAttribute("exception", ErrorCode.EXPIRED_TOKEN.getCode());
        } catch (JwtException e) {
            e.printStackTrace();
            request.setAttribute("exception", ErrorCode.INVALID_TOKEN.getCode());
        }

        return new UserAuthentication(claims);
    }

 

2. CustomAuthenticationEntryPoint

위 코드에서 예외가 발생하면 AuthenticationEntryPoint로 간다. 그러므로 우리는 CustomAuthenticationEntryPoint를 구현해서 나머지 코드를 작성하면 된다.

CustomAuthenticationEntryPoint로 넘어간 뒤에도 동일한 Request가 있으므로 위에서 처리했던 Attribute를 가져와서 조건문으로 응답 값을 다르게 주었다.

setResponse()라는 메서드를 이용해서 Response에 원하는 형태의 에러 메세지를 출력할 수 있도록 하였다.

리다이렉트를 사용한다면 여기서 원하는 곳으로 리다이렉트하여 Controller Advice처럼 Spring에서 예외 처리가 가능하다.
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

        log.debug("log: exception: {} ", exception);

        /**
         * 토큰 없는 경우
         */
        if(exception == null) {
            errorCode = ErrorCode.NON_LOGIN;
            setResponse(response, errorCode);
            return;
        }

        /**
         * 토큰 만료된 경우
         */
        if(exception.equals(ErrorCode.EXPIRED_TOKEN.getCode())) {
            errorCode = ErrorCode.EXPIRED_TOKEN;
            setResponse(response, errorCode);
            return;
        }

        /**
         * 토큰 시그니처가 다른 경우
         */
        if(exception.equals(ErrorCode.INVALID_TOKEN.getCode())) {
            errorCode = ErrorCode.INVALID_TOKEN;
            setResponse(response, errorCode);
        }
    }

    /**
     * 한글 출력을 위해 getWriter() 사용
     */
    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().println("{ \"message\" : \"" + errorCode.getMessage()
                + "\", \"code\" : \"" +  errorCode.getCode()
                + "\", \"status\" : " + errorCode.getStatus()
                + ", \"errors\" : [ ] }");
    }

	}

 

3. 시큐리티 설정 추가

마지막으로 시큐리티 설정에 CustomAuthenticationEntryPoint를 추가해주기만 하면 된다.

아래에서 네 번째 줄에 있는 .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) 이다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        Filter filter = new JwtAuthenticationFilter(authenticationManager(), jwtUtil());

        http
                .httpBasic().disable()
                .cors().and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/*/*/signin", "/*/*/users/**", "/exception/**").permitAll()
                .antMatchers(HttpMethod.GET, "/api/v1/terms").permitAll()
                .anyRequest().hasRole("SUB")
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and().addFilter(filter);
    }

 

 

4번 예외 처리

4번은 인증이 아니라 권한에서 예외가 발생한 것이므로 다르게 처리한다.

 

1. AccessDeniedHandler

AccesDenied의 경우는 AuthenticationEntryPoint가 아닌 AccessDeniedHandler 를 커스텀해서 사용한다.

위와 동일하게 리다이렉트를 사용한다면 원하는 곳으로 리다이렉트하여 Spring에서 예외 처리가 가능하다.
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().println("{ \"message\" : \"" + ErrorCode.ACCESS_DENIED.getMessage()
                + "\", \"code\" : \"" +  ErrorCode.ACCESS_DENIED.getCode()
                + "\", \"status\" : " + ErrorCode.ACCESS_DENIED.getStatus()
                + ", \"errors\" : [ ] }");
    }
}

 

2. 시큐리티 설정 추가

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        Filter filter = new JwtAuthenticationFilter(authenticationManager(), jwtUtil());

        http
                .httpBasic().disable()
                .cors().and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/*/*/signin", "/*/*/users/**", "/exception/**").permitAll()
                .antMatchers(HttpMethod.GET, "/api/v1/terms").permitAll()
                .anyRequest().hasRole("SUB")
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())
                .and().addFilter(filter);
    }