[오늘의 삽질] Custom Filter and PermitAll vs. ignoring (WebSecurity vs. HttpSecurity)

byeol·2023년 5월 31일
0
post-thumbnail

프로젝트 마감일이 얼마남지 않은 시점에서 Custom Filter가 이상하다는 것을 발견했습니다.

일단 코드 자체에 문제가 많은데
마감일 이틀전이라 수정할 수 있는 부분만 수정해보자고 시작하게 되었습니다.

수정해보고자 한 부분은 아래와 같습니다.

Custom filter가 의도와 다르게 작동한다는 것
사실상 access token이 만료되었는지 여부, 필요한 경우인데 주지 않는 경우 등을 판단하기 위해서 만들었는데 그런 의도와 벗어나게 작동하도록 구현했다는 것입니다.
(그러나 위 의도 또한 잘못된 의도? 였습니다...)

일단 저는 OncePerRequestFilter을 상속받은 JwtAuthenticationFilter를 만들었습니다.
이 필터는 아래와 같이 UsernamePasswordAuthentication 전에 동작합니다.

.addFilterBefore(jwtAuthFilter,UsernamePasswordAuthenticationFilter.class)

JwtAuthenticationFilter의 로직은 아래와 같습니다.

  • path가 auth로 시작하는가?
    • 시작 YES : filterChain.doFilter(request, response);
    • 시작 NO
      • header의 Authorization이 null이 아니면서 Bearer로 시작하는가?
        • YES
          • Jwt토큰이 만료기간이 지나지 않았고 유요한 값인가?
            • filterChain.doFilter(request, response);
        • NO
          • filterChain.doFilter(request, response);

위 필터는 만료기간이 지났을 때도 200을 return 하는 것의 문제도 있지만
필터가 왜 필요한지의 필요성이 없어보입니다.
모든 access token이 null인 경우에 403 Http 상태 코드를 반환한다는 것 외에는 아무런 역할을 하지 않기 때문입니다.

그래서 코드를 수정해보고자 했습니다.
삽질은 단계적으로 진행됩니다.

처음 시도

개선한 점은 유효하지 않은 토큰에 대해 403 Forbidden Error를 반환(이것도 잘못된 상태코드입니다.)하도록 Custom Filter를 수정하고 access token이 필요없는 api들을 모두 ignoring()에 넣었습니다.

그러나 Cors Error가 발생합니다.

SecurityConfig

원인을 생각해다가 문득 ignoring()과 permitAll()은 같은 역할을 하는데 도대체 어떤 기능의 차이가 있을까에 대한 물음이 생겨 찾아보게 되었습니다.

🐥 WebSecurity vs. HttpSecurity

  • WebSecurity
    • 보안이 전혀 상관없는 로그인, 공개페이지, 정적 리소스
    • ignoring()에 endpoint는 security filter chain이 적용되지 않는다.
    • CSS, XSS, content-sniffing 공격에 취약
  • HttpSecurity
    • permitAll 인증처리 결과를 무시
    • security filter chain 적용
    • CSS, XSS, content-sniffing 공격을 막을 수 있다.

저는 access token이 필요없는 api를 모두 ignoring()에 넣어두었습니다.
이게 cors 에러가 발생한 원인이었습니다.
왜냐하면 저는 Cors Error가 발생하는 것을 방지하기 위해 CorsConfigurationSource 인터페이스를 구현한 객체를 반환하여 CORS 구성하였습니다. 그리고 이를 Security Filter Chain에 포함시켰습니다.
따라서 저희가 발생한 Cors error는 저희가 만든 Cors error를 막을 수 있는 Custom Filter를 거치지 않도록 ignoring()에 선언했던 것이 문제였습니다.

Custom Filter

필터를 아래와 같은 로직으로 바꿨습니다.

  • path가 auth로 시작하는가?
    • YES : filterChain.doFilter(request, response);

    • NO

      • header의 Authorization이 null이거나 Bearer로 시작하지 않는다면?
        • 403 Http 상태코드 반환 ->filterChain.doFilter(request, response) -> 끝
      • Jwt가 유효한 토큰인가?
        • YES

          위와 같은 과정을 거칩니다.
        • NO
          • 403 Http 상태코드를 Response에 저장
        • YES 또는 NO를 거친 후에 filterChain.doFilter(request, response);

두 번째 시도

위 문제를 개선해서
permitAll에 access token이 필요없는 api들을 넣었습니다.
api가 너무 많았기 때문에 이를 class로 하나 뺐습니다.
그러나 access token이 필요없는 api에서 403 Forbidden Error가 발생하였습니다.

원인은 Custom Filter의 위치를 제가 완전히 잘못 생각하고 있었던 것입니다.

Custom Filter

저는 Custom Filter는 Spring Security Filter Chain에 들어갈 수 없다고 생각했습니다.

제가 아래와 같이 UsernamePasswordAuthenticationFilter.class 전에 Custom Filter가 작동되도록 구성했습니다.

.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

그러나 위의 구성이 다음과 같은 순서인 줄 알았습니다.

jwtAuthFilter -> Spring Security Filter Chain -> ...->필터N -> Controller 

하지만 데브코스 동료 개발자를 통해서 위 순서가 아님을 깨닫게 되었습니다.
실제 순서는 아래와 같습니다.

요청 --> 필터 체인 시작 --> 필터 1 --> customFilter --> UsernamePasswordAuthenticationFilter --> 필터 2 --> ... --> 필터 N --> 컨트롤러

그래서 제가 만든 Custom Filter는 인증 단계 필터에 들어가게 됩니다.

즉 제가 기존에 구현한 JWT 인증을 담당하는 Custom Filter는 Security Filter Chain 전에 작동하는 것이 아니라 Security Filter Chain 안에 들어가 UsernamePasswordAuthenticationFilter 전에 작동하는 것입니다. 따라서 기존에 저는 Custom Filter에 access token이 필요없는 api인가에 대해서 검사하는 조건문을 넣었지만 이는 필요없는 것이었습니다. 이 필터를 거치지만 발생한 예외에 대해서 이미 permitAll을 통해 무시한다고 선언했기 때문입니다.
따라서 기존에는 access token이 필요없는 api인가 검사하고 필요없다면 doFilterChain을 통해서 Security Filter Chain으로 진행하도록 넘기게 되었는데 이게 다시 인증을 단계를 거치도록 하기 때문에 여기서 충돌이 발생한 것으로 보였습니다.

그래서 코드를 아래와 같이 수정했고 access token이 필요없는 api에서 403 Forbidden Error가 발생하지 않게 되었습니다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthTokenProvider tokenProvider;



    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain)  throws ServletException, IOException {


        final String authorizationHeader = request.getHeader("Authorization");

        if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")){
            filterChain.doFilter(request, response);
            return;
        }

        String tokenStr = JwtHeaderUtil.getAccessToken(request);
        AuthToken token = tokenProvider.convertAuthToken(tokenStr);
        // 유효한 토큰인지 확인
        if (token.validate()) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            // 유효한 토큰이 아니라면
        }else{
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
        filterChain.doFilter(request, response);
    }


}

SecurityConfig

package com.everyonegarden.auth.config;

public class Constants {
    /**
     * 권한제외 대상
     * @see SecurityConfig
     */
    public static final String[] permitAllArray = new String[] {
            "/",
            "/auth/**",
            "/v1/weather/**",
            "/v1/crop/**",
            "/v1/garden/all/by-region/**",
            "/v1/garden/public/by-region/**",
            "/v1/garden/private/by-region/**",
            "/v1/garden/all/by-coordinate/**",
            "/v1/garden/public/by-coordinate/**",
            "/v1/garden/private/by-coordinate/**",
            "/v1/garden/recent/**",
            "/v1/garden/**"
    };
}

권한제외 대상이 많았기 때문에 하나의 클래스로 분리하였습니다.

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AuthTokenProvider authTokenProvider;

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().
                antMatchers(
                        "/v2/api-docs",
                        "/configuration/**",
                        "/swagger*/**",
                        "/webjars/**")
                
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter(authTokenProvider);

        http
                .authorizeRequests()
                .antMatchers(HttpMethod.GET,"/v1/garden/{gardenID:[\\d+]}").permitAll()
                .antMatchers(Constants.permitAllArray).permitAll()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .anyRequest().authenticated()

                .and()

                .headers()
                .frameOptions()
                .sameOrigin()

                .and()

                .cors()
                .configurationSource(corsConfigurationSource())
                .and()

                .csrf().disable()

                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()

                .oauth2Login()

                .and()

                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    }
     @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        corsConfiguration.addAllowedOrigin("http://localhost:3000");
        corsConfiguration.setMaxAge(600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);

        return source;
    }

}

이제 permitAll()에 포함된 api들은 Spring Security Filter chain을 거치지만 예외가 발생해도 무시됩니다.

정리

  • WebSecurity는 Spring Security Filter Chain을 거치지 않는다.
  • permitAll()에 선언된 api들은 Spring Security Filter Chain를 거치지만
    인증 단계에서 발생한 예외는 무시된다.
  • Custom Filter는 Spring Security Filter Chain에 들어갈 수 있고 어떤 필터 앞뒤로 실행되냐에 따라 인증, 인가의 역할이 결정될 수 있고
  • 내가 구성한 필터의 역할들과 충돌이 발생하지 않도록 생각해서 만들어야 한다.
profile
꾸준하게 Ready, Set, Go!

0개의 댓글