[Spring Security] - SecurityConfig 클래스의 permitAll() 이 적용되지 않았던 이유

최동근·2023년 10월 15일
4
post-custom-banner

안녕하세요 이번 포스팅에서는 Better 팀의 Iter 프로젝트 에서 진행했던 Spring Security 을 이용한 회원 인증/인가 시스템에서 제가 겪었던 문제점과 새롭게 알게된 점을 주제로 작성하고자합니다 💁‍♂️

1. SecurityConfig 의 permitAll() 이 먹히지 않는 문제

제가 직면한 문제는 Spring Security시큐리티 기능을 활성화하고, 인증/인가에 대한 설정을 구성하는 설정 클래스Security Config 에서 인증 여부와 관계 없이 접근을 허용 하는 permitAll() 이 먹히지 않는 문제였습니다 😢
분명 지금까지 antMatchers(모든 접근을 허용한 URL).permitAll() 을 하면, 인증이 없어도 API 호출 및 자원 접근이 가능하다고 알고 있었는데 말이죠...

문제를 좀 더 알아보기 위해 클래스를 하나하나씩 분석해봅시다 ❗️
먼저 SecurityConfig 클래스 부터 분석해보겠습니다.

SecurityConfig.class

// Spring Security 기능을 활성화 하고 전반적인 구성을 위한 설정 클래스
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity(debug = true) // Spring Security 활성화
public class SecurityConfig {
    private final JwtService jwtService;
    private final JwtProperties jwtProperties;
    private final UserRepository userRepository;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().antMatchers("/user/**");
    }
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors()
                .and()
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests()
                .antMatchers("/user/test").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint())
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

	// Password Encryption
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

	// Jwt 유효성 검증을 위한 filter
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtService,jwtProperties,userRepository);
    }

	// AuthenticationException 발생 시 처리하는 클래스
    @Bean
    public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
        return new JwtAuthenticationEntryPoint();
    }
}

어느 블로그에서도 쉽게 찾아볼 수 있는 코드입니다.
Jwt(Json Web Token) 기반의 인증 방식을 택했기 때문에, csrf, formLogin 을 비활성화 하고 session 방식을 Stateless 로 설정합니다.
: [Server] 쿠키&세션&토큰 에 대해 알아봅시다.

또한 API 요청 Authorization Header 에 존재하는 Access Token 의 유효성을 검증하는 커스텀 필터인 JwtAuthenticationFilter 을 Bean 으로 등록하며, JwtAuthenticationFilter 에서 인증 관련 예외(AuthenticationException) 발생 시, 해당 예외를 처리하기 위해 ExceptionTranslationFilter 에 의해 호출되는 JwtAuthenticationEntryPoint 을 Bean 으로 등록합니다.
: [Spring Security] Spring Security Filter Chain 에 대해

여기서 가장 눈여겨보아야 할 부분은 permitAll() 부분입니다.

.authorizeRequests()
                .antMatchers("/user/test").permitAll()
                .anyRequest().authenticated()

제가 의도했던 부분은 User 관련 API 호출 테스트를 위해 /user/test API 호출시 JWT 검증 없이 정상적인 호출 결과를 확인하는 것이였습니다
하지만, 기대와는 달리, JWT 없이 /user/test 호출 시, 인증 예외 발생하는 경우 ExceptionTranslationFilter 에 의해 동작하는 AuthenticationEntryPoint 을 상속한 JwtAuthenticationEntryPoint 가 호출되었습니다 🤔
해당 문제에 대해서는 밑에서 JwtAuthenticationEntryPoint 코드를 살펴본 후, 더 자세히 알아보도록 하겠습니다.

두번째로 실제 요청 헤더에 포함되어 있는 Jwt 유효성 검증을 위한 커스텀 필터 JwtAuthenticationFilter 을 살펴보겠습니다 ❗️

JwtAuthenticationFilter.class

/**
 * 인증이 필요한 회원 API 요청 시, jwt 인증 용도의 필터
 * - 인증 마다 SecurityContext 생성 후 저장
 **/
@Getter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final JwtProperties jwtProperties;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (request.getMethod().equals("OPTIONS")) {
            filterChain.doFilter(request, response);
            return;
        }


        log.info("checkAccessTokenAndAuthentication() called!");
        String accessToken = this.checkAccessTokenAndAuthentication(request);

        if (accessToken == null) {
            throw new JwtAuthenticationException("jwt Authentication exception occurs!");
        }

        // 3. Access Token 을 파싱해서 User 정보 가져오기
        User user = this.userRepository.findByUserId(this.jwtService.getUserIdFromToken(accessToken))
                .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 존재하지 않습니다."));

        // 4. Thread Local 로 동작 하는 SecurityContext 에 저장
        UserAuthentication userAuthentication = new UserAuthentication(user);
        SecurityContextHolder.getContext().setAuthentication(userAuthentication);

        filterChain.doFilter(request, response);
    }

    // 요청 Authorization 헤더 에서 jwt 유효성 검증 후 리턴
    private String checkAccessTokenAndAuthentication(HttpServletRequest request) {
        String accessToken =  this.jwtService.extractAccessToken(request)
                .orElseThrow(() -> new JwtAuthenticationException("jwt Authentication exception occurs!"));
        if(!this.jwtService.validateToken(accessToken)) {
            throw new JwtAuthenticationException("jwt Authentication exception occurs!");
        }
        return accessToken;
    }
}

해당 프로젝트에서는 Spring Security 에서 디폴트로 설정되어 있는 세션 기반 인증 방식이 아니기 때문에 기본적으로 제공되는 UsernamePasswordAuthenticationFilter 을 사용하지 않으며, SecurityConfig 클래스에서 addFilterBefore() 메소드를 통해 UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 을 커스텀 필터로 등록해줍니다 ❗️
: Spring/Spring Security8. JWT를 사용하기 전 Filter 등록 테스트

// SecurityConfig 의 SecurityFilterChain 메소드 일부
...
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();

세번째로는 앞서 언급했던 JwtAuthenticationEntryPoint 을 살펴보겠습니다 ❗️

JwtAuthenticationEntryPoint.class

/** JwtAuthenticationFilter 예외 발생 시 **/
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
   @Override
   public void commence(HttpServletRequest request,
                        HttpServletResponse response,
                        AuthenticationException authException) throws IOException, ServletException {
       log.error("Authentication Exception Occurs!");
       this.sendErrorMessage(new BadCredentialsException("로그인이 필요합니다.(인증 실패)"),response);
   }

   private void sendErrorMessage(Exception authenticationException,
                                 HttpServletResponse response
   ) {
       response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
       response.setContentType("application/json");
       response.setCharacterEncoding("utf-8");

       try {
           OutputStream os = response.getOutputStream(); // 응답 body write
           JavaTimeModule javaTimeModule = new JavaTimeModule();
           LocalDateTimeSerializer localDateTimeSerializer = new LocalDateTimeSerializer(
                   DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Seoul")));

           javaTimeModule.addSerializer(localDateTimeSerializer); // 직렬화 방식 add
           ObjectMapper objectMapper = new ObjectMapper().registerModule(javaTimeModule); // LocalDateTime serialize
           objectMapper.writeValue(os, ErrorMessage.of(authenticationException, HttpStatus.UNAUTHORIZED));
       } catch (IOException e) {
           throw new RuntimeException(e);
       }
   }
}

JwtAuthenticationEntrypoint 는 필터 동작 중 예외를 감지하는 ExceptionTranslationFilter 에 의해 호출됩니다.

즉, 필터 체인 과정에서 인증 예외가 발생하면 ExceptionTranslationFilter 접근시, 해당 필터에 의해 JwtAuthenticationEntryPoint 가 호출되며, 일관적인 인증 예외 응답을 보냅니다.
: Spring Security의 ExceptionTranslationFilter와 AuthenticationEntryPoint
: Spring Security - ExceptionTranslationFilter

이 또한, Spring Security 의 전반적인 설정을 구성하는 SecurityConfig 에서 선제적으로 적용해야합니다.

// SecurityConfig 의 SecurityFilterChain 메소드 일부
...
       .exceptionHandling()
       .authenticationEntryPoint(jwtAuthenticationEntryPoint()) // jwtAuthenticationEntryPoint 등록
.and()

Postman 을 통한 API 호출

이렇게 코드를 구현하고 테스트를 위해 Postman 을 통해 /user/test 을 호출해보았습니다.

그러나 JwtAuthenticationFilter 에서 JWT 인증 실패 시, 호출되는 JwtAuthenticationEntrypoint 에 의해 동작되는 commence 메소드에 의해 401 에러가 나는 것을 확인하였습니다..🤔

2. PermitAll() 에 대한 나의 잘못된 이해

직면한 문제를 해결하기 위해서는 PermitAll() 의 역할과 기능에 대해 정확한 이해가 필요했습니다.

antMatcher(URL).permitAll() 은 해당 URL 에 대한 모든 사용자의 요청을 허용하는 메소드입니다.

제가 여기서 놓쳤던 부분은 permitAll() 을 적용해도, 구성된 Spring Security 의 필터 체인을 거친다는 점이였습니다.
즉, URI 에 permitAll() 처리를 해도 여타 API 와 마찬가지로 설정된 필터 체인을 모두 거치며 필터를 무시하지 못합니다❗️
PermitAll() 에 대한 저의 잘못된 이해가 해당 문제의 근본적인 원인이였습니다 😢

그렇다면 위에 정의된 것처럼 모든 사용자의 요청을 허용한다는 것은 무슨 뜻일까요?

여기서 말하는 모든 사용자의 요청을 허용한다는 것은 모든 필터 체인을 거친 후 인증 정보가 없어도 즉, Thread Local 로 동작하는 SecurityContextHolder 안에 존재하는 SecurityContextAuthentication 인증 객체가 존재하지 않거나 필터 동작 과정 중 예외가 발생해도 해당 API 호출이 정상적으로 가능하다는 뜻입니다.

만약 모든 필터 체인을 거쳤는데 인증 객체를 담는 SecurityContext 에 인증 객체가 존재하지 않으면, 해당 요청이 인증되지 않았음을 의미합니다.
그러나, 만약 해당 API 에 permitAll()을 적용한다면 SecurityContext 에 인증 객체가 존재 여부와 상관 없이 API 호출이 이루어집니다.

정리하자면, permitAll() 적용 시, 필터 체인 동작 과정에서 인증/인가 예외가 발생해도 ExceptionTranslationFilter 을 거치지 않으며, 인증 객체 존재 여부 상관 없이 정상적으로 API 호출이 이루어집니다.

3. 문제 해결 방법

자, 앞에서 살펴보았던 JwtAuthenticationFilter 을 다시 살펴볼까요?

// JwtAuthenticationFilter 일부분
if (accessToken == null) {
            throw new JwtAuthenticationException("jwt Authentication exception occurs!");
}

해당 부분은 HttpServletRequest 에서 Authrization 헤더에서 엑세스 토큰을 파싱해 오고 만약 엑세스 토큰이 null 이면 JwtAuthentication 예외 클래스를 직접 Exception 을 던지고 있습니다.

// Authentication 을 상속한 JwtAuthentication 예외 클래스
@Getter
public class JwtAuthenticationException extends AuthenticationException {
    public JwtAuthenticationException(String message) {
        super(message);
    }
}

문제는 바로 이부분 때문이였습니다 🤔
앞서, JWT 인증 절차를 거치지 않아야 했던 /user/test 는 당연히 Authorization 헤더 자체가 존재하지 않습니다.
따라서 위와 같이 JwtAuthenticationFilter 에서 직접 Exception 을 던지면 filterChain.doFilter(request,response) 호출이 되지 않고, permitAll() 적용 여부 상관 없이 필터 체인인 ExceptionTranslationFilter 로 처리가 바로 넘어가게 됩니다.

👨‍💻 [문제 원인] : 직접 throw 로 예외를 던져준것 -> 예외를 직접 던지지 말고 발생시키기만 하고 다음 필터 호출로 이어져야 함

하지만 /user/test 는 permitAll() 의 적용 대상이였기 때문에 아무리 인증이 안된 API 더라도 ExceptionTranslationFilter 을 거치면 안됩니다 🚫

결국 해결책은 JwtAuthenticationFilter 에서 직접 Exception 을 던지는 것이 아니라 예외를 catch 로 잡아주고 filterChain.doFilter(request,response) 을 통해 다음 필터로 처리를 '자연스레' 넘겨주면 됩니다.
그러면 JwtAuthenticationFilter 에서 발생한 예외가 catch 된 채로 다음 필터로 넘어 갈 것이고, 해당 API 가 permitAll() 의 대상인지 판별 후 ExceptionTranslationFilter 에서 처리할 것인지 말것인지 결정할 것입니다.

여기서 만약 permitAll() 의 대상이 아니면 ExceptionTranslationFilterJwtAuthenticationFilter 에서 발생한 예외를 감지해 해당 필터로 처리가 넘어갈 것이고, permitAll() 의 대상이면 API 호출을 위해 핸들러(컨트롤러) 로 정상적으로 사용자의 요청이 넘어갈 것입니다 😀
-> 필터 체인 과정에서 인증/인가 관련된 예외가 발생하기만 해도 ExceptionTranslationFilter 가 자동으로 감지 ❗️

/**
 * 인증이 필요한 회원 API 요청 시, jwt 인증 용도의 필터
 * - 인증 마다 SecurityContext 생성 후 저장
 **/
 // 수정된 filter
@Getter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final JwtProperties jwtProperties;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (request.getMethod().equals("OPTIONS")) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            log.info("checkAccessTokenAndAuthentication() called!");
            String accessToken = this.checkAccessTokenAndAuthentication(request);

            if(accessToken == null) {
                throw new JwtAuthenticationException("jwt Authentication exception occurs!");
            }

            // 3. Access Token 을 파싱해서 User 정보 가져오기
            User user = this.userRepository.findByUserId(this.jwtService.getUserIdFromToken(accessToken))
                    .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 존재하지 않습니다."));

            // 4. Thread Local 로 동작 하는 SecurityContext 에 저장
            UserAuthentication userAuthentication = new UserAuthentication(user);
            SecurityContextHolder.getContext().setAuthentication(userAuthentication);
        } catch (JwtAuthenticationException | UsernameNotFoundException exception) {
            log.error("JwtAuthentication Authentication Exception Occurs! - {}",exception.getClass());
        }
        filterChain.doFilter(request, response);
        // permitAll() 의 정상적인 처리를 원한다면, 다음 필터로 넘겨 추후 판단하여 ExceptionTranslationFilter 을 무시할지 아니면
        // permitAll() 이 적용되지 않은 API 여서 ExceptionTransliationFilter 을 거칠지 판단
        // 바로 throw 을 하면 ExceptionTranslationFilter 로 처리가 넘어간다.(permitAll 과 상관 없이)
    }

    // 요청 Authorization 헤더 에서 jwt 유효성 검증 후 리턴
    private String checkAccessTokenAndAuthentication(HttpServletRequest request) {

        // 1. HttpServletRequest 에서 Access Token 파싱
        Optional<String> token = this.jwtService.extractAccessToken(request);
        if (token.isEmpty() || !this.jwtService.validateToken(token.get())) {
            return null;
        }
        return token.get();
    }
}

해당 코드는 수정된 JwtAuthenticationFilter 입니다 ❗️
Jwt 을 이용한 인증 과정에서 발생한 Exception을 throw 하지 않고 catch 로 잡아두는 것을 볼 수 있습니다.
이로써, 예외가 발생해도 filterChain.doFilter(request,response) 로 다음 필터로 처리가 넘어갈 수 있습니다.
따라서, 필터 체인의 흐름과 permitAll() 적용 여부에 따라 API 의 호출 과정일 이루어집니다 👨‍💻


코드를 수정하고, 아까와 동일하게 /user/test 로 요청을 보냈고 인증여부 상관없이 정상적으로 API가 처리된 것을 확인할 수 있습니다 👏

4. 만약 특정 API 에 대해 필터 체인을 거치지 않게 하고 싶다면?

만약 Spring Security 의 필터 체인 자체을 생략해야 하는 API가 있다면 어떻게 해야 할까요?
Spring Security 설정 클래스에 사용자 정의 @WebSecurityCustomizer 을 Bean 으로 등록해주면 됩니다.
WebSecurity 설정을 통해 특정 리소스에 대해 Spring Security 적용을 생략할 수 있습니다.

// SecurityConfig 클래스 내부
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
       return web -> {
           web.ignoring()
               .antMatchers(
                   "/api-document/**",
                   "/swagger-ui/**"
                   );
       };
 }


WebSecurity 는 HttpSecurity 상위에 존재하며, WebSecurity 의 ignoring 에 API 을 등록하면, Spring Security 의 필터 체인이 적용되지 않습니다. 하지만 이경우, Cross-Site Scripting,XSS 공격 등에 취약해 집니다.

앞에서도 살펴보았듯이, HttpSecurity 에서 permitAll() 은 인증 처리 결과을 무시하는 것이지 Spring Security 의 필터 체인이 적용은 정상적으로 됩니다.
그래서 WebSecurity는 보안과 전혀 상관없는 로그인 페이지, 공개 페이지(어플리캐이션 소개 페이지 등), 어플리캐이션의 health 체크를 하기위한 API에 사용하고, 그 이외에는 HttpSecurity 의 permitAll() 을 사용하는 것이 좋습니다 ❗️

: jomminii_before - Spring Security 설정
: Framework/SpringBoot[스프링 시큐리티] HttpSecurity vs WebSecurity
: SpringBoot : Security Configuration using HTTPSecurity vs WebSecurity

But ❗️❗️
@Component 등으로 @Bean 으로 등록된 커스텀 필터라면 @WebSecurityCustomizer 의 web.ignoring() 설정을 잡으면 일반적인 Spring Security 의 필터 체인 처럼 해당 필터를 거치지 않을까요?

만약 커스텀 필터(ex. JwtAuthenticationFilter) 을 @Bean 으로 등록했다면
@WebSecurityCustomizer 의 web.ignoring() 이 적용되지 않기에 shouldNotFilter 을 적용해줘야 합니다.

// 필터 타면 안되는 경우 - true, 필터 타야 하는 경우 - false 반환
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
	return StringUtils.startsWithAny(request.getRequestURI(), "/필터 타면 안되는 엔드 포인트");

: [Filter] 빈으로 등록한 Custom Filter 사용 주의

5. 회고

이번 문제에 대해 제가 고생한 근본적인 이유는 permitAll() 이 적용되었을 때 인증 처리 과정에 대한 이해가 부족해서였습니다.
Spring Security 는 서버의 인증/인가 시스템 구축을 위한 최고의 선택이 될 수 있지만, 개념이 방대하고 어렵기 때문에 충분한 이해가 선제적으로 필요한 것 같습니다.
시간이 날때마다 Spring Security 을 더 공부해야겠습니다 🤔
이번 포스팅은 Iter 프로젝트에서 Spring Security 을 이용해서 인증/인가 시스템을 구축하는 과정에서 직면한 문제의 원인을 알아보고 해결해보는 시간을 가졌습니다. 감사합니다 🙏


참고

Spring Security - permitAll() Filter 호출 에러
Stack Overflow - Spring Security with filters permitAll not working
[SpringSecurity] JwtAuthenticationFilter 구현
[Spring Security] 스프링시큐리티 설정값들의 역할과 설정방법(2)
[스프링시큐리티] Spring Security 5.7 (WebSecurityConfigurerAdapter 에러해결방법)

profile
비즈니스가치를추구하는개발자
post-custom-banner

3개의 댓글

comment-user-thumbnail
2024년 4월 22일

좋은글 감사합니다

답글 달기
comment-user-thumbnail
2024년 6월 3일

3주동안 해결 못한 문제를 이 블로그를 통해 해결했습니다. 감사합니다 ㅠㅜ

답글 달기
comment-user-thumbnail
2024년 6월 24일

좋은 글 감사합니다.

should not filter를 사용하려면 OncePerReuqestFilter 를 적용해야 할테고
GenericFilter 등의 Filter로는 해당 메소드가 없어서 적용되지 않을 겁니다. =)

authorizeRequests 전에 securityMatcher 를 거시면 security 를 적용할 path 만 넣어서 하실 수 있을거에요.
예제에서는 특정 uri만 빼고 적용하시려는 것이니깐 (해보진 않았으나) NegatedRequestMatcher 를 설정해서 넣으시면 되지 않을까 싶습니다.

이 경우엔 굳이 web.ignoring() 을 걸지 않아도 될 거에요.

답글 달기