Spring/Spring Boot

[Spring Security] 존재하지 않는 API 호출 시 404 대신 401 or 403 을 반환할 때

민돌v 2023. 7. 15. 18:36

 

개발환경은 Spring Boot 3.0.x + Spring Security 6.x 입니다

 

[목차]

  1. 문제 상황
  2. Spring Security 에서 404를 반환하지 않는 이유
  3. 문제 해결

 

[결론 요약]

  1. error page 설정
  2. FORWARD_REQUEST_URI - error 처리 경로 ("/error") spring seurity 에 등록
  3. 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 서버의 입장에서만 보면

 

[간단한 성공 요청 흐름]

  1. 내 IP 와 Port 로 특정 URL 처리 요청이 옴
  2. Filter 에서 정의된 처리들로 먼저 요청에대해 검사함 (Spring Security 라면 인증,인가)
  3. Filter에서 통과되면 Dispathcer Servlet 이 URL 에 맞는 Handler 를 찾음 (@Controller + @~Mapping 주석이 달린 URL 은 각각의 핸들러가 존재함)
  4. Handler 가 요청을 처리한 후 HTTP 응답
  5. 다시 필터 거친 후 HTTP 응답
    (Spring Boot 의 코드를 까보니까 Filter 가 체이닝메소드 방식으로 구현된 후  마지막 필터에서 →  Servelet 호출→ 필터 역순으로 메소드 종료

💡 이걸 제가 만든 JWT Filter 가 적용된 Spring Security 를 적용했을 때 가정해서 정리해보면

  1. 내 IP 와 Port 로 특정 URL 처리 요청이 옴
  2. Filter 에서 정의된 처리들로 먼저 요청에대해 검사함 
    1. Spring Security 의 인증이 필요한 URL 인지 검사함 
    2. 인증이 필요한 url 이면 → 앞단 기본적인 필터들을 통과한 후 JWT Filter 를 호출 함
    3. jwt filter가 인증절차를 마치면 Spring SecurityContextHolder 에 유저 정보를 저장함
    4. 필터의 가장 마지막단인 AuthentificationFilter 에서 SecurityContextHolder 의 정보를 호출하여 해당 요청에 인가 과정을 거침
  3. Filter에서 통과되면 Dispathcer Servlet 이 URL 에 맞는 Handler 를 찾음 (@Controller + @~Mapping 주석이 달린 URL 은 각각의 핸들러가 존재함)
  4. Handler 가 요청을 처리한 후 HTTP 응답
  5. 다시 필터 거친 후 HTTP 응답
    (Spring Boot 의 코드를 까보니까 Filter 가 체이닝메소드 방식으로 구현된 후  마지막 필터에서 →  Servelet 호출→ 필터 역순으로 메소드 종료

Filter 들

 

✔️ 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 재설정을 위한 요청 시 인증 절차를 거치지 않기 때문에 인가에 대한 권한 문제가 생기는 것 입니다.

 

✔️ 문제 원인을 생각해 보았을 떄 여러가지 해결 방법이 있겠네요

  1. error page 설정
  2. FORWARD_REQUEST_URI - error 처리 경로 ("/error") spring seurity 에 등록
  3. 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 시리즈======================