본문 바로가기
프로젝트

GiftFunding)필터 내 예외 처리

by son_i 2023. 11. 30.
728x90

접근 권한이 필요한 리소스에 접근 할 때 토큰의 유효성을 검증하는 과정에서 @ControllerAdvice, @ExceptionHandler로 처리해 오류 코드를 내려주려고 했는데 null 에러가 뜨는 것을 확인했다.

 

 

다른 분들이 피드백을 주셨는데 필터 내 예외처리는 일반 컨트롤러단에서 예외처리하는 것과 다르게 해줘야 한다는 것을 알게되었다.

 


@ControllerAdvice 와 @ExceptionHandler

- @ExceptionHandler는 @Controller가 적용된 Bean에서 발생하는 예외를 잡아 하나의 메서드에서 처리.

 

- @ControllerAdvice는 @Controller 어노테이션이 적용된 모든 곳에서 발생하는 예외를 처리.

  따라서 @ControllerAdvice가 적용된 클래스에 정의되어 있는 @ExceptionHandler는 모든 컨트롤러에서 발생하는 예외에 대해서 동작한다.

 

@ControllerAdvice vs @RestControllerAdvice

- @ControllerAdvice

  @ControllerAdvice은 @ExceptionHandler, @ModelAttribute, @InitBinder가 적용된 메소드들에 AOP를 적용해 Contrroller단에 적용하기 위해 고안된 어노테이션.

 

클래스에 선언하며 모든 @Controller에 대한 전역적으로 발생할 수 있는 예외 처리.

 

 @Component가 선언되어 있어 빈으로 관리되어 사용

 

@ControllerAdvice의 속성 설정을 통하여 원하는 컨트롤러나 패키지만 선택할 수 있음.

 

 

- @RestControllerAdvice

@RestControllerAdvice == @ControllerAdvice + @ResponseBody

@RestControllerAdvice로 선언하면 따로 @ResponseBody를 붙여주지 않아도 객체를 리턴할 수 있음.

 에러 메세지를 DTO객체에 담아 리턴해주는 방식으로 사용

 


@ExceptionHandler

실질적으로 예외를 잡아 처리해주는 건 @ExceptionHandler

 

@ExceptionHandler을 메소드에 선언하고 특정 예외 클래스를 지정해주면 해당 예외가 발생했을 때 메서드에 발생한 로직을 처리할 수 있다.

 

AOP를 이용한 예외처리 방식이기 때문에, 각 메서드마다 try/catch할 필요없이 깔끔한 예외처리가 가능해진다.

  - @ControllerAdvice 또는 @RestControllerAdvice에 정의된 메서드가 아닌 일반 컨트롤러 단에 존재하는 메서드에 선언할 경우 해당 컨트롤러에만 적용된다.

 

 - Controller, RestController에만 적용이 가능함 (@Service와 같은 빈은 안 됨)

 

구현과정

1. 에러 메세지를 담을 ErrorResponse 클래스 정의

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponse {
    private ErrorType errorCode;
    private String errorMessage;
    private int statusCode;

    public static ErrorResponse of(ErrorType errorType, String msg, int statusCode) {
        return ErrorResponse.builder()
            .errorCode(errorType)
            .errorMessage(msg)
            .statusCode(statusCode)
            .build();
    }
}

 

2. 모든 @Controller 빈에서 던져진 에러에 대해 전역적으로 처리해줄 GlobalExceptionHandler 구현

클래스에는 @RestControllerAdvice를 붙이고 각각의 에러를 잡아 처리해줄 메소드에는 @ExceptionHandler를 붙임.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(UserException.class)
    public ErrorResponse handleUseException(UserException e) {
        log.error("{} is occurred", e.getErrorCode());

        return new ErrorResponse(
            e.getErrorCode(),
            e.getErrorMessage(),
            e.getErrorCode().getCode());
    }

    @ExceptionHandler(TokenException.class)
    public ErrorResponse handleInvalidTokenException(TokenException e) {
        log.error("{} is occurred", e.getErrorCode());

        return new ErrorResponse(
            e.getErrorCode(),
            e.getErrorMessage(),
            e.getErrorCode().getCode());
    }

    @ExceptionHandler(Exception.class)
    public ErrorResponse handleException(Exception e) {
        log.error("Exception is occurred", e);

        return new ErrorResponse(
            INTERNAL_SERVER_ERROR,
            INTERNAL_SERVER_ERROR.getDescription(),
            INTERNAL_SERVER_ERROR.getCode());
    }
}

기본적인 예외처리 방식에 대해 정리해보았다.

그런데 내 코드에선 왜 동작을 하지 않았냐 하면 토큰 검증은 filter에서 진행되는 것이기 때문에 controller를 타기도 전에 발생하는 예외라서 여기에 걸리지 않는 것 !


 

 

Filter Exception Handling 과정

 1. A필터에서 예외를 던진다.

 2. A필터보다 앞에서 동작하는 B 필터에서 Exception을 체크하고 핸들링해준다.

 3. @ControllerAdvice에서 ResponseBody에 핸들링한 에러 메세지를 담는 것처럼 Exception 내용을 Response에 추가해준다.

 4. Response는 필터들을 거쳐 클라이언트에 전달한다.

 

이를 위해서 해야할 일은

- SecurityConfig 에 addFilterBefore로 예외 발생 필터와 예외 잡아줄 필터를 설정한다.

- 예외 잡아줄 JwtExceptionFilter를 설정한다.

 

<SecurityConfig>

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtExceptionFilter jwtExceptionFilter;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(
                SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/user/signup", "/user/signin").permitAll()
            .antMatchers("/oauth/kakao", "/kakao").permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint)
            .accessDeniedHandler(accessDeniedHandler)

            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class);
            /// TODO: 2023-11-23  
            //상품 검색에 대해서는 로그인 안 한 사용자도 가능하도록 옵션추가
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers();
    }
}

addFilterBefore로 UsernamePasswordAuthenticationFilter보다 jwtAuthenticationFilter가 먼저 동작하도록,

JwtAuthenticationFilter 보다 jwtExceptionFilter가 먼저 동작하도록 설정한다.

 

<JwtExceptionFilter>

@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 (TokenException e){
            response.setStatus(e.getErrorCode().getCode());
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            String json = new ObjectMapper().writeValueAsString(
                ErrorResponse.of(e.getErrorCode(), e.getErrorMessage(), e.getErrorCode().getCode()));
            response.getWriter().write(json);
        }
    }
}

이렇게 구현을 해서 JwtAuthenticationFilter에서 Tokenprovider의 validationToken메소드를 호출할 때 발생하는 예외를 처리해주는 부분을 만들었다.

 

그런데 멘토님께서 이 예외처리필터에서 예외를 또 한 번 던지도록하고 AuthenticationEntryPoint에서 일괄적으로 처리해주는 방법을 추천해주셔서 다시 리팩토링 해봤다.

 


AuthenticationEntryPoint , AccessDeniedHandler

- AuthenticationEntryPoint (401)

인증이 실패한 상황을 처리.

 : HttpStatus 401 Unauthorized는 사용자가 인증되지 않았거나 유효한 인증 정보가 부족하여 요청이 거부된 것

 

SpringSecurity에서 예외가 발생한 후, 반환되는 AuthenticationException을 감지하여 처리해주는 인터페이스.

해당 인터페이스를 implements하여 commence메소드를 구현해 사용하면 된다.

 

- AccessDeniedHandler (403)

인가에 실패했을 경우의 예외를 처리해주는 핸들러

 : HttpStatus 403 Forbidden은 서버가 해당 요청을 이해하였으나, 사용자의 권한이 부족하여 요청이 거부된 상태

 


내가 터트리고 싶은 예외는

- Bearer 뒤에 아무것도 입력되지 않았을 때 -> INVALID_TOKEN

- Access Token의 만료시간이 지났을 때 -> TOKEN_EXPIRED

 

이때 토큰 검증할 때 벌어지는 에러들은 인증과정에서 발생하는 것이기 때문에 JwtExceptionFilter를 타지 않고 바로 CustomAuthenticationEntryPoint를 타게 됨.

 

1. SecurityConfig에 설정.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtExceptionFilter jwtExceptionFilter;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(
                SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/user/signup", "/user/signin").permitAll()
            .antMatchers("/oauth/kakao", "/kakao").permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint)
            .accessDeniedHandler(accessDeniedHandler)

            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class);
            /// TODO: 2023-11-23  
            //상품 검색에 대해서는 로그인 안 한 사용자도 가능하도록 옵션추가
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers();
    }
}

 

+ 빼먹을 뻔한게 antMatchers로 권한이 필요한 리소스를 설정해줬어야 함 !

회원가입과 로그인은 권한이 없어도 접근이 가능하도록 설정하고 (자세한 url의 동작을 먼저 지정해야함)

나머지는 인증된 사용자만 접근을 허용하도록 설정.

.permitAll() // 전체 접근 허용
.authenticated() // 인증된 사용자만 접근 허용
.annonymous() // 인증되지 않은 사용자만 접근 허용
.hasRole("ADMIN") // ROLE_ADMIN 권한을 가진 사용자만 접근 허용
.hasAnyRole("ADMIN", "USER") // ROLE_ADMIN 혹은 ROLE_USER 권한을 가진 사용자만 접근 허용

 

 

2. JwtAuthentication에서 하는 토큰 유효성 검증이 token provider의 메소드를 호출해서 이루어지므로 필터단에서 바로 throw를 할 수 없음. 전체를 감싸 try/catch 처리.

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

    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    private final GetAuthentication getAuthentication;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = this.resolveTokenFromRequest(request);
        try {
            if (this.tokenProvider.validateToken(token)) {
                Authentication auth = getAuthentication.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(auth);

                log.info("[{}] -> {}"
                    , this.tokenProvider.getMail(token)
                    , request.getRequestURI());
            }
        } catch (TokenException e) {
            request.setAttribute("exception", e.getErrorCode());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveTokenFromRequest(HttpServletRequest request) {
        String token = request.getHeader(TOKEN_HEADER);

        if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) {
            return token.substring(TOKEN_PREFIX.length());
        }
        return null;
    }

 

 

3. AuthenticationEntryPoint를 구현한 CustonAuthenticationEntryPoint

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ObjectMapper objectMapper;
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException, ServletException {

        Object exception = request.getAttribute("exception");
        log.info("인증 실패");

        if (exception instanceof ErrorType) {
            ErrorType errorType = (ErrorType) exception;
            sendResponse(response, errorType);

            return;
        }
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }

    private void sendResponse(HttpServletResponse response, ErrorType e)
        throws IOException {
        response.setStatus(e.getCode());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String json = objectMapper.writeValueAsString(
            ErrorResponse.of(e, e.getDescription(), e.getCode()));
        response.getWriter().write(json);
    }
}

 

- Attribute의 반환값은 Object이다. 반환 받은 Object Exception을 casting하기 전에 instance of 조건 절을 사용하여 ErrorType 인스턴스 형이 맞는지를 점검하고 

  true 일 때 형변환하여 response 값을 세팅해주는 sendResponse 메소드 호출.

  false일 때 HttpServletResponse에 인증오류를 반환하도록 함.

 

생각해보니까 나는 권한이 구분되어있지 않아서 AccessDeniedHandler를 구현할 필요가 없는 것 같다. 삭제함

 

응답값이 지정한대로 잘 나온다 !

 

참고

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

 

[Spring Security] AuthenticationEntryPoint, AccessDeniedHandler를 통한 인증/인가 오류 처리

인증/인가 오류 처리 우리는 Spring Security를 통해 사용자의 권한을 처리하게 된다. Security 설정을 통해 특정 엔드포인트로의 요청에 필요한 권한 등을 설정할 수 있다. @Bean public SecurityFilterChain oaut

yoo-dev.tistory.com

https://velog.io/@coastby/SpringSecurity-filter-%EB%82%B4%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

 

[SpringSecurity] filter 내에서 발생한 예외 처리하기

REST 예외를 처리하기 위해 일반적으로 Spring MVC에서 @ControllerAdvice 및 @ExceptionHandler를 사용하지만 이러한 핸들러는 요청이 DispatcherServlet에 의해 처리되는 경우 작동한다. 그러나 보안 관련 예외는

velog.io

https://batory.tistory.com/454

 

AuthenticationEntryPoint

개인 공부 목적으로 작성한 포스팅입니다. 아래 출처를 참고하여 작성하였습니다. 1. AuthenticationEntryPoint란 ? 인증 처리 과정에서 예외가 발생한 경우 예외를 핸들링하는 인터페이스입니다. e.g. Au

batory.tistory.com

https://thalals.tistory.com/451

 

Spring Security Exception Handling - Filter 단 예외 처리하기

오늘은 Spring Security 를 적용했지만 JWT 가 만료되거나, 잘못된 토큰일 경우 401 코드 뿐만아니라 에러 메세지까지 핸들링 해줄 수 있도록 설정해 주고자 합니다. 1. Spring Security 와 @ControllerAdvice 먼

thalals.tistory.com