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
'공부 > 프로그래밍' 카테고리의 다른 글
[intellij] junit 으로 작성한 테스트가 gradle 로 실행될때 (3) | 2020.02.13 |
---|---|
[springboot] 데이터 사용 Service를 mockito로 테스트하기 (0) | 2020.02.12 |
[java] builder 패턴, 객체를 안전하게 생성하기 (0) | 2020.01.29 |
[react, springboot] react 와 spring boot 로 구성하기, 묶어 build 하기 (21) | 2020.01.19 |
[개발] 2019년 하반기 회고록 (0) | 2019.12.29 |
댓글