[Spring Security] 존재하지 않는 API 호출 시 404 대신 401 or 403 을 반환할 때
개발환경은 Spring Boot 3.0.x + Spring Security 6.x 입니다
[목차]
- 문제 상황
- Spring Security 에서 404를 반환하지 않는 이유
- 문제 해결
[결론 요약]
- error page 설정
- FORWARD_REQUEST_URI - error 처리 경로 ("/error") spring seurity 에 등록
- AuthenticationEntryPoint 혹은 AccessDeniedHandler 에서 핸들링
1. 🤣 문제상황
Spring Security 를 적용하니까 이게 6 버전이라 그런건지,, 설정을 놓친건지 클라이언트에서 존재하지 않는 리소스 endpoint 를 호출할 때 404 NOT FOND 를 반환하지 401 UnAuthorization 코드를 반환하는 문제가 있었습니다
이렇게되면 클라이언트 쪽에서 큰 오해가 발생할 수 있기 때문에 올바른 의도를 가진 HTTP상태코드와 에러메시지를 반환해주고 싶었습니다.
그래서 Filter ErrorHandling 도 건드려봤는데 희안하게 401 을 계속 반환하더군요.
404 대신 403을 반환하는 케이스도 존재하는데 그건 아마 Spring Security 설정에 HttpBasic 을 해주었냐 안해주었냐의 차이일겁니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable();
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtFilter.class)
)
.httpBasic(Customizer.withDefaults())
.build();
}
2. Spring Security 에서 404를 반환하지 않는 이유
👏🏻 일단 제가 이해하기로는 (틀릴수도 있습니다.) HTTP 요청이 URL 로 들어와서 처리되기 까지의 과정을 Spring 서버의 입장에서만 보면
[간단한 성공 요청 흐름]
- 내 IP 와 Port 로 특정 URL 처리 요청이 옴
- Filter 에서 정의된 처리들로 먼저 요청에대해 검사함 (Spring Security 라면 인증,인가)
- Filter에서 통과되면 Dispathcer Servlet 이 URL 에 맞는 Handler 를 찾음 (@Controller + @~Mapping 주석이 달린 URL 은 각각의 핸들러가 존재함)
- Handler 가 요청을 처리한 후 HTTP 응답
- 다시 필터 거친 후 HTTP 응답
(Spring Boot 의 코드를 까보니까 Filter 가 체이닝메소드 방식으로 구현된 후 마지막 필터에서 → Servelet 호출→ 필터 역순으로 메소드 종료)
💡 이걸 제가 만든 JWT Filter 가 적용된 Spring Security 를 적용했을 때 가정해서 정리해보면
- 내 IP 와 Port 로 특정 URL 처리 요청이 옴
- Filter 에서 정의된 처리들로 먼저 요청에대해 검사함
- Spring Security 의 인증이 필요한 URL 인지 검사함
- 인증이 필요한 url 이면 → 앞단 기본적인 필터들을 통과한 후 JWT Filter 를 호출 함
- jwt filter가 인증절차를 마치면 Spring SecurityContextHolder 에 유저 정보를 저장함
- 필터의 가장 마지막단인 AuthentificationFilter 에서 SecurityContextHolder 의 정보를 호출하여 해당 요청에 인가 과정을 거침
- Filter에서 통과되면 Dispathcer Servlet 이 URL 에 맞는 Handler 를 찾음 (@Controller + @~Mapping 주석이 달린 URL 은 각각의 핸들러가 존재함)
- Handler 가 요청을 처리한 후 HTTP 응답
- 다시 필터 거친 후 HTTP 응답
(Spring Boot 의 코드를 까보니까 Filter 가 체이닝메소드 방식으로 구현된 후 마지막 필터에서 → Servelet 호출→ 필터 역순으로 메소드 종료)
✔️ exception 처리
만약 이런 filter 단에서 exception 이 발생하면 인증-인가에 관련된 에러라면 ExceptionTranslationFilter 로 넘어가
AuthenticationEntryPoint 혹은 AccessDeniedHandler 에 위임하여 에러를 처리합니다.
그 외의 에러처리는 직접 fitler 단에 exception을 핸들링 할 필터를 새롭게 만들어주어야 합니다 → Filter 단 예외 처리하기
✔️ 자 그럼 404 Not Found 가 왜 안뜨냐
1) 잘못된 url 요청이 들어와도 올바른 jwt 가 HTTP 프로토콜에 담겨있다면 filter 에서는 신경을 쓰지 않습니다.
2) filter 처리가 끝난 후 Dispathcer 서블릿에서 적절한 핸들러를 찾지 못했기 때문에 response 에 에러 정보와 404 status 값을 담아 서블릿에서의 처리를 끝냅니다. (여기에는 not found 에 대한 인터셉터 핸들러 작업또한 포함되어 있게죠)
3) Dispatcher Servlet 요청이 끝난후 체이닝 메소드로 구성된 filter 단들의 처리가 종료되고 HTTP 응답 프로토콜을 세팅해주어야합니다.
→ 여기서 랜더링 해줄 에러페이지가 있다면 세팅해줍니다.
→ 저 status 메소드에서 HTTP 상태코드 및 해당 메세지를 처리합니다.
근데 spring security 를 적용했고, 별도의 error page를 지정해 주지 않아 attribute 에 에러다! 정도의 정보를 담는거 같습니다.
→ 페이지 랜더링에 대한 체크를 위한 메소드에서 response 의 setCommitted(false)를 통해 isCommited() 가 false 값으로 설정되었기 때문에 👏🏻응답 메세지를 재설정해줍니다.
→ 여기서 응답 메세지 재설정을 위해 다시 ApplicationDispatcher 을 호출하여 필터를 거쳐 FORWARD_REQUEST_URI 로 요청을 보냅니다.
👏🏻 결과적으로는 필터를 다시 호출하게 되는데 이때는 Request 객체에 에러 url 속성이 존재하기 때문에 jwt filter 가 호출되지 않고 skip 됩니다.
(🔥 이게 결론 🔥)
→ 그렇기 때문에 마지막 필터단인 AuthorizaitonFilter 에서 SpringContext 에 인증된 사용자 정보가 없기때문에 AccessDeniedException 을 던져버립니다...!!!!!!!!!!!!!
3. 문제 해결 방법 ✨
😂 자 원인을 파악하기까지 굉장히 힘들었는데 결론은 SpringSecurity 가 error page 재설정을 위한 요청 시 인증 절차를 거치지 않기 때문에 인가에 대한 권한 문제가 생기는 것 입니다.
✔️ 문제 원인을 생각해 보았을 떄 여러가지 해결 방법이 있겠네요
- error page 설정
- FORWARD_REQUEST_URI - error 처리 경로 ("/error") spring seurity 에 등록
- AuthenticationEntryPoint 혹은 AccessDeniedHandler 에서 핸들링
👏🏻 제가 추천하는 방법은 가장 간단한 2번 방법입니다 !
- 가장 간단하게 해결할 수 있기도하고, 디스패치 서블릿에서 핸들링하는 에러 메세지를 그대로 전달할 수 있다는 점에서 매력적인 방법이라고 생각합니다 :)
👏🏻 3번 방법도 한번 시도해봐서 내용을 남겨놓겠습니다.
- authenticationEntryPoint() 와 accessDeniedHandler 메소드를 이용해 default class 대신 적용할 class 들을 주입해주어 핸들링 할 수 있습니다.
SecurityConfiguration.class
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtFilter jwtFilter;
private final ExceptionHandlerFilter exceptionHandlerFilter;
private final HttpRequestEndpointChecker endpointChecker;
private static final String[] PERMIT_URL_ARRAY = {
// "/error" - 2번 방법은 이거 하나만 추가해주면 됩니당
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.httpBasic().disable()
.exceptionHandling()
.authenticationEntryPoint(new MyAuthenticationEntryPoint(endpointChecker))
.accessDeniedHandler(new MyAccessDeniedHandler(endpointChecker));
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.requestMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtFilter.class)
)
.build();
}
}
MyAuthenticationEntryPoint.class && MyAccessDeniedHandler.class
@RequiredArgsConstructor
public class MyAuthenticationEntryPoint extends Http403ForbiddenEntryPoint {
private final HttpRequestEndpointChecker endpointChecker;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
if (!endpointChecker.isEndpointExist(request)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found");
} else {
super.commence(request, response, authException);
}
}
}
@RequiredArgsConstructor
public class MyAccessDeniedHandler extends AccessDeniedHandlerImpl {
private HttpRequestEndpointChecker endpointChecker;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
if (!endpointChecker.isEndpointExist(request)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found");
} else {
super.handle(request, response, accessDeniedException);
}
}
}
HttpRequestEndpointChecker.class
- handler 가 존재하는지 안하는지 판단해주기 위한 클래스 입니다.
@Component
@RequiredArgsConstructor
public class HttpRequestEndpointChecker {
private final DispatcherServlet servlet;
public boolean isEndpointExist(HttpServletRequest request) {
for (HandlerMapping handlerMapping : servlet.getHandlerMappings()) {
try {
HandlerExecutionChain foundHandler = handlerMapping.getHandler(request);
if (foundHandler != null) {
return true;
}
} catch (Exception e) {
return false;
}
}
return false;
}
}
원하던 문제를 해결해서 좋긴한데,, 굉장히 간단한 방법이 존재했고 처음 Spring Security를 설정할 때 꼼꼼하게 고려해가며 설정할걸 후회도 되네요 ㅠㅠ
문제를 파악하고 삽질하는데 굉장히 오래걸렸지만 API 요청이 필터를 거쳐 디스패쳐 서블릿 까지 어떤 흐름으로 이동하는지 어느정도 흐름을 파악해 볼 수 있었던거같아 좋은 경험이었다고 생각합니다
그럼 오늘도 아디오스!
[ Spring Security ]
404 NOT_FOUND 반환하기
(Spring Filter 요청 처리 흐름)
====================== 🫡 Spring Security 시리즈======================