Spring Security를 통한 회원 인증/인가 부분에서 생기는 문제점과 해결책
| SpringBoot3.x, Spring Security 6 이상
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-toolkit 의RTK-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] 어쩌고 이렇게 오는 것이었습니다.
클라이언트 코드의 로직을 바꿀 수도 있으나 백엔드에서 이러한 검증을 처리하는 게 맞죠.
이제 서버에서 뭐가 문제였는지 코드를 보며 한 번 확인해보죠.
@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
를 확인해 보면 되겠죠.
@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 시킵니다.
해결책에는 여러가지가 있을 수 있습니다.
문자열을 파싱
해서 이상한 object 가 담기지 않았는지 확인try-catch
블록으로 error 를 붙잡아 다음 필터로 넘긴다.AntPathRequestMatcher
배열에 요청 url 패턴을 지정해 검증하지 않기!위 모든 해결책을 적용하는 것이 좋겠지만, 특정 url 에 대한 검증을 아예 피해가게 필터를 구현하는 것을 우선적으로 구현하였습니다. 이 로직이 통하는 지 try-catch
블록에 넣어 catch 에 잡히는 지도 확인해 보았습니다.(catch 에 들어오면 matcher
가 제대로 안된 것)
@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 가 남겠죠. 하지만 둘 중에 하나만 되어도 지금까지의 문제점을 해결할 수 있습니다.
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 를 공부하자^^