본문 바로가기
공부/프로그래밍

[spring security oauth] 403이 아닌 406 에러가 나는 경우 (Accept 설정에 따른 문제)

by demonic_ 2020. 1. 30.
반응형

spring security oauth 로 인증요청 할 경우 에러가 2가지 있다

401 Unauthorized

403 FORBIDDEN(Access Denied)

401의 경우 로그인 실패시, 403은 인증한 토큰이 잘못되었을 경우 발생한다.

사내에서 사용하고 있는 API 헤더 2가지 옵션인 Accept와 Content-Type 를 json으로 통신하되 커스텀하게 붙인게 있다

Content-Type: application/json+custom
Accept: application/json+custom

 

이게 정상으로 처리가 되었을 경우 문제가 되지 않았는데 에러가 발생할 때 403 에러를 리턴해야 하는데 406 에러를 리턴했다.

HTTP 상태 406 – 받아들일 수 없음(Not Acceptable)
요청으로부터 받은 proactive negotiation 헤더에 따르면, 
대상 리소스는 해당 user agent가 받아들일만한 현재의 representation이 없고, 
서버 또한 기본 representation을 제공하지 않으려 합니다.

살펴보니 403 에러떄문에 OAuth2Exception 에서 처리하던 중, 등록되어있지 않은 MediaType 이어서 406 에러가 발생한 것이다.

그래서 로그를 살펴보니 다음과 같은 문구가 발견된다.

[01-30 16:42:26] [mvc.support.DefaultHandlerExceptionResolver/198] Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]

 

일단 살펴보기에 앞서 Accept 에 사용된 MediaType의 종류가 기본 설정내에 있다면 문제가 발생하지 않는다.

MediaType의 기본 설정 다음과 같다.

0 = {MediaType@13285} "application/octet-stream"
1 = {MediaType@13286} "*/*"
2 = {MediaType@13287} "text/plain"
3 = {MediaType@13286} "*/*"
4 = {MediaType@13286} "*/*"
5 = {MediaType@13288} "application/xml"
6 = {MediaType@13289} "text/xml"
7 = {MediaType@13290} "application/*+xml"
8 = {MediaType@13291} "application/x-www-form-urlencoded"
9 = {MediaType@13292} "multipart/form-data"
10 = {MediaType@13288} "application/xml"
11 = {MediaType@13289} "text/xml"
12 = {MediaType@13293} "application/*+xml"
13 = {MediaType@13294} "application/json"
14 = {MediaType@13295} "application/*+json"
15 = {MediaType@13296} "application/cbor"
16 = {MediaType@13288} "application/xml"
17 = {MediaType@13289} "text/xml"
18 = {MediaType@13297} "application/*+xml"	

 

그런데 라이브에서 운영하다보니 위와같은 에러가 발생했다.

개발초기에 Accept 를 application/json+custom 로 설정해 주고받기로 했는데,

제대로 인수인계가 되지않아 테스트하는 과정에서는 기본 MediaType(application/json) 으로 진행되어 발견이 안되었던 것이다.

 

이쯤에서 Accept와 Content-Type의 차이를 알아보자

Content-Type: 데이터를 전송하는 쪽에서 데이터형식을 알려주는 헤더

Accept: 클라이언트에서 웹서버로 요청시 요청메세지에 담기는 헤더로써, 선언한 타입만 허용하겠다는 의미

 

즉 에러가 발생해서 return 하려하는데 선언되지 않는 MediaType이라 에러가 발생한 것이다.

 

 

메세지를 보면 HttpMediaTypeNotAcceptableException 클래스에서 에러를 발생했기에 해당 클래스를 쫓아가보았다.

package org.springframework.web;

import java.util.List;
import org.springframework.http.MediaType;

public class HttpMediaTypeNotAcceptableException extends HttpMediaTypeException {
    public HttpMediaTypeNotAcceptableException(String message) {
        super(message);
    }

    public HttpMediaTypeNotAcceptableException(List<MediaType> supportedMediaTypes) {
        super("Could not find acceptable representation", supportedMediaTypes);
    }
}

 

 

클래스를 호출하는 것은 DefaultOAuth2ExceptionRenderer 에서 호출하는 것이다.

그리고 이 클래스는 OAuth2ExceptionRenderer을 구현한 클래스다.

public class DefaultOAuth2ExceptionRenderer implements OAuth2ExceptionRenderer {
...
	private void writeWithMessageConverters(Object returnValue, HttpInputMessage inputMessage,
			HttpOutputMessage outputMessage) throws IOException, HttpMediaTypeNotAcceptableException {
		List<MediaType> acceptedMediaTypes = inputMessage.getHeaders().getAccept();
		if (acceptedMediaTypes.isEmpty()) {
			acceptedMediaTypes = Collections.singletonList(MediaType.ALL);
		}
		MediaType.sortByQualityValue(acceptedMediaTypes);
		Class<?> returnValueType = returnValue.getClass();
		List<MediaType> allSupportedMediaTypes = new ArrayList<MediaType>();
		for (MediaType acceptedMediaType : acceptedMediaTypes) {
			for (HttpMessageConverter messageConverter : messageConverters) {
				if (messageConverter.canWrite(returnValueType, acceptedMediaType)) {
					messageConverter.write(returnValue, acceptedMediaType, outputMessage);
					if (logger.isDebugEnabled()) {
						MediaType contentType = outputMessage.getHeaders().getContentType();
						if (contentType == null) {
							contentType = acceptedMediaType;
						}
						logger.debug("Written [" + returnValue + "] as \"" + contentType + "\" using ["
								+ messageConverter + "]");
					}
					return;
				}
			}
		}
		for (HttpMessageConverter messageConverter : messageConverters) {
			allSupportedMediaTypes.addAll(messageConverter.getSupportedMediaTypes());
		}
		throw new HttpMediaTypeNotAcceptableException(allSupportedMediaTypes);
	}
...
}

 

 

이것이 403이 아닌 406을 발생한것과 무슨 관계가 있을까?

위 코드를 보면 for문을 수행하면서 허용된 MediaTypes(acceptedMediaTypes)인지 확인하는데 messageConverter에 등록되어 있지 않다보니 throw new HttpMediaTypeNotAcceptableException(allSupportedMediaTypes); 가 수행되고 상위 메서드의 ServletException에 걸린 것이다.

public abstract class AbstractOAuth2SecurityExceptionHandler {
...
	protected final void doHandle(HttpServletRequest request, HttpServletResponse response, Exception authException)
			throws IOException, ServletException {
		try {
			ResponseEntity<?> result = exceptionTranslator.translate(authException);
			result = enhanceResponse(result, authException);
			exceptionRenderer.handleHttpEntityResponse(result, new ServletWebRequest(request, response));
			response.flushBuffer();
		}
		catch (ServletException e) {
			// Re-use some of the default Spring dispatcher behaviour - the exception came from the filter chain and
			// not from an MVC handler so it won't be caught by the dispatcher (even if there is one)
			if (handlerExceptionResolver.resolveException(request, response, this, e) == null) {
				throw e;
			}
		}
		catch (IOException e) {
			throw e;
		}
		catch (RuntimeException e) {
			throw e;
		}
		catch (Exception e) {
			// Wrap other Exceptions. These are not expected to happen
			throw new RuntimeException(e);
		}
	}
...

 

 

우선 해결책만 말하자면 OAuthExceptionRenderer 를 구현할 때, MessageConverter에 application/json+custom 를 추가하면 된다.

추가하려면 해당 추상클래스를 상속한 구현체가 필요하다.

 

 

잠깐 설정부분을 살펴보자

현재 사내 시스템은 Spring boot 가 아닌 Spring 5.x 를 사용하고 있다. 그래서 xml로 다음과 같이 등록되어 있다

<sec:http
	pattern="/api/**"
	...

	<sec:access-denied-handler ref="oauthAccessDeniedHandler" />
</sec:http>

...

<bean
	id="oauthAccessDeniedHandler"
	class="com.test.oauth.CustomAccessDeniedHandler" />

 

 

access-denied-handler 를 보면 CustomAccessDeniedHandler 클래스를 참조하게 되있다.

CustomAccessDeniedHandler 는 OAuth2AccessDeniedHandler 를 구현한 클래스다.

 

내부를 보면 exceptionRenderer를 등록하게 되어있는데, exceptionRenderer의 구현을 Spring에서 기본 제공하는 DefaultOAuth2ExceptionRenderer 를 사용했다.

이중에 MediaType 을 추가하는 것만 구현했다.

@Component
public class CustomAccessDeniedHandler extends OAuth2AccessDeniedHandler {
    public CustomAccessDeniedHandler() {
		setExceptionRenderer(mOAuth2ExceptionRenderer);
	}

	private OAuth2ExceptionRenderer mOAuth2ExceptionRenderer = new DefaultOAuth2ExceptionRenderer() {
		@Override
		public void handleHttpEntityResponse(HttpEntity<?> responseEntity, ServletWebRequest webRequest)
				throws Exception {
			
			setMessageConverters(geDefaultMessageConverters());

			super.handleHttpEntityResponse(response, webRequest);
		}
	};

	private List<HttpMessageConverter<?>> geDefaultMessageConverters() {
		List<HttpMessageConverter<?>> result = new ArrayList<HttpMessageConverter<?>>();
		result.addAll(new RestTemplate().getMessageConverters());
		result.add(new JaxbOAuth2ExceptionMessageConverter());

		MappingJackson2HttpMessageConverter httpMessageConverter = new MappingJackson2HttpMessageConverter();
		List<MediaType> mediaTypes = new ArrayList<>();
		mediaTypes.add(new MediaType("application", "json+custom"));
		httpMessageConverter.setSupportedMediaTypes(mediaTypes);
		result.add(httpMessageConverter);

		return result;  
	}
}

 

 

setMessageConverters 에 등록할 때, MediaType 을 추가할 수 있는데 기본은 다음처럼 19개가 등록된다.

위의 코드를 통해 json+custom 이 추가되었다.

0 = {MediaType@13285} "application/octet-stream"
1 = {MediaType@13286} "*/*"
2 = {MediaType@13287} "text/plain"
3 = {MediaType@13286} "*/*"
4 = {MediaType@13286} "*/*"
5 = {MediaType@13288} "application/xml"
6 = {MediaType@13289} "text/xml"
7 = {MediaType@13290} "application/*+xml"
8 = {MediaType@13291} "application/x-www-form-urlencoded"
9 = {MediaType@13292} "multipart/form-data"
10 = {MediaType@13288} "application/xml"
11 = {MediaType@13289} "text/xml"
12 = {MediaType@13293} "application/*+xml"
13 = {MediaType@13294} "application/json"
14 = {MediaType@13295} "application/*+json"
15 = {MediaType@13296} "application/cbor"
16 = {MediaType@13288} "application/xml"
17 = {MediaType@13289} "text/xml"
18 = {MediaType@13297} "application/*+xml"	
19 = {MediaType@13298} "application/json+custom"	

 

이제 테스트를 진행해보면 아까와 같은 에러메세지는 없어졌고 403 으로 정상에러를 리턴한다.

 

 

테스트 작성하기 (Junit5 기준)

...
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {
    "file:src/main/webapp/WEB-INF/spring/security-context.xml"
    ... }
@WebAppConfiguration
@TestPropertySource(properties = {"spring.profiles.active=local"})
class CustomAccessDeniedHandlerTest {

    @Autowired
    WebApplicationContext context;

    private MockMvc mockMvc;

    @BeforeEach
    void init() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @Test
    @DisplayName("잘못된 토큰으로 시도")
    public void errorToken403() throws Exception{
        // given
        String url = "/api/test/search";
        String params = "요청값";
        // 일부로 잘못된 토큰 넣음
        String accessToken = "bearer dfb54ae6-da30-4be1-8e3f-3d33d32a53ab";

        MvcResult mvcResult = mockMvc.perform(post(url)
                .accept("application/json+custom")
                .contentType("application/json+custom")
                .header("Authorization", accessToken)
                .content(params)
        ).andDo(print()).andReturn();

        System.out.println("======================");
        int status = mvcResult.getResponse().getStatus();
        Assertions.assertEquals(403, status);
    }
}

 

Console 확인

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /api/test/search
       Parameters = {}
          Headers = [Content-Type:"application/json+custom", Accept:"application/json+custom", Authorization:"bearer dfb54ae6-da30-4be1-8e3f-3d33d32a53ab"]
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = null

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 403
    Error message = null
          Headers = [Pragma:"no-cache", Cache-Control:"no-store", Content-Type:"application/json+custom;charset=UTF-8"]
     Content type = application/json+custom;charset=UTF-8
             Body = {}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
======================
403

 

 

끝.

 

 

 

참조사이트: 

https://webstone.tistory.com/66

 

Contents-Type Header 와 Accept Header의 차이점

Content-Type: HTTP 메시지(요청과 응답 모두)에 담겨 보내는 데이터의 형식을 알려주는 헤더이다. HTTP 표준 스펙을 따르는 브라우저와 웹서버는 Content-Type 헤더를 기준으로 HTTP 메시지에 담긴 데이터를 분석..

webstone.tistory.com

 

반응형

댓글