SpringBoot + Thymeleaf + JWT 사용 중 리프레시토큰 발급 방식 수정

박찬규·2023년 7월 8일

GoodJobProject

목록 보기
7/9

진행중이던 프로젝트에서 JWT 토큰을 사용하며 사용자를 인증하고 있었는데, 한 가지 문제가 발생했다.
기존 방식은 JWT 토큰을 통해 액세스 토큰을 만들어 사용자의 쿠키에 넣어주고, 액세스 토큰이 만료될 경우 리프레시 토큰 유무를 확인 후 다시 액세스 토큰을 발급해주는 식이었다.
여기서 내가 생각하지 못한 점이 있었다. 리프레시 토큰을 확인하는 과정에서 액세스 토큰과 그 토큰을 담은 쿠키의 만료시간을 동일하게 30분으로 설정했기 때문에, 쿠키 자체가 만료돼버려서 리프레시 토큰을 확인할 새도 없이 바로 로그아웃 처리가 돼 버리는 것이었다.
즉, 리프레시 토큰 유무를 확인하지 못하는 것!

이 문제를 해결하기 위해 나름대로 생각을 해 본 결과 아래와 같은 과정으로 리프레시 토큰을 확인하는 로직을 작성하게됐다.

  1. 로그인 할 때 액세스 토큰을 쿠키에 발급 - 동일 과정
  2. 리프레시 토큰과 같은 만료시간을 가지는 subCookie를 만들어 userId를 담아 발급
  3. 매 요청마다 실행되는 JwtAuthorizationFilter에서 액세스 토큰과 subCookie를 찾는다.
  4. 액세스 토큰이 만료된 경우 subCookie에서 userId 값을 꺼내 redis에 리프레시 토큰이 있는지 조회한다.
  5. 리프레시 토큰이 있는 경우 액세스 토큰을 재발급하고, 없으면 재로그인 시킨다.

작성한 코드는 아래와 같다.

@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));
    }

이렇게 설정해서 쿠키 만료 후에도 리프레시 토큰의 유무를 검색할 수 있도록 나름대로 방법을 짜 내 봤다. 별다른 문제 없이 동작했지만 코드가 지저분하게 느껴져서 다른 방법을 찾아본 결과, 나와 달리 액세스 토큰과 리프레시 토큰을 둘 다 발급하는 방식을 찾았고 이를 적용하기로 했다.
나는 아래와 같은 흐름으로 구현했다.

  1. 로그인 시 액세스 토큰과 리프레시 토큰에 회원정보를 넣고 jwt토큰으로 만들어 쿠키에 넣고 발급한다.
  2. 매 요청마다 쿠키에서 로그인한 회원 정보를 얻어오는 필터에서 액세스 토큰이 담긴 쿠키를 확인한다.
  3. 액세스 토큰이 만료된 경우 리프레시 토큰이 담긴 쿠키를 확인한다.
  4. 리프레시 토큰이 만료되지 않았다면 새 액세스 토큰을 발급하고, 만료된 경우 재로그인 시킨다.
@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를 얻어 올 수 있으니 더 가독성 좋고 효율적인 코드가 된 것 같다.

0개의 댓글