[SpringBoot] Security Config permitAll()과 jwt 인증

twonezero·2024년 3월 14일
0

Spring Security를 통한 회원 인증/인가 부분에서 생기는 문제점과 해결책
| SpringBoot3.x, Spring Security 6 이상

PermitAll() 쓰면 인증 안하는 거 아닌가요

React 클라이언트에서 회원가입/로그인을 진행 시, Security Config 에서 해당 request url 을 permitAll() 을 사용하면 되는 줄 알았습니다. 그러나 클라이언트는 인정받지 못했다는 슬픈 에러 내용을 계속 받고 있었습니다.

그리고, 스프링부트 서버에서는 계속 JwtAuthenticationFilter 에서 아래와 같은 에러를 뱉고 있었습니다.

2024-03-14T14:18:07.205+09:00 ERROR 14432 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

// jwt 를 받은 String 을 통해 만들 수 없다는 것 같네요
io.jsonwebtoken.MalformedJwtException: JWT strings must contain exactly 2 period characters. Found: 0
	at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:275) ~[jjwt-impl-0.11.5.jar:0.11.5]
	at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:529) ~[jjwt-impl-0.11.5.jar:0.11.5]
	at io.jsonwebtoken.impl.DefaultJwtParser.parseClaimsJws(DefaultJwtParser.java:589) ~[jjwt-impl-0.11.5.jar:0.11.5]
	at io.jsonwebtoken.impl.ImmutableJwtParser.parseClaimsJws(ImmutableJwtParser.java:173) ~[jjwt-impl-0.11.5.jar:0.11.5]
	at com.bodytok.healthdiary.service.jwt.JwtService.extractAllClaims(JwtService.java:93) ~[main/:na]
	at com.bodytok.healthdiary.service.jwt.JwtService.extractClaim(JwtService.java:45) ~[main/:na]
	at com.bodytok.healthdiary.service.jwt.JwtService.extractUsername(JwtService.java:37) ~[main/:na]
	at com.bodytok.healthdiary.filter.jwt.JwtAuthenticationFilter.doFilterInternal(JwtAuthenticationFilter.java:57) ~[main/:na]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.2.jar:6.1.2]

jwt 인증을 할 때, 클라이언트가 요청 헤더에 Authorization : Bearer "토큰 문자열" 으로 보내게 되는데, 문자열이 담겼지만 이상한 문제가 있는 것 같네요.

그런데 클라이언트는 어떻게 요청을 보내고 있나요

React 에서 axios 를 쓰지 않고 Redux-toolkitRTK-query 를 사용해 모든 요청에 인증을 담아 사용하고, 인증 만료가 된다면 refreshToken을 자동으로 발급받는 방식으로 구현하고 있었습니다.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

const baseQuery = fetchBaseQuery({
  baseUrl: 'http://localhost:8080',
  credentials: 'include',
  prepareHeaders: (headers, { getState }) => {
    const accessToken = getState().auth.token;
    if (accessToken) {
      headers.set('authorization', `Bearer ${accessToken}`);
    }
    return headers;
  },
});

const baseQueryWithReAuth = async (args, api) => {
  // 토큰에 대한 에러를 받았을 때
  // refresh 요청 url 로 refresh token 을 발급받는 로직
};

---생략

그러니까 accessToken 이 비었는데, 서버에 날아오는 요청에는 Bearer [Object] 어쩌고 이렇게 오는 것이었습니다.
클라이언트 코드의 로직을 바꿀 수도 있으나 백엔드에서 이러한 검증을 처리하는 게 맞죠.

이제 서버에서 뭐가 문제였는지 코드를 보며 한 번 확인해보죠.

Security Config

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter, AuthenticationProvider authenticationProvider, CustomAuthenticationEntryPoint authenticationEntryPoint) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationProvider = authenticationProvider;
        this.authenticationEntryPoint = authenticationEntryPoint;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        return http
                .cors(cors -> cors
                        .configurationSource(corsConfigurationSource())
                )
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers("api/**").permitAll() // data-rest
                                .requestMatchers("auth/login/**").permitAll()
                                .requestMatchers("auth/sign-up/**").permitAll()
                                .requestMatchers("auth/refresh-token/**").permitAll() //refresh-token 요청
                                .requestMatchers(HttpMethod.GET,"diaries/**").permitAll()
                                .requestMatchers(HttpMethod.GET, "community/**").permitAll() //커뮤니티 다이어리 가져오기
                                .anyRequest().authenticated()
                )
                //Authentication Entry Point -> Exception Handler
                .exceptionHandling(
                        config -> config.authenticationEntryPoint(authenticationEntryPoint)
                )
                // Set Session policy = STATELESS
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authenticationProvider(authenticationProvider)
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }


    @Bean
    public UrlBasedCorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); // 클라이언트가 쿠키를 전송할 수 있도록 허용
        config.addAllowedOrigin("http://localhost:3000"); // 특정 출처 허용
        config.addAllowedHeader("*"); // 모든 헤더 허용
        config.addAllowedMethod("*"); // 모든 HTTP 메서드 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); // 모든 경로에 대해 CORS 설정 적용

        return source;
    }
}

참고 : Spring Security 6.1 이상에서는 필터 체인을 사용하고 람다식을 지향하는 방식으로 바뀌었습니다. 기존의 줄줄이 소세지로 설정을 구현하는 것보다는 많이 가독성이 좋아진 것 같습니다.

코드는 어느 곳에나 널려있는 이전 코드를 버전에 맞게 람다식으로 바꿨을 뿐입니다.
jwt 기반 인증 방식을 사용해서, csrf 을 비활성화 하고 session 방식을 Stateless 로 설정합니다.

중요한 것은 로그인과 회원가입 요청 패턴에 permitAll() 을 지정해 놨다는 점입니다.

.requestMatchers("auth/login/**").permitAll()
.requestMatchers("auth/sign-up/**").permitAll()

사실 이 코드에는 별 다른 문제가 없습니다. 문제가 되는 부분은 아래 부분인데요.

.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

jwt 검증 filter 를 위와 같이 등록하면 모든 요청이 jwtAuthFilter를 거치게 됩니다.

그러면 에러를 계속 내뱉고 있던 JwtAuthenticationFilter 를 확인해 보면 되겠죠.

JwtAuthenticationFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    --- 필드 의존성 주입
	//

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

        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;


        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userEmail = jwtService.extractUsername(jwt);
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
            //
            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

간단하게 구현된 jwt 검증 필터입니다. authHeader 가 null 이거나 Bearer 로 시작하지 않으면 검증을 하지 않고 넘기고 있죠.
그런데 이미 클라이언트에서 Authorization 에 잘못된 값을 넘겨주고 있기에 이 부분에 걸리지 않았던 것입니다.

또한 이 점에 대한 예외를 제대로 컨트롤하지 않은 것이 더욱 문제였는데, 검증 과정에서 예외가 발생한다면 스프링 시큐리티의 ExceptionTranslationFilter 에서 직접 예외를 Throw 시킵니다.

그래서 어떻게 해결해요

해결책에는 여러가지가 있을 수 있습니다.

  • authHeader 로 넘어온 문자열을 파싱해서 이상한 object 가 담기지 않았는지 확인
  • try-catch 블록으로 error 를 붙잡아 다음 필터로 넘긴다.
    -> 예외를 클라이언트에게 던져버리지 않아야 함
  • AntPathRequestMatcher 배열에 요청 url 패턴을 지정해 검증하지 않기!

위 모든 해결책을 적용하는 것이 좋겠지만, 특정 url 에 대한 검증을 아예 피해가게 필터를 구현하는 것을 우선적으로 구현하였습니다. 이 로직이 통하는 지 try-catch 블록에 넣어 catch 에 잡히는 지도 확인해 보았습니다.(catch 에 들어오면 matcher 가 제대로 안된 것)

수정된 JwtAuthenticationFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

	--- 의존성 주입
    
    //특정 url 패턴과, method 가 등록된 Matcher 를 사용
    private final AntPathRequestMatcher[] permitAllMatchers = {
            new AntPathRequestMatcher("/auth/sign-up", HttpMethod.POST.name()),
            new AntPathRequestMatcher("/auth/login", HttpMethod.POST.name())
    };


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

        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;


        try{
            log.info("요청 정보\n->{}\n->{}",request.getMethod(),request.getRequestURI());
            //
            // jwt 인증이 필요없는 요청 검증 없이 넘기기
            //
            if(isPermitAllRequest(request)){ 
                log.info("인증 필요없는 요청 넘기기~!~!~!~!");
                filterChain.doFilter(request, response);
                return;
            }
            if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                filterChain.doFilter(request, response);
                return;
            }
            jwt = authHeader.substring(7);
            userEmail = jwtService.extractUsername(jwt);
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
                //
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        }catch(JwtException | UsernameNotFoundException exception){
            log.error("JwtAuthentication Authentication Exception Occurs! - {}",exception.getClass());
        }

        filterChain.doFilter(request, response);
    }

    private boolean isPermitAllRequest(HttpServletRequest request) {
        return Arrays.stream(permitAllMatchers)
                .anyMatch(matcher -> matcher.matches(request));
    }
}

AntPathRequestMatcher 이용해서 특정 요청 url 에 대한 패턴을 저장해놓고, isPermitAllRequest 함수를 구현해 matcher 배열안에 있는 모든 패턴을 request와 비교하는 지 확인하였습니다.

isPermitAllRequest 이 잘 동작한다면 "인증 필요없는 요청 넘기기~!~!~!~!" 라는 로그가 서버 터미널에 뜰 것이고, 아니라면 catch 블록에서 error 에 관한 log 가 남겠죠. 하지만 둘 중에 하나만 되어도 지금까지의 문제점을 해결할 수 있습니다.

결과

클라이언트 network 탭

스프링부트 서비스 콘솔

2024-03-14T15:09:46.778+09:00  INFO 16892 --- [nio-8080-exec-5] c.b.h.f.jwt.JwtAuthenticationFilter      : 요청 정보
->POST
->/auth/sign-up
2024-03-14T15:09:46.778+09:00  INFO 16892 --- [nio-8080-exec-5] c.b.h.f.jwt.JwtAuthenticationFilter      : 인증 필요없는 요청 넘기기~!~!~!~!
2024-03-14T15:09:46.789+09:00 DEBUG 16892 --- [nio-8080-exec-5] o.s.web.servlet.DispatcherServlet        : POST "/auth/sign-up", parameters={}

이로써, 성공적으로 인증이 필요없는 요청을 jwt 에 대한 검증을 거치지 않고, 클라이언트에게 답정너 401 을 보내지 않게 되었습니다.
만약 예상치 못한 문제로, 만든 함수가 동작하지 않아도 catch 블록에서 error 로그를 띄워주고 클라이언트에게는 throw 하지 않으니, 일단 문제는 해결된 것 같습니다.

✨결론✨
Spring Security 를 공부하자^^

profile
소소한 행복을 즐기는 백엔드 개발자입니다😉

0개의 댓글