RefreshToken

Regular Kim·2025년 6월 6일
0

Reelvy

목록 보기
10/11

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 쿠키로 변환하는 클래스이다.

RefreshToken 갱신

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

이 메서드는 갱신 로직을 처리한다.

  1. 가장 먼저 요청에 사용한 rt가 유효한지 확인한다(at는 확인하지 않는다). rt가 없거나, 유효하지 않다면 예외를 던진다.
  2. 다음으로 갱신을 요청한 유저를 찾는다. rt에서 유저의 이름을 가져온 후 그 이름으로 유저를 찾는다. 유저가 없다면 예외를 던진다.
  3. 찾은 유저의 이름으로 DB에 접근, 그 유저의 rt를 가져온다.
  4. 요청할 때 동봉된 rt와 db에 저장된 rt값이 다르다면 예외를 던진다.
  5. rt 만료를 확인한다. 수명이 다했다면 예외를 던진다.
  6. 위 조건을 모두 만족했다면 갱신한다.

로그아웃

// 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 를 제거한다.

기타 클래스

JwtConstants

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

JwtProperties

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

CookieJwtResolver

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

KeyProvider

@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 생성
JwtUtilJWT 유효성 검사 및 클레임 추출 (username, role, expiration 등)
CookieJwtUtilJWT를 HTTP-only 쿠키로 변환 및 삭제용 쿠키 생성
JwtLoginFilter로그인 성공 시 토큰 발급을 담당
RefreshTokenService토큰 발급, 저장/갱신/삭제 및 재사용 감지 로직 수행
CookieJwtResolver요청 쿠키에서 토큰 추출
KeyProvider서명용 SecretKey 제공
JwtPropertiesaccess/refresh 토큰의 수명, issuer, secretKey 등 JWT 설정값 관리
JwtConstantsAccessToken/RefreshToken 타입 정의 및 쿠키 이름 제공
profile
What doesn't kill you, makes you stronger

0개의 댓글