본문 바로가기
부트캠프 개발일기/Pre-Project

90일차: Pre-Project Day 10-1 (인증 에러 처리: AuthenticationFailureHandler, AuthenticationEntryPoint)

by shyun00 2023. 6. 22.

로그인, 로그인 상태 검증 등 인증과 관련된 내용을 정리하다보니 인증에 실패했을때 리턴 형태에 대해 고민하게 되었다.

지금은 기본 지정된 상태로 값이 리턴되고 있다.

이걸 에러코드 / 메세지로 깔끔하게 보내는건 어떨까? 하는 생각이 들었다.

인증과 관련해서 발생할 수 있는 에러는 크게 두가지로 나뉜다.

먼저 로그인(Authentication)과정에서 실패하는 경우 -> AuthenticationFailureHandler를 통해 원하는 작업을 지정할 수 있다.

그 외 인증 과정에서 만료된 토큰이나 조작된 토큰 등 유효하지 않은 인증 정보를 사용하는 경우 -> 발생한 에러를 AuthenticationEntryPoint에서 잡아서 처리할 수 있다.

 

먼저 로그인에 실패했을때 에러 코드 / 메세지를 출력할 수 있도록 하였다.

(처음엔... 인증 관련 모든 예외는 AuthenticationEntryPoint에서 처리하는줄알고 Postman으로 계~속 테스트를 했는데 안돼서...

이것저것 알아보다가 AuthenticationFailureHandler가 따로 있다는 것을 알게되었다...ㅎㅎ)

 

인증과정에서 실패했을때도 에러코드 / 메세지를 출력하도록 하였다.

[공통처리 (로그인 실패, 인증 실패)]

1.  ErrorResponse: 예외 발생시 status, message를 리턴할 수 있도록 하였다.

    생성자는 private으로 하고 of() 메서드를 통해 생성하도록 하였다.

    -> ErrorResponse 클래스는 인증 외에도 다른 예외상황에서도 사용할 예정이므로, 필드 추가 등 코드는 변경될 예정이다.

public class ErrorResponse {
    private int status;
    private String message;

    private ErrorResponse(int status, String message) {
        this.status = status;
        this.message = message;
    }
    public static ErrorResponse of(HttpStatus httpStatus) {
        return new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase());
    }
}

[로그인 실패 처리]

1. MemberAuthenticationFailureHandler: 로그인 인증에 실패했을때 작동할 핸들러이다.

    AuthenticationFailureHandler를 구현한 클래스로 작성하였다.
    AuthenticationFailureHandler는 Spring Security에서 인증에 실패했을 때 호출되는 핸들러 메서드이다.

    onAuthenticatinoFailure() 메서드를 오버라이드하여 원하는 작업을 설정해줄 수 있다.

    현재 코드에서는 로그를 남기고, 클라이언트에게 에러 메세지를 전달하는 기능을 추가했다.

    ErrorResponse 객체를 gson 을 사용해 JSON 형태로 변환하여 HttpServletResponse 출력 스트림을 통해 클라이언트로 전달한다.

@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.error("# Authentication failed: {}", exception.getMessage());

        sendErrorResponse(response);
    }

    private void sendErrorResponse(HttpServletResponse response) throws IOException {
        Gson gson = new Gson();
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
    }
}

2. Gson 라이브러리 추가: Gson을 사용하기 위해서는 라이브러리 추가를 해주어야한다.

    Gson은 Google에서 개발한 Java용 JSON 라이브러리로 객체를 직렬화 / 역질렬화 하는 기능을 제공한다.

dependencies {
   ...
   implementation 'com.google.code.gson:gson:2.8.7'
}

3. SecurityConfiguration 설정: 작성한 AuthenticationFailureHandler를 설정파일에 추가한다.

public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
      
    @Override
    public void configure(HttpSecurity builder) throws Exception {
            
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
        jwtAuthenticationFilter.setFilterProcessesUrl("/login");

        // Handler 추가
        jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer);
        
        builder.addFilter(jwtAuthenticationFilter) 
               .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); 
    }
}

[결과 확인]

처음에는 기본값 형태(좌)로 응답이 왔었으나, handler 적용 후에는 오른쪽과 같이 지정한 형태로 응답이 도착했다.

필요에 따라 해당 내용을 수정하여 활용할 수 있다.

로그도 잘 남겨져있는것을 확인할 수 있다.

[인증 실패 처리]

1. JwtVerificationFilter: 지난번에 작성했던 JWT를 검증하는 필터이다.

    JWT 검증과정에서 유효시간 초과, 부적절한 서명 등 예외가 발생하면

    해당 예외를 catch 해서 HttpServletRequest의 Attribute로 해당 예외를 "exception"이라는 이름으로 저장해두도록 했다.

public class JwtVerificationFilter extends OncePerRequestFilter {

   ...
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try{
            Map<String, Object> claims = verifyJws(request);
            setAuthenticationContext(claims);
        } catch (SignatureException se) {
            request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        } // 에러가 발생하면 request attribute로 "exception"을 등록한다.

        filterChain.doFilter(request, response);
    }

2. MemberAuthenticationEntryPoint: AuthenticationEntrypoint를 구현하는 클래스이다.

    AuthenticationEntryPoint는 인증이 필요한 리소스에 접근할 때 호출되는 인터페이스이다.

    AuthenticationException이 발생한 경우에 처리해주는 핸들러 역할을 하도록 설정할 수 있다.

    commence() 메서드를 재정의하면 원하는 동작을 지정할 수 있다. (JSON 형태의 에러 응답 전송, 로그인페이지 리다이렉트 등)

@Component
@Slf4j
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    private static final Gson gson = new Gson();
    private static final ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("# Unauthorized error happened: {}", request.getAttribute("exception"));

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        // MemberAuthenticationFailureHandler의 아래 코드와 같은 역할. errorResponse를 직렬화 한다.
        // response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));        
        try (Writer writer = response.getWriter()) {
            gson.toJson(errorResponse, writer);
        }
    }
}

3. SecurityConfiguration: 작성한 EntryPoint를 설정파일에 적용한다.

@Configuration
public class SecurityConfiguration implements WebMvcConfigurer {
    ...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...
                .exceptionHandling()
                .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
                .and()
                ...

        return http.build();
    }

[결과 확인]

잘못된 토큰을 넣어 보냈을때 처음에는 기본값 형태(좌)로 응답이 왔었으나,

EntryPoint 적용 후에는 오른쪽과 같이 지정한 형태로 응답이 도착했다. 필요에 따라 내용은 변경해서 사용할 수 있다.

 

참고자료

Gson 라이브러리 사용법 및 예제

Spring Security 인증 인가 예외 처리

Restful API 구현을 위한 Spring Security(AuthenticationEntryPoint, AccessDeniedHandler)