RefreshToken 기능을 완료했으므로 이 글을 작성한다. access_token(이하 at)과 refresh_token(이하 rt) 로직이 어떻게 진행되는지 설명한다.
RefreshToken 구현 전에는 3시간 수명을 가지는 JWT만 발급하는 구조였다. RefreshToken을 구현하면서 ACCESS_TOKEN의 수명은 15분으로, REFRESH_TOKEN의 수명은 7일로 정했다.
jwt:
access-token-lifetime: 15 # AccessToken: 15분
refresh-token-lifetime: 10080 # RefreshToken: 7일(분 단위)
issuer: my-videos
secret-key: my_jwt_secret_key
// JwtLoginFilter
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authResult.getPrincipal();
User user = userService.getUserByUsername(userDetails.getUsername());
refreshTokenService.issueNewTokens(user, response);
// 로그인 성공 시, accessToken 및 refreshToken을 생성하고 쿠키에 저장하여 응답
}
유저가 로그인에 성공하면 refreshTokenService에서 at와 rt를 생성한다. 만약 로그인 요청한 유저의 rt가 이미 db에 있다면 업데이트한다. rt가 없다면 생성 후 저장한다.
// RefreshTokenService
public void issueNewTokens(User user, HttpServletResponse response) {
Date now = new Date();
String accessToken = jwtProvider.createToken(user, now, ACCESS_TOKEN);
String refreshToken = jwtProvider.createToken(user, now, REFRESH_TOKEN);
saveOrUpdateRefreshToken(user, refreshToken);
response.addHeader(HttpHeaders.SET_COOKIE, cookieJwtUtil.createCookieToken(accessToken, ACCESS_TOKEN).toString());
response.addHeader(HttpHeaders.SET_COOKIE, cookieJwtUtil.createCookieToken(refreshToken, REFRESH_TOKEN).toString());
}
at와 rt 생성은 jwtProvider에서 전담한다.
// JwtProvider
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtProvider {
private final KeyProvider keyProvider;
private final JwtUtil jwtUtil;
public String createToken(User user, Date now, JwtConstants token) {
Date expiry = new Date(now.getTime() + jwtUtil.getLifeTimeOf(token));
return Jwts.builder()
.claim("username", user.getUsername())
.claim("role", user.getUserRole())
.issuedAt(now)
.expiration(expiry)
.signWith(keyProvider.getSecretKey())
.compact();
}
}
생성된 rt는 DB에도 저장한다.
public void saveOrUpdateRefreshToken(User user, String token) {
refreshTokenRepository.findByUser(user).ifPresentOrElse(
refreshToken -> refreshToken.update(token), // 갱신 로직
() -> refreshTokenRepository.save(new RefreshToken(user, token))); // 저장 로직
}
생성한 토큰들은 처리 후 응답 헤더에 저장한다.
@Component
@RequiredArgsConstructor
public class CookieJwtUtil {
private final JwtUtil jwtUtil;
public ResponseCookie createCookieToken(String jwt, JwtConstants tokenType) {
return ResponseCookie.from(tokenType.getCookieName(), jwt)
.httpOnly(true)
.sameSite("Lax")
.path("/")
.maxAge(jwtUtil.getLifeTimeOf(tokenType))
.build();
}
public ResponseCookie deleteCookieToken(JwtConstants tokenType) {
return ResponseCookie.from(tokenType.getCookieName(), "")
.httpOnly(true)
.path("/")
.maxAge(0)
.build();
}
}
생성된 토큰을 전달받아 http-only 쿠키로 변환하는 클래스이다.
// controller
@PostMapping("/refresh")
public ResponseEntity<String> refreshToken(HttpServletRequest request, HttpServletResponse response) {
try {
refreshTokenService.reissueTokens(request, response);
return ResponseEntity.ok("accessToken과 refreshToken이 재발급되었습니다.");
} catch (IllegalArgumentException e) {
log.info("갱신 실패");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
}
rt 갱신 시에는 /refresh 경로로 요청을 해야한다.
// service
public void reissueTokens(HttpServletRequest request, HttpServletResponse response) {
// 1. rt 유효성 체크
String refreshToken = cookieJwtResolver.resolveToken(request, REFRESH_TOKEN);
if (refreshToken == null || !jwtUtil.isValidToken(refreshToken)) {
// 요청에 동봉된 rt 유효성 검사
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
}
// 2. 사용자 추출
String username = jwtUtil.getUsername(refreshToken);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자"));
// 3. DB 저장 토큰 확인
RefreshToken stored = refreshTokenRepository.findByUser(user)
.orElseThrow(() -> new IllegalArgumentException("리프레시 토큰 없음"));
// 4. 요청 rt와 db rt 비교 (값이 다르면 재사용임)
if (!stored.getToken().equals(refreshToken)) {
log.warn("[TOKEN REUSE DETECTED] username={}, usedToken={}", username, refreshToken);
// 해당 유저의 세션 강제 만료
refreshTokenRepository.deleteByUser(user);
throw new IllegalArgumentException("일치하지 않는 리프레시 토큰");
}
// 5. db rt 유효성 검사
if (!jwtUtil.isValidToken(stored.getToken())) {
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
}
// 6. 갱신
issueNewTokens(user, response);
}
이 메서드는 갱신 로직을 처리한다.
// AuthPermittedController
@DeleteMapping("/logout")
public ResponseEntity<String> logout(@AuthenticationPrincipal UserDetails userDetails,
HttpServletResponse response) {
refreshTokenService.deleteRefreshToken(userDetails, response);
return ResponseEntity.ok("Successfully logged out");
}
rt를 db에서 삭제해야하므로 delete 요청을 받는다.
public void deleteRefreshToken(UserDetails userDetails, HttpServletResponse response) {
// 로그인 여부에 상관없이 로그아웃 처리
response.addHeader(HttpHeaders.SET_COOKIE, cookieJwtUtil.deleteCookieToken(ACCESS_TOKEN).toString());
response.addHeader(HttpHeaders.SET_COOKIE, cookieJwtUtil.deleteCookieToken(REFRESH_TOKEN).toString());
if (userDetails != null) {
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
refreshTokenRepository.deleteByUser(user);
}
}
요청에 유저 정보가 없더라도 일단은 로그아웃 로직이 실행된다. 이후 유저 정보가 있다면 그 유저의 rt 를 제거한다.
@Getter
@RequiredArgsConstructor
public enum JwtConstants {
ACCESS_TOKEN("access-token"),
REFRESH_TOKEN("refresh-token");
private final String cookieName;
public Cookie from(HttpServletRequest request) {
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findFirst()
.orElse(null);
}
}
@AllArgsConstructor
@ConfigurationProperties("jwt")
public class JwtProperties {
private final String issuer;
private final Long refreshTokenLifetime; // 분단위
private final Long accessTokenLifetime; // 분단위
private final String secretKey;
public Duration getAccessTokenLifetime() {
return Duration.ofMinutes(accessTokenLifetime);
}
public Duration getRefreshTokenLifetime() {
return Duration.ofMinutes(refreshTokenLifetime);
}
public String getIssuer() {
return issuer;
}
public String getSecretKey() {
return secretKey;
}
}
@Slf4j
@Component
public class CookieJwtResolver {
public String resolveToken(HttpServletRequest request, JwtConstants jwtType) {
Cookie cookie = jwtType.from(request);
if(cookie != null) {
return cookie.getValue();
}
log.info("Access token not found");
return null;
}
}
@Component
@Getter
@RequiredArgsConstructor
public class KeyProvider {
private final JwtProperties jwtProperties;
private SecretKey secretKey;
@PostConstruct
private void init() {
secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
}
}
클래스 | 역할 |
---|---|
JwtProvider | 유저 정보를 기반으로 AccessToken, RefreshToken 생성 |
JwtUtil | JWT 유효성 검사 및 클레임 추출 (username, role, expiration 등) |
CookieJwtUtil | JWT를 HTTP-only 쿠키로 변환 및 삭제용 쿠키 생성 |
JwtLoginFilter | 로그인 성공 시 토큰 발급을 담당 |
RefreshTokenService | 토큰 발급, 저장/갱신/삭제 및 재사용 감지 로직 수행 |
CookieJwtResolver | 요청 쿠키에서 토큰 추출 |
KeyProvider | 서명용 SecretKey 제공 |
JwtProperties | access/refresh 토큰의 수명, issuer, secretKey 등 JWT 설정값 관리 |
JwtConstants | AccessToken/RefreshToken 타입 정의 및 쿠키 이름 제공 |