안녕하세요 오늘은 Spring Boot에서 JWT를 커스텀하여 원하는 정보를 내부에 추가하는 작업에 대해 포스팅하려고 합니다.
우선 현재 인증/인가 프로세스에 대해 살펴보면
요청이 디스패쳐 서블릿(DispatcherServlet)에 들어오기 전에 인증/인가 작업을 진행해야 하기 때문에 통신 시 필터를 추가하여 보안 관련 설정을 추가합니다. 여기서 주의할 점은 @Configuration 어노테이션을 추가한 이후 @Bean을 사용해야 한다는 점입니다. 없어도 가능하지만 싱글톤 패턴을 보장받을 수 없어 불필요한 여러 빈이 생성될 수 있습니다. 하지만 @Configuration 어노테이션은 프록시 패턴이 적용되어 기존과 동일한 객체를 반환합니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 토큰을 사용하기 때문에 csrf 설정 disable
.csrf(AbstractHttpConfigurer::disable)
// 예외 처리 시 직접 만들었던 클래스 추가
.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
// 세션 사용하지 않기 때문에 세션 설정 STATELESS
.sessionManagement((sessionManagement) -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 토큰이 없는 상태에서 요청이 들어오는 API들은 permitAll
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
// JwtFilter를 addFilterBefore로 등록했던 jwtSecurityConfig 클래스 적용
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
기존 커스텀 필터인 JwtFilter는 GenericFilterBean을 상속받았습니다. GenericFilterBean의 경우 일반 Filter 클래스에서 설정 정보까지 가져올 수 있는 확장된 클래스입니다. 하지만 두 클래스 모두 매 서블릿마다 호출된다는 특징이 있는데, Spring Security의 인증 과정에서 RequestDispatcher 클래스로 인해 다른 서블릿으로 dispatch되어 이 과정에서 필터가 중복되어 적용된다는 단점이 있습니다.
따라서 이 부분을 해결한 클래스가 OncePerRequestFilter로, 사용자의 요청 당 한 번만 실행되어 중복 실행을 방지할 수 있습니다. 내부 코드를 확인해보면 OncePerRequestFilter 클래스의 경우 GenericFilterBean을 상속하여 요청을 스킵해야 하는 경우 또는 필터링하면 안되는 경우, 그리고 이미 같은 필터로 필터링이 된 요청을 걸러내어 그대로 진행시키고, 그 외의 경우 setAttribute를 이용하여 attribute를 설정하고 필터 로직을 실행한 뒤 해당 attribute를 제거하여 다음 요청 때 다시 필터링할 수 있도록 합니다.
@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!((request instanceof HttpServletRequest httpRequest) && (response instanceof HttpServletResponse httpResponse))) {
throw new ServletException("OncePerRequestFilter only supports HTTP requests");
}
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
else if (hasAlreadyFilteredAttribute) {
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
else {
// Do invoke this filter...
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
doFilterInternal(httpRequest, httpResponse, filterChain);
}
finally {
// Remove the "already filtered" request attribute for this request.
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
인증 정보의 경우 Authentication 객체로 생성되며 사용자 정보인 principal, 비밀번호인 credentials 등으로 구성됩니다. 현재 서비스에 사용되는 토큰은 JWT로 데이터가 담겨 있는 payload에는 서비스를 사용할 때 필요한 유저 정보와 인증 정보가 들어가있습니다. 유저 정보는 Claims 클래스를 이용하여 JWT의 페이로드에 추가 및 조회할 수 있습니다. 이 때 토큰의 인증 정보를 가져와 유효한 인증 정보일 경우 이메일을 principal로, 입력받은 토큰을 credentials로 하는 Authentication 객체를 반환하여 SecurityContextHolder의 SecurityContext 내에 저장합니다.
public Authentication getAuthentication(String token){
// 토큰을 이용하여 claim 생성
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
// 인증 정보가 존재하지 않을 경우 에러 리턴
// claim을 이용하여 authorities 생성
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// claim과 authorities 이용하여 User 객체 생성
User principal = new User(claims.getSubject(), "", authorities);
// 최종적으로 Authentication 객체 리턴
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
Spring Security에서 인증 정보는 Authentication - SecurityContext - SecurityContextHolder의 순서로 저장됩니다. SecurityContext의 경우 Authentication 객체가 보관되는 저장소 역할을 하며, SecurityContextHolder는 SecurityContext 객체를 보관하고 있는 클래스입니다. 이 때 SecurityContextHolder의 initializeStrategy() 메소드에서 스레드 전략을 설정할 수 있습니다. 기본적으로 MODE_THREADLOCAL, 즉 스레드 로컬 방식으로 하나의 스레드에서 같은 SecurityContext 객체에 접근 가능하도록 되어 있습니다.
Spring Boot에서 요청을 처리하는 로직은 다음과 같습니다.
1) 클라이언트에서 요청한 URI가 서블릿 컨테이너로 전송
2) 서블릿 컨테이너에서 HttpServletRequest, HttpServletResponse 객체를 생성
3) 요청한 URI가 어느 서블릿에 대한 요청인지 탐색.
--> 요청에 맞는 서블릿이 한 번도 실행된 적이 없거나 메모리에 인스턴스가 존재하지 않을 경우 서블릿 인스턴스 생성 후 요청 처리를 위한 스레드 추가 생성
--> 메모리에 요청에 맞는 서블릿 인스턴스가 존재할 경우 서블릿 인스턴스 생성하지 않고 요청 처리를 위한 스레드 추가 생성
4) 서블릿에서 service메소드를 호출한 후 클리아언트 요청에 따라 doGet() 또는 doPost() 호출
5) HttpServletResponse 객체로 처리 결과 전달
6) HttpServletResponse 클라이언트로 전송
즉 하나의 요청을 처리 시 보통 하나의 스레드가 사용되므로 스레드 로컬 방식을 사용하게 되면 요청 과정 중 인증 정보를 스레드 어디서든 참조할 수 있다는 장점이 있습니다.