Articles

JWT로 로그인하면서 로그아웃과 토큰 재발급을 제대로 만들기

JWT는 서버가 기억하지 않아도 되는 게 장점이지만, 그래서 로그아웃이 어려워요. DropThePitch가 Redis에 리프레시 토큰과 블랙리스트를 두고 재발급과 로그아웃을 처리한 방법을 정리했어요.

DropThePitch는 회원가입을 받고, 로그인하면 JWT를 발급해요. JWT는 토큰 안에 사용자 정보와 만료 시각이 들어 있고 서버의 서명이 붙어 있어서, 서버는 서명만 확인하면 누가 보낸 요청인지 알 수 있어요. 세션을 저장해 둘 필요가 없어요.

그런데 이 장점이 그대로 단점이 돼요. 서버가 아무것도 기억하지 않으니, 이미 발급한 토큰을 "이제 쓰지 마"라고 막을 방법이 없어요. 로그아웃을 눌러도 토큰은 만료될 때까지 계속 유효해요. 이 문제를 어떻게 풀었는지 정리했어요.

토큰은 두 개예요

로그인하면 토큰 두 개를 돌려줘요.

토큰 유효 기간 쓰는 곳
액세스 토큰 1시간 모든 API 요청의 헤더
리프레시 토큰 7일 액세스 토큰이 만료됐을 때 재발급 요청

액세스 토큰은 매 요청마다 오가니 유출될 기회가 많아요. 그래서 유효 기간을 짧게 잡았어요. 리프레시 토큰은 재발급할 때만 쓰니 길게 잡았어요. 사용자는 일주일 동안 다시 로그인하지 않아도 돼요.

액세스 토큰에는 권한 정보를 넣고, 리프레시 토큰에는 넣지 않아요. 리프레시 토큰은 "이 사람이 맞다"는 것만 증명하면 되고, 권한은 재발급할 때 데이터베이스에서 다시 읽어요. 그사이 관리자가 권한을 바꿨다면 새 토큰에 반영돼요.

String accessToken = createToken(username, authorities, new Date(now + ACCESS_TOKEN_EXPIRE_TIME));
String refreshToken = createToken(username, null, new Date(now + REFRESH_TOKEN_EXPIRE_TIME));

redisService.setValues(REFRESH_KEY_PREFIX + username, refreshToken,
        Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME));

리프레시 토큰은 서버가 기억해요

리프레시 토큰만큼은 서버가 기억하도록 했어요. 발급할 때 Redis에 refresh:이메일 키로 저장하고, 유효 기간이 지나면 Redis가 알아서 지워요.

재발급 요청이 오면 들어온 리프레시 토큰이 저장된 것과 같은지 확인해요.

public boolean isStoredRefreshToken(String username, String refreshToken) {
    Object storedRefreshToken = redisService.getValues(REFRESH_KEY_PREFIX + username);
    return storedRefreshToken != null && storedRefreshToken.toString().equals(refreshToken);
}

사용자 한 명에 리프레시 토큰은 하나만 저장돼요. 새로 로그인하면 덮어써지니, 예전에 발급한 리프레시 토큰은 서명이 멀쩡해도 재발급에 쓸 수 없어요.

재발급할 때 확인하는 것

재발급 요청에는 만료된 액세스 토큰과 리프레시 토큰을 함께 보내요. 서버는 네 가지를 차례로 확인해요.

if (accessToken == null || !tokenProvider.validateTokenAllowExpired(accessToken)) {
    throw new TokenInvalidException();
}

if (!tokenProvider.validateToken(refreshToken)) {
    throw new TokenInvalidException();
}

String email = tokenProvider.getSubject(refreshToken);

if (!email.equals(tokenProvider.getSubject(accessToken))) {
    throw new TokenInvalidException();
}

if (!tokenProvider.isStoredRefreshToken(email, refreshToken)) {
    throw new TokenInvalidException();
}
  1. 액세스 토큰은 서명이 맞아야 해요. 만료된 것은 괜찮아요. 재발급은 원래 만료된 뒤에 하는 일이니까요.
  2. 리프레시 토큰은 서명이 맞고 만료되지도 않아야 해요.
  3. 두 토큰의 주인이 같아야 해요. 남의 리프레시 토큰을 주워 와서 내 액세스 토큰과 같이 보내는 경우를 막아요.
  4. 리프레시 토큰이 Redis에 저장된 것과 같아야 해요.

만료된 토큰도 서명은 확인해야 해서, 검증 함수에 "만료는 봐줄지"를 고르는 옵션을 두었어요.

} catch (ExpiredJwtException e) {
    if (allowExpired) {
        return isNotBlacklisted(token);
    }
    log.warn("[validateToken] 만료된 JWT 인증 요청: {}", e.getMessage());
}

서명이 틀렸거나 형식이 깨진 토큰은 이 옵션과 상관없이 거절돼요.

한 번 쓴 토큰은 다시 못 써요

재발급이 끝나면 새 토큰 두 개를 발급하고, 방금 쓴 두 토큰은 블랙리스트에 올려요.

tokenProvider.blacklistAccessToken(accessToken);
tokenProvider.blacklistRefreshToken(refreshToken);
AuthenticationToken authenticationToken = tokenProvider.generateToken(authentication);

리프레시 토큰을 재발급 때마다 바꾸는 이유는 유출에 대비하기 위해서예요. 누군가 리프레시 토큰을 훔쳐 갔더라도, 진짜 사용자가 먼저 재발급하면 훔친 토큰은 쓸 수 없어요. 반대로 훔친 사람이 먼저 쓰면 진짜 사용자의 재발급이 실패해서, 다시 로그인해야 한다는 신호가 돼요.

로그아웃은 블랙리스트로

로그아웃할 때는 저장된 리프레시 토큰을 지우고, 액세스 토큰과 리프레시 토큰을 블랙리스트에 올려요.

블랙리스트도 Redis에 저장해요. 핵심은 유효 기간이에요. 토큰은 만료되면 어차피 거절되니, 블랙리스트에 영원히 둘 필요가 없어요. 토큰에 남은 시간만큼만 저장해요.

private void blacklistToken(String token, String tokenName, String username) {
    long remainingTime = parseClaims(token).getExpiration().getTime() - System.currentTimeMillis();

    if (remainingTime <= 0) {
        log.info("[blacklistToken] 이미 만료된 {}이라 블랙리스트 등록 생략: {}", tokenName, username);
        return;
    }

    redisService.setValues(BLACKLIST_KEY_PREFIX + token, BLACKLIST_VALUE, Duration.ofMillis(remainingTime));
}

이렇게 하면 블랙리스트가 끝없이 쌓이지 않아요. 액세스 토큰은 길어야 1시간, 리프레시 토큰은 길어야 7일만 남아 있다가 Redis에서 사라져요.

모든 요청은 서명을 확인한 뒤 블랙리스트도 확인해요. Redis에서 키 하나가 있는지 보는 것이라 요청마다 확인해도 부담이 적어요.

if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
    Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

결국 서버가 일부를 기억해요

이렇게 만들고 보면 "서버가 아무것도 기억하지 않는다"는 JWT의 장점이 일부 사라져요. 리프레시 토큰과 블랙리스트를 Redis에 두니까요. 하지만 기억하는 양이 다르다는 점은 중요해요.

  • 세션 방식은 로그인한 모든 사용자의 상태를 기억해요.
  • 이 방식은 사용자마다 리프레시 토큰 하나와, 로그아웃하거나 재발급한 토큰만 남은 시간 동안 기억해요.

그리고 Redis는 페르소나 프로필을 올려 두는 데도 같이 쓰고 있어서, 토큰만을 위한 저장소를 따로 두지 않아도 됐어요.

정리

  • 액세스 토큰은 짧게, 리프레시 토큰은 길게 잡고, 리프레시 토큰은 서버에 저장해요.
  • 재발급할 때는 서명, 만료, 두 토큰의 주인, 저장된 토큰과의 일치를 모두 확인해요.
  • 재발급할 때마다 리프레시 토큰을 바꾸면 유출된 토큰을 쓰기 어려워져요.
  • 로그아웃은 블랙리스트로 처리하고, 블랙리스트는 토큰에 남은 시간만큼만 저장해요.
다음 글 가상 사용자 프로필을 미리 글로 만들어 Redis에 올려 두기