
GIF 출처 : https://sigridjin.medium.com/spring-transaction-관리에-대한-메모-f391fd2885b4
1 ) 배경
2 ) 문제 분석
3 ) 해결 과정
4 ) 결과 및 후기
User 도메인 작업을 마친 뒤 테스트 하는 과정에서 모든 URI에 대하여 LogoutFilter가 적용되는 문제를 발견하였다. 또한 디버깅을 위해 각 Filter 클래스의 doFilterInternal 메소드에 로그를 심었는데 OncePerRequestFilter 를 상속한 모든 필터가 한번씩 적용되는 점이 발견 되었다.
구글링을 해보니 OncePerRequestFilter를 상속한 모든 클래스는 모든 URI에 대해서 한번씩 적용되는 특징을 가지고 있다.
그렇기 때문에 만약 SecurityContextHolder를 통해 시큐리티 컨테이너에 저장되는 회원 객체가 아니라면 접근할 수 없는 필터 클래스의 경우 OncePerRequestFilter 를 상속한다면 무조건적으로 필터를 실행하게 된다. 따라서 특정 URI가 아니라면 필터링을 하지 않는 로직으로 변경해야한다.
다행히 OncePerRequestFilter 클래스의 메소드에는 특정 URI에 대해서 필터를 실행하지 않도록 하는 메소드가 존재한다.
shouldNotFilter 메소드는 return 받는 URI에 대해서는 필터를 적용하지 않는다. 따라서 원하는 URI가 아닌 경우를 묶어 필터처리를 하지 않는 로직을 구성하면 된다.
따라서 LogoutFilter 와 JwtFilter에 해당 로직을 추가하였다.
package org.kangwooju.skeleton_user.common.security.filter;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kangwooju.skeleton_user.common.security.filter.response.FilterErrorResponse;
import org.kangwooju.skeleton_user.common.security.filter.response.FilterSuccessResponse;
import org.kangwooju.skeleton_user.common.security.repository.RefreshRepository;
import org.kangwooju.skeleton_user.common.security.service.ReissueService;
import org.kangwooju.skeleton_user.common.security.util.JwtUtil;
import org.kangwooju.skeleton_user.common.utils.JsonResponseUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.function.Predicate;
@RequiredArgsConstructor
@Slf4j
public class JwtLogoutFilter extends OncePerRequestFilter {
private final RefreshRepository refreshRepository;
private final JwtUtil jwtUtil;
private final ReissueService reissueService;
@Override
protected boolean shouldNotFilter(HttpServletRequest request)
throws ServletException {
return !("/logout".equals(request.getRequestURI())
&&"POST".equalsIgnoreCase(request.getMethod()));
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("[ 로그아웃 필터 실행 ] URI : {} , TIME : {} "
,request.getRequestURI()
,LocalDateTime.now());
String refresh = reissueService.findCookie(request);
log.info("[ Refresh Token ] : " + refresh);
// Refresh토큰에 오류가 있는지 검사
if(checkRefresh(refresh).error()){
log.info(" [ Refresh Token 오류 발생 ] TIME : {} ",LocalDateTime.now());
filterChain.doFilter(request,response);
return;
}
// Refresh 삭제하기
reissueService.deleteRefresh(refresh);
// Cookie를 빈 쿠키로 설정하기
reissueService.zeroCookie(response);
// 로그아웃 성공 Response반환
JsonResponseUtils.writeJsonResponse(HttpStatus.OK,
response,
new FilterSuccessResponse(true,
"Method : /logout ",
"로그아웃에 성공하였습니다.",
LocalDateTime.now().toString())
);
log.info(" [ 로그아웃 성공 ] TIME : {}",LocalDateTime.now());
}
// 2rd. Refresh토큰을 검사
private FilterErrorResponse checkRefresh(String refresh){
Predicate<String> checkNull = StringParam
-> StringUtils.isBlank(StringParam); // NULL 체크하기
Predicate<String> checkExpiration = StringParam
-> jwtUtil.isExpired(StringParam); // Expiration 여부 확인하기
Predicate<String> checkCategory = StringParam
-> !"refresh".equals(jwtUtil.getCategory(StringParam)); // 카테고리 일치여부 확인하기
String strCheckNull = ""+checkNull.test(refresh);
String strCheckExpiration = "" + checkExpiration.test(refresh);
String strCheckCategory = "" + checkCategory.test(refresh);
log.info("Method : POST , "+
"Refresh NULL : " + strCheckNull + " ,"
+"Expiration : " + strCheckExpiration + " ,"
+"Category : " + strCheckCategory);
boolean result = (checkNull.test(refresh)
&&checkCategory.test(refresh)
&&checkExpiration.test(refresh));
FilterErrorResponse apiErrorResponse =
new FilterErrorResponse(result,
"Method : POST",
"Refresh NULL : " + checkNull.test(refresh) + "\n"
+"Expiration : " + checkExpiration.test(refresh) + "\n"
+"Category : " + checkCategory.test(refresh),
""
);
return apiErrorResponse;
}
}
package org.kangwooju.skeleton_user.common.security.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.kangwooju.skeleton_user.common.security.auth.UserDetailsImpl;
import org.kangwooju.skeleton_user.common.security.dto.response.ExpiredJwtResponse;
import org.kangwooju.skeleton_user.common.security.dto.response.InvalidTokenCategoryResponse;
import org.kangwooju.skeleton_user.common.security.util.JwtUtil;
import org.kangwooju.skeleton_user.domain.user.entity.User;
import org.kangwooju.skeleton_user.domain.user.repository.UserRepository;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.LocalDateTime;
@Slf4j
public class JWTFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
private final AntPathMatcher antPathMatcher;
public JWTFilter(JwtUtil jwtUtil,
UserRepository userRepository,
ObjectMapper objectMapper,
AntPathMatcher antPathMatcher){
this.jwtUtil = jwtUtil;
this.userRepository = userRepository;
this.objectMapper = objectMapper;
this.antPathMatcher = antPathMatcher;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return antPathMatcher.match("/login",path)||
antPathMatcher.match("/refresh",path)||
antPathMatcher.match("/user/**",path);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = extractToken(request);
// 토큰이 비었는지 검사
if(accessToken == null){
log.info(" AccessToken NULL "+" [ Time : " + LocalDateTime.now() + " ]");
filterChain.doFilter(request,response); // 다음 필터 진행
return;
}
log.info("Authorization NOW : " + accessToken + " [ Time : " + LocalDateTime.now() + " ]");
// 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
try{
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e){
handleExpiredJwt(response);
return;
}
String category = jwtUtil.getCategory(accessToken);
if(!category.equals("access")){
log.info("Token Invalid Category " + " [ Time : " + LocalDateTime.now() + " ]");
handleInvalidTokenCategory(response,category);
return;
}
// makeToken()메소드를 사용해서 토큰 생성 후
SecurityContextHolder.getContext().setAuthentication(makeToken(accessToken));
filterChain.doFilter(request,response); // 다음 필터 진행
}
private String extractToken(HttpServletRequest request){
String header = request.getHeader("Authorization");
if(header == null || !header.startsWith("Bearer ")){
return null;
}
return header.substring(7);
}
private Authentication makeToken(String token){
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
User user = userRepository.findByUsername(username)
.orElseThrow(()-> {
log.error("해당 유저 정보를 찾을 수 없습니다. username : " +
username +
" [ Time : " + LocalDateTime.now() + " ]");
throw new UsernameNotFoundException("해당 유저정보를 찾을 수 없습니다.");
});
UserDetailsImpl userDetails = new UserDetailsImpl(user);
// 토큰 생성 ( LoginFIlter 전 인증 생성 -> 이후에 LoginFilter에서는 username,password만 넣어 인증 요청 !! 둘이 파라미터 다름 )
Authentication authentication =
new UsernamePasswordAuthenticationToken
(userDetails,null,userDetails.getAuthorities());
return authentication;
}
private void handleExpiredJwt(HttpServletResponse response) throws IOException{
// 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
ExpiredJwtResponse expiredJwtResponse =
new ExpiredJwtResponse("Expired","JwtToken Has Expired" +
" [ Time : " +
LocalDateTime.now() +
" ]");
String json = objectMapper.writeValueAsString(expiredJwtResponse);
//response body
response.getWriter().write(json);
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 상태코드 추가
response.setContentType("application/json;charset=UTF-8"); // Content-Type 지정
}
private void handleInvalidTokenCategory(HttpServletResponse response,
String category) throws IOException{
InvalidTokenCategoryResponse invalidTokenCategoryResponse =
new InvalidTokenCategoryResponse(category,"Invalid Token Category Found" +
" [ Time : " +
LocalDateTime.now() +
" ]");
String json = objectMapper.writeValueAsString(invalidTokenCategoryResponse);
response.getWriter().write(json);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
}
}
다시 프로젝트 파일을 돌린 후 Postman에서 /login으로 전달하면 다음과 같이 로그인 필터만 적용되는 것을 확인할 수 있다.
스프링 시큐리티를 이용한 프로젝트를 하다보면 무조건적으로 Filter 클래스를 생성해서 적용하게 된다. 이때 , SecurityConfig 내부에서 필터 순서를 적용하게 되지만 모든 필터가 개발자의 생각대로 조건적으로 적용되지는 않는다. 따라서 shouldNotFilter 를 통해 분기를 나눌 수 있는 방식으로 필터를 적용할 수 있으니 나와 같은 문제가 생긴분들은 참고해서 해결하셨으면 좋겠다☺️