진행중이던 프로젝트에서 JWT 토큰을 사용하며 사용자를 인증하고 있었는데, 한 가지 문제가 발생했다.
기존 방식은 JWT 토큰을 통해 액세스 토큰을 만들어 사용자의 쿠키에 넣어주고, 액세스 토큰이 만료될 경우 리프레시 토큰 유무를 확인 후 다시 액세스 토큰을 발급해주는 식이었다.
여기서 내가 생각하지 못한 점이 있었다. 리프레시 토큰을 확인하는 과정에서 액세스 토큰과 그 토큰을 담은 쿠키의 만료시간을 동일하게 30분으로 설정했기 때문에, 쿠키 자체가 만료돼버려서 리프레시 토큰을 확인할 새도 없이 바로 로그아웃 처리가 돼 버리는 것이었다.
즉, 리프레시 토큰 유무를 확인하지 못하는 것!
이 문제를 해결하기 위해 나름대로 생각을 해 본 결과 아래와 같은 과정으로 리프레시 토큰을 확인하는 로직을 작성하게됐다.
작성한 코드는 아래와 같다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final MemberService memberService;
private final CookieUt cookieUt;
private final RedisUt redisUt;
private Optional<Member> opMember;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 쿠키에서 accessToken 값을 가져온다.
Cookie accessToken = cookieUt.getCookie(request, "accessToken");
Cookie subCookie = cookieUt.getCookie(request, "userId");
// 쿠키가 만료된 경우 리프레시토큰 확인
if (accessToken == null) {
if (subCookie != null) {
boolean hasRefreshToken = redisUt.hasValue(subCookie.getValue());
if (hasRefreshToken) { // 리프레시 토큰 있는 경우
opMember = memberService.findById(Long.parseLong(subCookie.getValue()));
createNewAccessToken(response);
}
}
} else {
String token = accessToken.getValue();
try {
if (jwtProvider.verify(token)) {
Map<String, Object> claims = jwtProvider.getClaims(token);
long id = (int) claims.get("id");
opMember = memberService.findById(id);
if (opMember.isPresent()) {
forceAuthentication(opMember.get());
}
// accessToken 담은 쿠키 만료시 리프레시 토큰 사용을 위한 만료시간이 긴 쿠키설정
if (subCookie == null) {
response.addCookie(cookieUt.createSubCookie("userId", String.valueOf(opMember.get().getId())));
}
}
} catch (ExpiredJwtException e) {
log.debug("토큰 만료");
createNewAccessToken(response);
}
}
filterChain.doFilter(request, response);
}
// 새로운 액세스 토큰 발급하는 메서드
private void createNewAccessToken(HttpServletResponse response) throws IOException {
String userId = String.valueOf(opMember.get().getId());
Long ttl = redisUt.getExpire(userId);
if (ttl < 0) { // 리프레시 토큰까지 만료되었거나 키가 존재하지 않는 경우
// 재로그인
log.debug("재로그인");
response.sendRedirect("/member/login");
}
// 새로운 액세스 토큰 발급
String newAccessToken = jwtProvider.genToken(opMember.get().toClaims());
response.addCookie(cookieUt.createCookie("accessToken", newAccessToken));
}
이렇게 설정해서 쿠키 만료 후에도 리프레시 토큰의 유무를 검색할 수 있도록 나름대로 방법을 짜 내 봤다. 별다른 문제 없이 동작했지만 코드가 지저분하게 느껴져서 다른 방법을 찾아본 결과, 나와 달리 액세스 토큰과 리프레시 토큰을 둘 다 발급하는 방식을 찾았고 이를 적용하기로 했다.
나는 아래와 같은 흐름으로 구현했다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final MemberService memberService;
private final CookieUt cookieUt;
private final RedisUt redisUt;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Cookie accessToken = cookieUt.getCookie(request, "accessToken");
// accessToken 만료된 경우
if (accessToken == null) {
// 리프레시 토큰 확인
Cookie refreshToken = cookieUt.getCookie(request, "refreshToken");
if (refreshToken != null) {
createNewAccessToken(refreshToken, response);
}
} else {
String token = accessToken.getValue();
if (jwtProvider.verify(token)) {
Map<String, Object> claims = jwtProvider.getClaims(token);
long id = (int) claims.get("id");
Member member = memberService.findById(id).orElse(null);
forceAuthentication(member);
}
}
filterChain.doFilter(request, response);
}
// 새로운 액세스 토큰 발급하는 메서드
private void createNewAccessToken(Cookie refreshToken, HttpServletResponse response) throws IOException {
log.debug("토큰 만료");
String token = refreshToken.getValue();
Map<String, Object> claims = jwtProvider.getClaims(token);
long id = (int) claims.get("id");
Member member = memberService.findById(id).orElse(null);
Long ttl = redisUt.getExpire(id);
// 리프레시 토큰까지 만료되었거나 키가 존재하지 않는 경우
if (ttl < 0) {
log.debug("재로그인");
response.sendRedirect("/member/login");
}
String newAccessToken = jwtProvider.genToken(member.toClaims(), ACCESS_TOKEN_VALIDATION_SECOND);
response.addCookie(cookieUt.createCookie("accessToken", newAccessToken));
}
전에는 로그인한 회원 정보를 받아오기 위해 userId를 담은 subCookie나 opMember를 만들어서 구현했는데 리프레시 토큰에서 바로 userId를 얻어 올 수 있으니 더 가독성 좋고 효율적인 코드가 된 것 같다.