Spring Security의 Unauthorized, Forbidden 처리

하루히즘·2021년 12월 31일
9

Spring Framework

목록 보기
11/15

서론

Spring Security를 적용해서 HTTP 요청에 대해 인증 및 인가를 적용할 경우 시큐리티 필터 체인에 의해 인증 여부나 인가 여부에 따라 요청이 수락되거나 거절된다. 그런데 필터 체인 특성상 이런 Spring Security에 의한 차단은 서블릿 필터 단계에 속하는 부분이기 때문에 @ControllerAdvice 같은 예외 처리기로 처리할 수 없다. 그래서 JSON 응답을 커스텀하려면 별도로 설정이 필요하다.

본론

ExceptionTranslationFilter

Spring Security를 적용하고 아무런 설정도 변경하지 않으면 모든 요청에 대해서 401 Unauthorized 응답을 받게 된다.

C:\Users\park2>curl http://localhost:8080 -v
...
> GET / HTTP/1.1
> Host: localhost:8080
...
< HTTP/1.1 401
...

화면을 템플릿(뷰)으로 직접 그려내는 전통적인 MVC 애플리케이션이라면 별 문제가 없겠지만 API 기반으로 동작하는 경우 클라이언트 측 문제(400)나 서버 측 문제(500)로 인해 요청이 실패한 경우 그 이유나 요청 시각 등 API 응답에 필요한 항목을 바디에 포함해야 할 것이다.

하지만 언급했듯이 Spring Security에 의해서 차단된 경우 필터 단에서 걸러지기 때문에 이는 시큐리티 설정 클래스에서 직접 핸들러를 등록해야 한다. 이 때 사용할 수 있는 인터페이스가 AuthenticationEntryPointAccessDeniedHandler다.

스프링 시큐리티 필터 체인에는 인증 예외 AuthenticationException, 인가 예외 AccessDeniedException를 처리하는 ExceptionTranslationFilter가 등록되어 있다. 이 필터에서는 인증 예외가 발생했다면 AuthenticationEntryPoint를, 인가 예외가 발생했다면 익명(anonymous) 사용자일 경우 AuthenticationEntryPoint를, 그렇지 않다면 AccessDeniedHandler를 실행한다. 왜냐면 익명 사용자일 경우 인가된 사용자인데 인증이 안된건지 아닌지 파악할 수 없기 때문이다.

authenticationEntryPoint

그래서 인증 예외가 발생했을 때 응답을 커스텀하려면 인증 예외가 발생했을 때 불리는 처리기를 변경해야 한다. 이를 위해 스프링 시큐리티 설정 클래스에서 HttpSecurityauthenticationEntryPoint 메서드에 AuthenticationEntryPoint 인터페이스의 구현체를 파라미터로 넘기는 방식으로 활용할 수 있다.

구현체는 아래와 같은 방식으로 입맛에 맞게 작성할 수 있다.

private final AuthenticationEntryPoint unauthorizedEntryPoint =
    (request, response, authException) -> {
        ErrorResponse fail = ...; // Custom error response.
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        String json = objectMapper.writeValueAsString(fail);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = response.getWriter();
        writer.write(json);
        writer.flush();
    };

지금은 JSON 형태로 응답을 반환하기 때문에 별도로 응답용 클래스를 정의해두고 이를 Jackson의 ObjectMapper로 변환하여 직접 응답 바디에 출력하였다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
    .exceptionHandling()
    .authenticationEntryPoint(unauthorizedEntryPoint)
    ...

그리고 이 핸들러를 사용하도록 authenticationEntryPoint 메서드에 넘겨서 설정해주면 이후 AuthenticationException이 발생할 때 등록한 핸들러에 의해 처리되기 때문에 원하는 대로 응답을 커스텀할 수 있다.

GET /api/user

{
    "success": false,
    "result": null,
    "message": "Full authentication is required to access this resource"
}

위의 경우는 Authorization 헤더에 JWT를 포함하지 않았을 경우 인증 실패로 인해 발생하는 401 응답이다. 만약 시큐리티 설정에서 핸들러를 등록해주지 않았다면 403 상태 코드 외에는 아무런 응답이 반환되지 않을 것이다.

C:\Users\park2>curl http://localhost:8080/api/user -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /api/user HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
>
< HTTP/1.1 403
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 0
< Date: Fri, 31 Dec 2021 16:06:50 GMT
<
* Connection #0 to host localhost left intact

C:\Users\park2>

왜 인증 예외가 발생했는데 403이 발생하는 것일까? 이는 스프링 시큐리티에서 아무런 AuthenticationEntryPoint가 등록되지 않았을 경우 Http403ForbiddenEntryPoint를 사용하기 때문이다. authenticationEntryPoint 메서드의 설명을 확인해보면 다음과 같다.

Sets the AuthenticationEntryPoint to be used.
If no authenticationEntryPoint(AuthenticationEntryPoint) is specified, then defaultAuthenticationEntryPointFor(AuthenticationEntryPoint, RequestMatcher) will be used. The first AuthenticationEntryPoint will be used as the default if no matches were found.
If that is not provided defaults to Http403ForbiddenEntryPoint.

현재 애플리케이션에서는 JWT를 기반으로 인증하고 있기 때문에 스프링 시큐리티에서 자체적으로 제공하는 인증 서비스(폼 로그인, Basic 등)를 사용하고 있지 않다. 그렇기 때문에 별도의 AuthenticationEntryPoint가 등록되지 않았다.

실제로 호출 스택을 확인해보면 아래처럼 예외 처리 필터인 ExceptionTranslationFilter에서 Http403ForbiddenEntryPoint를 호출하는 것을 볼 수 있다.

소스 코드를 확인해보면 이 핸들러에서는 아래처럼 단순히 서블릿의 sendError 메서드만 호출하고 있는 것을 볼 수 있다.

response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");

그렇기 때문에 응답 바디에 별다른 정보가 담기지 않고 403으로만 처리되는 것이다. 만약 401로 바꾸고 싶다면 위처럼 별도의 핸들러를 구현하여 등록해야 할 것이다.

accessDeniedHandler

비슷하게 인가 예외가 발생했을 경우도 HttpSecurityaccessDeniedHandler를 이용하여 AccessDeniedHandler 인터페이스의 구현체를 전달하면 된다.

구현체는 다음처럼 위와 비슷하게 작성할 수 있다.

private final AccessDeniedHandler accessDeniedHandler = 
(request, response, accessDeniedException) -> {
    ErrorResponse fail = ...;
    response.setStatus(HttpStatus.FORBIDDEN.value());
    String json = objectMapper.writeValueAsString(fail);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    PrintWriter writer = response.getWriter();
    writer.write(json);
    writer.flush();
};

설정 역시 동일하다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .exceptionHandling()
        .accessDeniedHandler(accessDeniedHandler)
        .authenticationEntryPoint(unauthorizedEntryPoint);
    ...

그래서 인가 예외를 발생시키는 로직을 실행시켜보면 다음처럼 응답이 커스텀되어 잘 나오는 것을 확인할 수 있다.

PUT /api/room/1/users/user123
Authorization: Bearer ...

{
    "success": false,
    "result": null,
    "message": "User user123 is not manager of room #1"
}

역시 위의 설정을 적용하지 않는다면 기본적인 정보만 제공되는 것을 볼 수 있다.

{
    "timestamp": "2021-12-31T16:35:05.571+00:00",
    "status": 403,
    "error": "Forbidden",
    "path": "/api/room/1/users/user123"
}

위의 인증 예외 때와는 달리 별다른 설정 없이도 특정 포맷으로 제공되는 것을 볼 수 있는데 이는 스프링의 ErrorMvcAutoConfiguration으로 등록된 BasicErrorController라는 에러 처리 전용 컨트롤러에서 미리 정의된 포맷으로 응답을 제공하기 때문이다.

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    HttpStatus status = getStatus(request);
    if (status == HttpStatus.NO_CONTENT) {
        return new ResponseEntity<>(status);
    }
    Map<String, Object> body = getErrorAttributes(
        request,
        getErrorAttributeOptions(request, MediaType.ALL));
    return new ResponseEntity<>(body, status);
}

위의 error 메서드는 MVC 환경이라면 Whitelabel Error Page를, API 환경이라면 Map 기반 오류 정보 응답을 제공한다.

하지만 아직 제대로 파악하지 못한 부분이 있는데 ExceptionTranslationFilter에서는 accessDeniedHandler가 등록되지 않았을 경우 자체적으로 AccessDeniedHandlerImpl라는 구현체를 활용한다. 해당 구현체에서는 위의 Http403ForbiddenEntryPoint처럼 서블릿의 sendError 메서드만 호출하고 있는데 어디선가 /error 도메인으로 요청이 포워딩되어 위처럼 BasicErrorController가 이를 잡아 처리하는 방식으로 진행되는 것을 확인할 수 있었다.

디스패처 서블릿을 확인해보면 /error 도메인으로 요청이 나가는 것을 확인할 수 있는데 문서 설명 상으로는 별도의 errorPage 필드가 설정되지 않을 경우 포워딩이 진행되지 않는다고 한다. 어디에서 포워딩되는지 추후 확인해봐야 할 것이다.

결론

스프링 시큐리티에 JWT를 적용한 이후 가끔씩 인증 정보를 입력하지 않았지만 403 Forbidden이 반환되는 것을 보고 갸우뚱 했다가 신경쓰지 않고 금방 잊어버린 기억이 있다. 오늘에서야 그 이유를 알 수 있어서 다행이다.

그렇지만 /error로 누가 포워딩시키는지는 아무리 찾아봐도 알 수 없었다. 스프링 블로그에서는 내부적으로 포워딩한다고 하는데 디버깅으로 찾아볼 수 없는 영역에서 호출하는 건지 궁금하다. 좀 더 조사가 필요할 듯.

profile
YUKI.N > READY?

4개의 댓글

comment-user-thumbnail
2022년 1월 1일

:thumbsup:

1개의 답글
comment-user-thumbnail
2022년 9월 2일

많은 도움됐습니다!!!

1개의 답글