⚠️ 문제 상황
사용자로부터 재로그인 빈도가 높아 불편하다는 피드백(VOC)을 다수 접수함. 특히, 토큰이 자주 만료되어 로그인이 반복적으로 필요하다는 문제가 발생
🕵️ 원인 분석
- Access Token 만료 주기가 짧음: 보안 강화를 위해 만료 시간을 1시간이라는 짧은 시간으로 설정했으나, 사용자 경험이 저하됨
- Refresh Token 미활용: 기존 시스템에서는 Access Token 만료 시, 매번 로그인 페이지로 이동하도록 설계됨
- 인증 시스템 최적화 부족: 사용자 활동 여부와 관계없이 일괄적으로 토큰을 만료시키는 정책이 적용됨
✅ 해결 방법
Refresh Token을 활용한 인증 유지 연장
- Access Token 만료 시, Refresh Token을 사용하여 자동으로 새로운 Access Token을 발급
- 이를 통해 사용자는 별도의 로그인 절차 없이 인증 상태를 유지 가능
Refresh Token Rotation(RTR) 방식 적용
- Refresh Token을 한 번 사용할 때마다 새로운 Refresh Token을 발급하여 보안 강화
- 도난 방지를 위해 기존 Refresh Token을 즉시 폐기
토큰 유효성 검증 최적화
- 활동 감지 기반 토큰 갱신: 사용자의 활동 여부에 따라 Access Token 만료 시간을 연장하는 방식 적용
- 세션 유지 전략 변경: 기존 1시간 단위 일괄 만료 정책을 Sliding Expiration(사용자 활동 시 갱신) 방식으로 개선
🎯 적용 결과
- UX 개선 및 사용자 불편 감소
- 보안 강화 (Redis TTL, 로그아웃 시 RT 삭제)
- Refresh Token 탈취 방지 (RTR 적용)
로그인 시 AT & RT 반환으로 리팩토링 과정
JwtToken VO 객체 생성
public record JwtToken(
String accessToken,
String refreshToken
) {}
- AT와 RT를 관리하기 위한 값 객체 생성
Redis 연결을 위한 세팅
RedisConfig 설정
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.RedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
- RefreshTokenService에서 의존성 주입으로 사용하기 위해 Bean 등록
Refresh Token 관리를 위한 Service 작성
import java.time.Duration;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final StringRedisTemplate redisTemplate;
private static final String REFRESH_TOKEN_PREFIX = "RT:";
private static final Duration REFRESH_TOKEN_VALIDITY = Duration.ofDays(7);
public void storeRefreshToken(String email, String refreshToken) {
redisTemplate.opsForValue().set(REFRESH_TOKEN_PREFIX + email, refreshToken, REFRESH_TOKEN_VALIDITY);
}
public String getRefreshToken(String email) {
return redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + email);
}
public void deleteRefreshToken(String email) {
redisTemplate.delete(REFRESH_TOKEN_PREFIX + email);
}
}
- Token이 생성 될 경우에 레디스에 저장, 삭제할 수 있는 Service 구현
- Redis에서 Refresh Token의 TTL을 설정하여 자동 삭제되도록 구성, 불필요한 토큰을 줄임
JWT Token Provider에서 AT & RT 발급
public JwtToken create(String email) {
Date expiredDate = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
String accessToken = Jwts.builder()
.signWith(key)
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(expiredDate)
.compact();
String refreshToken = UUID.randomUUID().toString();
return new JwtToken(accessToken, refreshToken);
}
- AccessToken은 Email과 만료 기간의 정보를 갖고있는 JWT로 반환
- RefreshToken은 단순 문자열로 반환
로그인 할 경우에 RT 레디스에 저장
@Override
public ResponseEntity<? super SignInResponseDto> signIn(SignInRequestDto dto) {
try{
...검증 로직
JwtToken jwtToken = jwtProvider.create(email);
// 레디스에 RT 저장
refreshTokenService.storeRefreshToken(email, jwtToken.refreshToken());
return SignInResponseDto.success(jwtToken);
} catch(Exception exception){
...
}
}
- RT는 Redis에서 TTL을 이용하여 유효 기간을 관리
- 랜덤 문자열로 이루어진 RT를 제공하고 레디스에 저장
이전 상황: AT만 반환할 경우
리팩토링 이후: AT와 RT 반환
만료된 AT일 경우 AT & RT 재발급 구현 과정
⚠️ 문제상황
- 기존 JwtAuthenticationFilter에서는 Access Token이 만료되면 모든 API 요청에서 401 Unauthorized를 반환
- 하지만 Refresh Token API(**/api/v1/auth/refresh**)에서는 만료된 Access Token에서도 email을 사용해야 함
- SecurityContextHolder에 email이 저장되지 않으므로 @AuthenticationPrincipal에서 email을 가져올 수 없음
✅ 해결 방법
- Refresh Token API 요청인 경우에는 Access Token이 만료되었어도 SecurityContextHolder에 email을 저장하도록 수정
- 다른 API에서는 기존 로직을 유지하여 Access Token이 만료되면 401 Unauthorized 반환
refresh API 호출 시 email을 @AuthenticationPrincipal로 파싱해 오는 로직을 사용하기에 만료된 토큰이라도 email 파싱
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String token = parseBearerToken(request); // parseBearerToken()을 통해 인증된 토큰을 받음.
String requestURI = request.getRequestURI();
boolean isRefreshTokenRequest = "/api/v1/auth/refresh".equals(requestURI); // Refresh Token API 여부 체크
if (token != null) {
String email = jwtProvider.extractEmail(token); // Access Token에서 email 추출
boolean isValid = jwtProvider.validate(token) != null; // Access Token이 유효한지 확인
if (isValid) {
///...다른 API 요청
} else if (isRefreshTokenRequest) {
// Refresh Token API 요청이면 만료된 Access Token에서도 email 저장
AbstractAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(email, null, List.of()); // 권한 없이 email만 SecurityContext에 저장
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(securityContext);
} else {
// 다른 API 요청에서는 401 반환
}
}
} catch (Exception exception) {
exception.printStackTrace();
}
filterChain.doFilter(request, response);
}
- refresh API에 한해 @AuthenticationPrincipal 파싱할 경우 email 추출 가능
- 추출된 email을 토대로 HTTP 요청 메세지의 refresh 토큰과 Redis의 refresh 토큰 비교 가능
만료된 AT라는 응답을 받을 경우
-> AT와 RT 재발급
로그아웃 할 경우
⚠️ 문제상황
- 레디스에 RT는 저장되어있기에 탈취한 AT와 RT로 지속적인 재발급이 가능
✅ 해결 방법
- 사용자가 로그아웃을 할 경우 레디스에 저장된 RT를 삭제하여 탈취된 RT를 무효화
@Override
public ResponseEntity<? super LogoutResponseDto> logout(String email) {
try {
refreshTokenService.deleteRefreshToken(email);
return LogoutResponseDto.success();
} catch(Exception exception){
...
}
}
- 레디스에 저장된 RT를 삭제
- 유출된 AT와 RT로 재발급 방지
로그아웃 구현
'WEB > 트러블슈팅' 카테고리의 다른 글
[트러블 슈팅] 회원 600명 부하테스트 진행하기 with K6 (0) | 2025.03.21 |
---|---|
[트러블 슈팅] 외부에서의 redis 접근으로 인한 복제 노드로 변환되는 문제 (0) | 2025.03.21 |
[트러블 슈팅] 복합키 인덱스 최적화 (0) | 2025.01.28 |
[트러블 슈팅] MySQL 시간대(Timezone) 설정 이슈 (0) | 2025.01.07 |
[트러블슈팅] 벌크 삭제를 통한 성능 개선 (0) | 2024.12.13 |