RefreshToken
Refresh Token은 Access Token을 재발급할 때 사용하는 키입니다. Access Token이 긴 만료 시간을 가지게 되면, 탈취당하여 악의적인 공격에 사용될 수 있습니다. 이를 해결하기 위해서 Access Token의 만료 시간을 짧게 유지하고, 상대적으로 긴 만료 시간을 가지는 Refresh Token을 통해 Access Token을 재발급받음으로써 사용자가 로그아웃 없이 로그인 상태를 유지할 수 있게 하여 해결합니다.
✔️ JWT의 설명은 여기에서 확인 가능합니다.
🏷️ Access Token만 사용시 문제점
JWT를 이용해서 프로젝트를 진행 했었는데 그 당시에는 JWT에 대해 이해도 부족하여 AccessToken만을 사용하고 만료 시간을 길게 사용했지만 이 선택은 다음과 같은 문제가 발생한다는 것을 알게 되었습니다.
✅ 탈취 위험 증대
Access Token이 탈취될 경우, 공격자는 AT의 긴 유효기간(예: 1일 또는 1주일) 동안 사용자 계정을 완전히 도용할 수 있습니다.
✅ 토큰 무효화 불가능
순수 JWT 시스템은 서버에 토큰의 상태를 저장하지 않습니다. 따라서 일단 발급된 Access Token은 만료 시점까지 강제로 무효화할 방법이 없습니다. 사용자가 비밀번호를 변경하거나, 관리자가 악성 사용자를 차단해도 이미 탈취된 토큰은 계속 유효합니다.
✅ 세션 제어 불가능
사용자가 로그아웃을 하더라도, 서버는 해당 AT가 만료될 때까지 유효하다고 판단합니다. 진정한 의미의 "로그아웃"이 보장되지 않습니다.
🏷️ Refresh Token의 역할
- 보안 극대화: Access Token의 수명을 매우 짧게 (5~30분) 설정하여 탈취되더라도 공격 가능 시간을 최소화합니다.
- UX 유지: Access Token이 만료되면, 사용자에게 재로그인을 요구하는 대신 Refresh Token을 사용하여 백그라운드에서 조용히 새로운 Access Token을 발급받아 세션을 연장합니다.
- 제어 가능성 확보: Refresh Token 자체는 HttpOnly 쿠키와 서버 측 상태 관리(Redis)를 통해 안전하게 보관 및 무효화가 가능하여, 시스템 관리자가 언제든지 세션을 종료시킬 수 있는 제어권을 갖게 됩니다.
🏷️ 저장 위치
마찬가지로 Refresh Token을 세션 스토리지에 저장하는 것도 XSS 공격과 같이 여러 취약성을 가지고 있습니다. 따라서 Refresh Token을 Redis와 HttpOnly 쿠키에 저장 방식을 채택했습니다
✅ Redis로 상태 관리
- Key - Value 방식, 인메모리 DB 방식으로 빠르게 접근할 수 있습니다.
- Refresh Token은 영구적으로 저장되는 데이터가 아닙니다.
- 사용자가 로그아웃하거나 관리자가 토큰을 강제 무효화해야 할 때, Redis에서 해당 Refresh Token을 삭제함으로써 즉시 토큰을 무효화할 수 있습니다.
✅ HttpOnly 쿠키로 클라이언트에 보관
- 외부의 악성 JavaScript 코드가 document.cookie를 통해 해당 쿠키의 값(Refresh Token)을 읽어 외부 서버로 전송하는 행위를 원천적으로 차단해 XSS (Cross-Site Scripting) 공격 방어합니다.
- SameSite=Lax 또는 Strict 플래그를 함께 사용하면, 외부 도메인에서 발생하는 악의적인 요청에 Refresh Token이 포함되어 전송되는 것을 방지하여 CSRF 공격을 방어 할 수 있습니다.
- 토큰이 쿠키에 저장되어 있으므로, 클라이언트 측 코드에서 별도로 헤더에 토큰을 담아 보낼 필요 없이, 브라우저가 정해진 경로(Path)로 요청할 때 서버에 자동으로 전송됩니다.
- Secure 플래그를 함께 사용하면 HTTPS 통신에서만 쿠키가 전송되도록 강제하여, 네트워크 상의 중간자 공격(MITM)으로부터 토큰을 보호합니다.
🏷️ 코드
✅ yaml 파일 설정
spring:
data:
# redis 설정
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
jwt:
secret-key: secret-key 설정
token:
# access token 만료 시간
access-expired-time-ms: 1800000 # ex) 30분
# refresh token 만료 시간
refresh-expired-time-ms: 1209600000 # ex) 2주
✅ Cache Repository 코드
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.util.Optional;
@Slf4j
@RequiredArgsConstructor
@Repository
public class RefreshTokenCacheRepository {
// Refresh Token 저장을 위한 RedisTemplate
private final RedisTemplate<String, String> refreshTokenRedisTemplate;
// Refresh Token의 유효기간 설정
// 이는 JWT Refresh Token의 실제 유효기간과 일치해야 합니다.
private final static Duration REFRESH_TOKEN_TTL = Duration.ofDays(14);
/**
* userId를 키로 Refresh Token 저장 (RT 유효 기간과 TTL 동일 설정)
*/
public void saveRefreshToken(String userId, String refreshToken) {
String key = getKey(userId);
log.info("Redis에 Refresh Token을 저장합니다. 키: {}", key);
refreshTokenRedisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_TTL);
}
/**
* userId로 저장된 Refresh Token 조회
*/
public Optional<String> findByUserId(String userId) {
String key = getKey(userId);
String token = refreshTokenRedisTemplate.opsForValue().get(key);
return Optional.ofNullable(token);
}
/**
* userId로 저장된 Refresh Token 삭제 (로그아웃, 탈퇴 시)
*/
public void deleteByUserId(String userId) {
String key = getKey(userId);
log.info("Redis에서 Refresh Token을 삭제합니다. 키: {}", key);
refreshTokenRedisTemplate.delete(key);
}
/**
* Refresh Token 저장에 사용될 키를 생성합니다.
*/
private String getKey(String userId) {
return "REFRESH_TOKEN:" + userId;
}
}
✅ JwtTokenProvider 코드
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
@Value("${jwt.secret-key}")
private String secretKey; // JWT 비밀 키
// Access Token의 만료 시간
@Value("${jwt.token.access-expired-time-ms}")
private Long accessExpiredTimeMs;
// Refresh Token의 만료 시간
@Value("${jwt.token.refresh-expired-time-ms}")
private Long refreshExpiredTimeMs;
// Access Token 생성
public String createAccessToken(String userId) {
return JwtTokenUtils.createJwtToken(userId, secretKey, accessExpiredTimeMs);
}
// Refresh Token 생성
public String createRefreshToken(String userId) {
return JwtTokenUtils.createJwtToken(userId, secretKey, refreshExpiredTimeMs);
}
// Refresh Token을 사용하여 새로운 Access Token을 생성
public String reissueAccessToken(String refreshToken) {
// Refresh Token 유효성 검증 및 만료 여부 확인
if (!validateToken(refreshToken)) {
// 유효하지 않거나 만료된 경우, 로그아웃 처리 또는 클라이언트에게 재로그인 요청
throw new DoubleSApplicationException(
ErrorCode.INVALID_TOKEN,
"Refresh Token이 맞지 않습니다."
);
}
Claims claims = JwtTokenUtils.extractClaims(refreshToken, secretKey);
String userId = (String) claims.get("userId");
// 새로운 Access Token 생성
return createAccessToken(userId);
}
// Token 검증
public boolean validateToken(String token) {
// return JwtTokenUtils.isExpired(token, secretKey);
try {
// isExpired가 만료되면 true를 반환한다고 가정하고,
// validateToken은 만료되지 않아야 true를 반환하도록 수정
return !JwtTokenUtils.isExpired(token, secretKey);
} catch (Exception e) {
// 서명 오류, 유효하지 않은 토큰 등 예외 처리
// 서명 오류, 유효하지 않은 토큰 등 예외가 발생하면 유효하지 않으므로 false를 반환합니다.
// (수정됨: Exception 발생 시 예외를 던지지 않고 false 반환)
log.error("Token validation failed: {}", e.getMessage());
return false;
}
}
// 토큰에서 Authentication 객체 생성
public UsernamePasswordAuthenticationToken getAuthentication(String token) {
Claims claims = JwtTokenUtils.extractClaims(token, secretKey);
String userId = (String) claims.get("userId");
// 유저 정보 가져오기
UserAccountDto userAccountDto = UserAccountDto.fromEntity(
serviceUtils.getUserAccountOrException(userId)
);
// Authentication 객체에 담기
return new UsernamePasswordAuthenticationToken(
userAccountDto, null, userAccountDto.getAuthorities()
);
}
// 토큰에서 userId 가져오기
public String getUserId(String token) {
return JwtTokenUtils.getUserId(token, secretKey);
}
}
✔️ JwtTokenUtils의 코드는 여기서 확인 가능합니다.
✅ JwtTokenFilter 코드
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final List<String> TOKEN_SKIP_URIS = List.of(
"/api/reissue" // Access Token 재발급 요청은 Access Token 검증을 건너뜁니다.
);
private static final String BEARER_PREFIX = "Bearer ";
private static String TOKEN_PREFIX = "token=";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 토큰 재발급은 jwt 필터 스킵
if (TOKEN_SKIP_URIS.contains(request.getRequestURI())) {
log.info("Access Token 검증 스킵: 재발급(Reissue) 경로 접근 허용. URI: {}", request.getRequestURI());
filterChain.doFilter(request, response);
return;
}
final String token;
// 일반 HTTP 요청 시 토큰 추출 (Authorization 헤더 체크)
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
// 헤더가 null이거나 Bear 토큰 형식이 아닌 겨우, 인증 없이 다음 필터로 이동
if (header == null || !header.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
// Bearer 접두사 제거 후 문자열로 가져옴
token = header.substring(BEARER_PREFIX.length()).trim();
// JWT 유효성 검사 및 인증 처리
try {
// validateToken은 토큰이 유효하면 true를 반환해야 합니다.
// 토큰이 만료되었는지 확인
if (!jwtTokenProvider.validateToken(token)) {
log.error("JWT 토큰이 유효하지 않거나 만료되었습니다.");
filterChain.doFilter(request, response);
return;
}
// 토큰이 유효한 경우, 인증 객체 생성
UsernamePasswordAuthenticationToken authentication =
jwtTokenProvider.getAuthentication(token);
// 추가적인 사용자 세부 정보 추가 (IP 등을 추가 할 수 있음)
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 사용자 정보를 securityContextHolder에 추가
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("인증 성공: User ID: {}", authentication.getName());
} catch (Exception e) {
// RuntimeException 뿐만 아니라 모든 예외(Exception)를 처리하도록 포괄적으로 변경
log.error("JWT 인증 과정 중 오류 발생 (토큰 파싱/인증 실패): {}", e.getMessage());
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
}
✅ SecurityConfig 코드
@Configuration
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${jwt.secret-key}")
private String key;
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // csrf비활성화
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll() // security 설정 생략
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // stateless 설정
)
.addFilterBefore(
new JwtTokenFilter(jwtTokenProvider), // jwt 필터 설정
UsernamePasswordAuthenticationFilter.class
)
.build();
}
// 패스워드 인코더 추가
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
✅ Service 코드
@Slf4j
@RequiredArgsConstructor
@Service
public class UserAccountService {
private final RefreshTokenCacheRepository refreshTokenCacheRepository;
private final ServiceUtils serviceUtils;
private final JwtTokenProvider jwtTokenProvider;
// 로그인 (Access Token과 Refresh Token을 모두 반환하고 RT를 Redis에 저장)
@Transactional
public TokenDto login(String userId, String password) {
// 회원 가입 체크
UserAccount userAccount = serviceUtils.getUserAccountOrException(userId);
// 비밀 번호 체크
if (!encoder.matches(password, userAccount.getPassword())) {
// 에러 반환
}
// 토큰 생성: Access Token 및 Refresh Token 생성
String accessToken = jwtTokenProvider.createAccessToken(userId);
String refreshToken = jwtTokenProvider.createRefreshToken(userId);
// Refresh Token 저장 (전용 Redis Key에 저장)
refreshTokenCacheRepository.saveRefreshToken(userId, refreshToken);
// 토큰 반환
return TokenDto.of(accessToken, refreshToken);
}
// 로그아웃
public void logout(String userId) {
// 로그아웃시 Refresh Token가 존재하면 삭제
if (refreshTokenCacheRepository.findByUserId(userId).isPresent()) {
refreshTokenCacheRepository.deleteByUserId(userId);
log.info("userId의 RT 제거");
}
}
// 토큰 재발급 (Reissue)
public TokenDto reissueToken(String refreshToken) {
// Refresh Token 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
// 에러 반환
}
// Refresh Token에서 userId 추출
String userId = jwtTokenProvider.getUserId(refreshToken);
// Redis에 저장된 Refresh Token 조회
String findRefreshToken = refreshTokenCacheRepository.findByUserId(userId)
.orElseThrow(() ->
// 에러 반환
);
// Redis에 저장된 RT와 클라이언트가 보낸 RT가 일치하는지 확인 (보안 강화)
if (!findRefreshToken.equals(refreshToken)) {
// 에러 반환
}
// 모든 검증이 통과되면 새로운 Access Token 발급
String accessToken = jwtTokenProvider.reissueAccessToken(userId);
return TokenDto.of(accessToken, refreshToken);
}
}
✅ Controller 코드
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class UserController {
private final UserAccountService userAccountService;
private static int REFRESHTOKEN_AGE = 60 * 60 * 24 * 14;
// 로그인
@PostMapping("/login")
public Response<UserLoginResponse> login(
@RequestBody UserLoginRequest request,
HttpServletResponse httpServletResponse
) {
// Service에서 AT와 RT를 모두 포함하는 TokenResponse를 받습니다.
TokenDto tokenDto = userAccountService.login(request.getUserId(), request.getPassword());
// RT를 HttpOnly 쿠키로 설정합니다.
String sameSiteHeader = setRefreshTokenCookie(tokenDto.getRefreshToken(), REFRESHTOKEN_AGE, true);
httpServletResponse.setHeader("Set-Cookie", sameSiteHeader);
return Response.success(UserLoginResponse.ofDto(tokenDto));
}
// 로그아웃
@PostMapping("/logout")
public Response<Void> logout(
Authentication authentication,
HttpServletResponse httpServletResponse
) {
if (authentication == null) {
log.warn("인증되지 않은 사용자(/logout) 요청. 토큰이 없거나 이미 만료된 상태일 수 있습니다.");
// 클라이언트 세션 정리만 하도록 유도하기 위해 성공 응답을 반환할 수도 있습니다.
// 또는 401 에러를 명시적으로 반환할 수도 있습니다. (정책에 따라 선택)
return Response.error(ErrorCode.ACCESS_DENIED.name());
}
// 서버측 토큰 무효화 (DB/Redis 등에서 삭제)
userAccountService.logout(authentication.getName());
// 브라우저 쿠키 삭제를 위해 Max-Age=0인 쿠키 헤더 설정
String deleteCookieHeader = setRefreshTokenCookie("", 0, true);
httpServletResponse.setHeader("Set-Cookie", deleteCookieHeader);
return Response.success();
}
// token 재발급
@PostMapping("/reissue")
public Response<UserLoginResponse> reissueToken(
// @CookieValue 어노테이션으로 브라우저가 자동으로 보낸 HttpOnly 쿠키의 값을 추출합니다.
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse httpServletResponse
) {
if (refreshToken == null) {
// Refresh Token이 없는 경우 401 Unauthorized 오류를 반환해야 하지만,
// 여기서는 Response 객체를 사용하므로 실패 응답을 반환합니다.
return Response.error(ErrorCode.INVALID_TOKEN.name());
}
// RT를 검증하고 새로운 Access/Refresh Token을 발급받습니다.
TokenDto newTokenDto = userAccountService.reissueToken(refreshToken);
// RT를 다시 HttpOnly 쿠키로 설정합니다. (토큰 회전)
String sameSiteHeader = setRefreshTokenCookie(
newTokenDto.getRefreshToken(), REFRESHTOKEN_AGE,true);
httpServletResponse.setHeader("Set-Cookie", sameSiteHeader);
return Response.success(UserLoginResponse.ofDto(newTokenDto));
}
private static String setRefreshTokenCookie(String refreshToken, int age, boolean secure) {
ResponseCookie cookie = ResponseCookie
.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(secure)
.path("/")
.maxAge(age) // 14 일 60 * 60 * 24 * 14
.sameSite("Lax") // SameSite=Lax 속성 적용
.build();
return cookie.toString();
}
}
✅ DTO 코드
// TokenDto
@Data
@RequiredArgsConstructor
public class TokenDto {
private final String accessToken;
private final String refreshToken;
public static TokenDto of(String accessToken, String refreshToken) {
return new TokenDto(accessToken, refreshToken);
}
}
// UserLoginResponse
@Getter
@AllArgsConstructor
public class UserLoginResponse {
// 토큰 담는 response
private String accessToken; // access token
private String refreshToken; // refresh Token
public static UserLoginResponse ofDto(TokenDto tokenDto) {
return new UserLoginResponse(tokenDto.getAccessToken(), tokenDto.getRefreshToken());
}
}
// UserLoginRequest
@Getter
@AllArgsConstructor
public class UserLoginRequest {
private String userId;
private String password;
}'코딩 공부 > web & Spring' 카테고리의 다른 글
| [Web / JWT / Spring] HttpOnly 쿠키와 보안 (0) | 2025.12.21 |
|---|---|
| [Spring] STOMP + Spring Security에서 principal이 null 값 뜨는 경우 (0) | 2025.12.14 |
| [Spring] Pageable (0) | 2025.04.08 |
| [Spring] BCryptPasswordEncoder (0) | 2025.04.04 |
| [JPA] CRUDRepository와 JPARepository (0) | 2025.04.01 |