diff --git a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java index 10a14bae..5098b225 100644 --- a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java +++ b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java @@ -47,6 +47,9 @@ public class AuthController { @Value("${jwt.refresh-token-expiry}") private long refreshTokenExpiry; + @Value("${app.cookie.secure:true}") + private boolean cookieSecure; + private final AuthService authService; @Operation(summary = "소셜 로그인 페이지로 리다이렉트") @@ -77,8 +80,9 @@ public ResponseEntity refresh( public ResponseEntity logout( @RequestHeader("Authorization") String authorizationHeader, @AuthenticationPrincipal Long userId, + @CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) { - authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId); + authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId, refreshToken); deleteRefreshTokenCookie(response); return ResponseEntity.ok().build(); } @@ -137,7 +141,7 @@ public ResponseEntity checkNickname(@RequestParam String private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(refreshTokenExpiry / 1000) .sameSite("Strict") @@ -148,7 +152,7 @@ private void addRefreshTokenCookie(HttpServletResponse response, String refreshT private void deleteRefreshTokenCookie(HttpServletResponse response) { ResponseCookie cookie = ResponseCookie.from("refreshToken", "") .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(0) .sameSite("Strict") diff --git a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java index 70752d99..9696800f 100644 --- a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java +++ b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java @@ -48,7 +48,8 @@ public DevTokenResult login(String nickname, UserRole role) { String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name()); String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry); + tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), + refreshToken, refreshTokenExpiry); log.info("Dev 로그인 — userId: {}, nickname: {}", user.getId(), user.getNickname()); return new DevTokenResult(accessToken, refreshToken); diff --git a/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java index f55735c2..215b597f 100644 --- a/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java @@ -34,7 +34,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (token != null) { try { Claims claims = jwtProvider.parseClaims(token); - if (!blacklistRepository.isBlacklisted(token)) { + if (jwtProvider.isAccessToken(claims) && !blacklistRepository.isBlacklisted(token)) { setAuthentication(claims); } } catch (Exception e) { diff --git a/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java b/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java index 08a4f4e0..439b623d 100644 --- a/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java +++ b/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java @@ -9,6 +9,7 @@ import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import java.util.Date; +import java.util.UUID; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -16,6 +17,10 @@ @Component public class JwtProvider { + private static final String TOKEN_TYPE_CLAIM = "typ"; + private static final String ACCESS_TOKEN_TYPE = "access"; + private static final String REFRESH_TOKEN_TYPE = "refresh"; + private final SecretKey secretKey; private final long accessTokenExpiry; private final long refreshTokenExpiry; @@ -35,16 +40,29 @@ public String generateAccessToken(Long userId, String role) { return Jwts.builder() .subject(userId.toString()) .claim("role", role) + .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) .issuedAt(now) .expiration(new Date(now.getTime() + accessTokenExpiry)) .signWith(secretKey) .compact(); } + /** + * 새 세션의 Refresh Token을 발급한다. 세션 식별자(jti)는 새로 생성된다. + */ public String generateRefreshToken(Long userId) { + return generateRefreshToken(userId, UUID.randomUUID().toString()); + } + + /** + * 기존 세션의 Refresh Token을 재발급한다. 회전 시 세션 식별자(jti)를 유지하기 위해 사용한다. + */ + public String generateRefreshToken(Long userId, String sessionId) { Date now = new Date(); return Jwts.builder() .subject(userId.toString()) + .id(sessionId) + .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) .issuedAt(now) .expiration(new Date(now.getTime() + refreshTokenExpiry)) .signWith(secretKey) @@ -55,8 +73,24 @@ public void validateToken(String token) { parseClaims(token); } - public Long getUserId(String token) { - return Long.parseLong(parseClaims(token).getSubject()); + /** + * Refresh Token을 검증하고 사용자 ID와 세션 식별자를 반환한다. + * Access Token이나 세션 식별자(jti) 도입 이전에 발급된 토큰이면 {@link InvalidTokenException}. + */ + public RefreshTokenClaims parseRefreshToken(String token) { + Claims claims = parseClaims(token); + String tokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + if (!REFRESH_TOKEN_TYPE.equals(tokenType) || claims.getId() == null) { + throw new InvalidTokenException(); + } + return new RefreshTokenClaims(Long.parseLong(claims.getSubject()), claims.getId()); + } + + /** + * API 인증에 사용할 수 있는 Access Token인지 확인한다. Refresh Token은 서명이 유효해도 false. + */ + public boolean isAccessToken(Claims claims) { + return ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); } public String getRole(String token) { diff --git a/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java b/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java new file mode 100644 index 00000000..1afc331d --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.auth.jwt; + +/** + * 검증된 Refresh Token에서 추출한 사용자 ID와 세션 식별자(jti). + */ +public record RefreshTokenClaims(Long userId, String sessionId) { +} diff --git a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java index 320b74f4..b62256f3 100644 --- a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java +++ b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java @@ -45,7 +45,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.info("OAuth2 로그인 성공 — userId: {}, isNewUser: {}", user.getId(), oAuth2User.isNewUser()); String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry); + tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), + refreshToken, refreshTokenExpiry); addRefreshTokenCookie(response, refreshToken); String redirectUrl = redirectBaseUri + "?isNewUser=" + (user.getRole() == UserRole.PENDING); diff --git a/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java b/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java index f34029bc..29001c42 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java @@ -1,14 +1,47 @@ package com.Coming.Backend.auth.repository; -import java.util.Optional; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; +/** + * Refresh Token을 기기(세션) 단위로 저장한다. + *
    + *
  • {@code RT:{userId}:{sessionId}} — 세션별 Refresh Token
  • + *
  • {@code RT_SESSIONS:{userId}} — 사용자의 세션 목록 ZSET (score = 세션 생성 시각)
  • + *
+ */ @Repository public class TokenRepository { private static final String KEY_PREFIX = "RT:"; + private static final String SESSIONS_KEY_PREFIX = "RT_SESSIONS:"; + private static final int MAX_SESSIONS = 5; + + // 저장값이 일치하면 교체하고(1), 다르면 이미 회전된 토큰의 재사용으로 보고 세션을 폐기하며(0), 세션이 없으면 -1. + // 세션이 refresh로만 연장되는 경우에도 세션 목록이 먼저 만료되지 않도록 목록 TTL을 함께 연장한다. + private static final RedisScript ROTATE_SCRIPT = new DefaultRedisScript<>( + "local stored = redis.call('get', KEYS[1]) " + + "if not stored then return -1 end " + + "if stored ~= ARGV[1] then " + + "redis.call('del', KEYS[1]) " + + "redis.call('zrem', KEYS[2], ARGV[4]) " + + "return 0 end " + + "redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3]) " + + "redis.call('pexpire', KEYS[2], ARGV[3]) " + + "return 1", + Long.class); + + public enum RotationResult { + ROTATED, + REUSE_DETECTED, + SESSION_NOT_FOUND + } private final RedisTemplate redisTemplate; @@ -16,17 +49,80 @@ public TokenRepository(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } - public void save(Long userId, String refreshToken, long ttlMillis) { + /** + * 새 세션을 저장한다. 세션이 최대 개수를 넘으면 가장 오래 전에 생성된 세션부터 제거한다. + */ + public void saveSession(Long userId, String sessionId, String refreshToken, long ttlMillis) { + String sessionsKey = sessionsKey(userId); redisTemplate.opsForValue() - .set(KEY_PREFIX + userId, refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + .set(sessionKey(userId, sessionId), refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForZSet().add(sessionsKey, sessionId, System.currentTimeMillis()); + redisTemplate.expire(sessionsKey, ttlMillis, TimeUnit.MILLISECONDS); + + pruneSessions(userId); + } + + /** + * 저장된 Refresh Token이 currentToken과 일치할 때만 newToken으로 교체한다. + * 일치하지 않으면 이미 회전된 토큰의 재사용(탈취 의심)으로 보고 해당 세션을 폐기한다. + */ + public RotationResult rotate(Long userId, String sessionId, String currentToken, String newToken, + long ttlMillis) { + Long result = redisTemplate.execute( + ROTATE_SCRIPT, + List.of(sessionKey(userId, sessionId), sessionsKey(userId)), + currentToken, newToken, String.valueOf(ttlMillis), sessionId); + if (Long.valueOf(1L).equals(result)) { + return RotationResult.ROTATED; + } + if (Long.valueOf(0L).equals(result)) { + return RotationResult.REUSE_DETECTED; + } + return RotationResult.SESSION_NOT_FOUND; + } + + public void deleteSession(Long userId, String sessionId) { + redisTemplate.delete(sessionKey(userId, sessionId)); + redisTemplate.opsForZSet().remove(sessionsKey(userId), sessionId); + } + + public void deleteAllSessions(Long userId) { + String sessionsKey = sessionsKey(userId); + Set sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1); + List keys = new ArrayList<>(); + if (sessionIds != null) { + sessionIds.forEach(sessionId -> keys.add(sessionKey(userId, sessionId))); + } + keys.add(sessionsKey); + redisTemplate.delete(keys); + } + + // 만료된 세션을 목록에서 정리한 뒤, 남은 세션이 최대 개수를 넘으면 오래된 순으로 제거한다. + // score(생성 시각)는 refresh로 연장된 세션을 구분하지 못하므로, 만료는 Refresh Token 키 존재 여부로 판단한다. + private void pruneSessions(Long userId) { + String sessionsKey = sessionsKey(userId); + Set sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1); + if (sessionIds == null) { + return; + } + List activeSessionIds = new ArrayList<>(); + for (String sessionId : sessionIds) { + if (Boolean.TRUE.equals(redisTemplate.hasKey(sessionKey(userId, sessionId)))) { + activeSessionIds.add(sessionId); + } else { + redisTemplate.opsForZSet().remove(sessionsKey, sessionId); + } + } + for (int i = 0; i < activeSessionIds.size() - MAX_SESSIONS; i++) { + deleteSession(userId, activeSessionIds.get(i)); + } } - public Optional find(Long userId) { - String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId); - return Optional.ofNullable(value); + private String sessionKey(Long userId, String sessionId) { + return KEY_PREFIX + userId + ":" + sessionId; } - public void delete(Long userId) { - redisTemplate.delete(KEY_PREFIX + userId); + private String sessionsKey(Long userId) { + return SESSIONS_KEY_PREFIX + userId; } } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 8d5a6b38..026bcb39 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -19,9 +19,11 @@ import com.Coming.Backend.auth.exception.TermsNotAgreedException; import com.Coming.Backend.auth.exception.UserNotFoundException; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.auth.repository.BlacklistRepository; import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; @@ -33,6 +35,7 @@ import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -56,38 +59,49 @@ public class AuthService { /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. - * 기존 Refresh Token은 즉시 교체되어 재사용이 불가능하다. + * 해당 세션(기기)의 Refresh Token만 교체되며, 이미 회전된 Refresh Token이 다시 제출되면 그 세션을 폐기한다. * * @param refreshToken HttpOnly Cookie에서 추출한 Refresh Token */ public TokenPair refreshToken(String refreshToken) { - Long userId = extractUserIdFromRefreshToken(refreshToken); - validateStoredRefreshToken(userId, refreshToken); + RefreshTokenClaims claims = parseRefreshToken(refreshToken); + Long userId = claims.userId(); + String sessionId = claims.sessionId(); User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); if (user.getStatus() != UserStatus.ACTIVE) { throw new RefreshTokenInvalidException(); } String newAccessToken = jwtProvider.generateAccessToken(userId, user.getRole().name()); - String newRefreshToken = jwtProvider.generateRefreshToken(userId); - tokenRepository.save(userId, newRefreshToken, jwtProvider.getRefreshTokenExpiry()); + String newRefreshToken = jwtProvider.generateRefreshToken(userId, sessionId); + RotationResult result = tokenRepository.rotate(userId, sessionId, refreshToken, newRefreshToken, + jwtProvider.getRefreshTokenExpiry()); + if (result == RotationResult.REUSE_DETECTED) { + log.warn("Refresh Token 재사용 감지 — 세션 폐기, userId: {}", userId); + } + if (result != RotationResult.ROTATED) { + throw new RefreshTokenInvalidException(); + } return new TokenPair(newAccessToken, newRefreshToken); } /** - * Access Token을 블랙리스트에 등록하고 Refresh Token을 삭제한다. + * Access Token을 블랙리스트에 등록하고 현재 기기의 세션만 삭제한다. + * Refresh Token이 없거나 유효하지 않으면 세션 삭제는 건너뛴다 (세션은 TTL 만료로 정리). * - * @param accessToken Authorization 헤더에서 추출한 Access Token - * @param userId 인증된 사용자 ID + * @param accessToken Authorization 헤더에서 추출한 Access Token + * @param userId 인증된 사용자 ID + * @param refreshToken HttpOnly Cookie에서 추출한 Refresh Token (nullable) */ - public void logout(String accessToken, Long userId) { + public void logout(String accessToken, Long userId, String refreshToken) { blacklistRepository.save(accessToken, jwtProvider.getRemainingExpiry(accessToken)); - tokenRepository.delete(userId); + findOwnSessionId(userId, refreshToken) + .ifPresent(sessionId -> tokenRepository.deleteSession(userId, sessionId)); log.info("로그아웃"); } /** - * 회원 탈퇴 처리 후 토큰을 무효화한다. + * 회원 탈퇴 처리 후 Access Token을 블랙리스트에 등록하고 모든 기기의 세션을 삭제한다. * * @param accessToken Authorization 헤더에서 추출한 Access Token * @param userId 인증된 사용자 ID @@ -101,7 +115,8 @@ public void withdraw(String accessToken, Long userId) { User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); user.withdraw(); userRepository.flush(); - logout(accessToken, userId); + blacklistRepository.save(accessToken, jwtProvider.getRemainingExpiry(accessToken)); + tokenRepository.deleteAllSessions(userId); log.info("회원 탈퇴"); } @@ -206,9 +221,9 @@ public NicknameCheckResponse checkNickname(String nickname) { return new NicknameCheckResponse(available); } - private Long extractUserIdFromRefreshToken(String refreshToken) { + private RefreshTokenClaims parseRefreshToken(String refreshToken) { try { - return jwtProvider.getUserId(refreshToken); + return jwtProvider.parseRefreshToken(refreshToken); } catch (ExpiredTokenException e) { throw new RefreshTokenExpiredException(); } catch (InvalidTokenException e) { @@ -216,11 +231,16 @@ private Long extractUserIdFromRefreshToken(String refreshToken) { } } - private void validateStoredRefreshToken(Long userId, String refreshToken) { - String stored = tokenRepository.find(userId) - .orElseThrow(RefreshTokenInvalidException::new); - if (!stored.equals(refreshToken)) { - throw new RefreshTokenInvalidException(); + // 다른 사용자의 Refresh Token으로 그 사용자의 세션을 지우지 못하도록 소유자를 확인한다. + private Optional findOwnSessionId(Long userId, String refreshToken) { + if (refreshToken == null) { + return Optional.empty(); + } + try { + RefreshTokenClaims claims = jwtProvider.parseRefreshToken(refreshToken); + return userId.equals(claims.userId()) ? Optional.of(claims.sessionId()) : Optional.empty(); + } catch (ExpiredTokenException | InvalidTokenException e) { + return Optional.empty(); } } diff --git a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java index 875ef788..75856ab6 100644 --- a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java @@ -1,12 +1,16 @@ package com.Coming.Backend.auth.controller; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -14,6 +18,7 @@ import com.Coming.Backend.auth.dto.MeResponse; import com.Coming.Backend.auth.dto.NicknameCheckResponse; import com.Coming.Backend.auth.dto.RegisterRequest; +import com.Coming.Backend.auth.dto.TokenPair; import com.Coming.Backend.auth.dto.TokenResponse; import com.Coming.Backend.auth.exception.NicknameDuplicateException; import com.Coming.Backend.auth.exception.TermsNotAgreedException; @@ -21,6 +26,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.GlobalExceptionHandler; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +41,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @ExtendWith(MockitoExtension.class) @@ -184,4 +191,62 @@ void should_return_200_with_available_false_when_nickname_is_taken() throws Exce .andExpect(status().isOk()) .andExpect(jsonPath("$.available").value(false)); } + + // ------------------------------------------------------------------------- + // POST /api/auth/logout + // ------------------------------------------------------------------------- + + @Test + void should_return_200_and_pass_refresh_token_when_logout_with_cookie() throws Exception { + // given + String refreshToken = "refresh-token-value"; + + // when & then + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN) + .cookie(new Cookie("refreshToken", refreshToken))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("Max-Age=0"))); + verify(authService).logout(ACCESS_TOKEN, USER_ID, refreshToken); + } + + @Test + void should_return_200_when_logout_without_refresh_token_cookie() throws Exception { + // given — Refresh Token 쿠키 없음 + + // when & then + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()); + verify(authService).logout(ACCESS_TOKEN, USER_ID, null); + } + + @Test + void should_issueNonSecureCookie_when_cookieSecureDisabled() throws Exception { + // given — 로컬(http) 환경: app.cookie.secure=false + ReflectionTestUtils.setField(authController, "cookieSecure", false); + given(authService.refreshToken("refresh-token-value")) + .willReturn(new TokenPair(ACCESS_TOKEN, "new-refresh-token")); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .cookie(new Cookie("refreshToken", "refresh-token-value"))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("new-refresh-token"))) + .andExpect(header().string("Set-Cookie", not(containsString("Secure")))); + } + + @Test + void should_issueSecureCookie_when_cookieSecureEnabled() throws Exception { + // given + ReflectionTestUtils.setField(authController, "cookieSecure", true); + given(authService.refreshToken("refresh-token-value")) + .willReturn(new TokenPair(ACCESS_TOKEN, "new-refresh-token")); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .cookie(new Cookie("refreshToken", "refresh-token-value"))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("Secure"))); + } } diff --git a/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java b/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java index 291e47eb..ab2b1def 100644 --- a/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java @@ -64,6 +64,7 @@ void should_setAuthentication_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -85,6 +86,7 @@ void should_notSetAuthentication_when_tokenIsBlacklisted() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(true); // when @@ -94,6 +96,21 @@ void should_notSetAuthentication_when_tokenIsBlacklisted() throws Exception { assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } + @Test + void should_notSetAuthentication_when_refreshTokenGivenAsBearer() throws Exception { + // given — 서명이 유효한 Refresh Token을 Bearer로 제출 + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(false); + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(filterChain.getRequest()).isNotNull(); + } + @Test void should_passFilterWithoutAuthentication_when_tokenIsTampered() throws Exception { // given @@ -136,6 +153,7 @@ void should_invokeNextFilter_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -166,6 +184,7 @@ void should_setMdcUserId_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -185,6 +204,7 @@ void should_clearMdcUserId_after_requestCompletes() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); diff --git a/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java b/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java index 2c54d70e..e02a366d 100644 --- a/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java @@ -5,6 +5,10 @@ import com.Coming.Backend.auth.exception.ExpiredTokenException; import com.Coming.Backend.auth.exception.InvalidTokenException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Date; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -32,7 +36,7 @@ void should_returnUserId_when_parseAccessToken() { String token = jwtProvider.generateAccessToken(userId, role); // then - assertThat(jwtProvider.getUserId(token)).isEqualTo(userId); + assertThat(jwtProvider.parseClaims(token).getSubject()).isEqualTo(userId.toString()); } @Test @@ -57,7 +61,85 @@ void should_returnUserId_when_parseRefreshToken() { String token = jwtProvider.generateRefreshToken(userId); // then - assertThat(jwtProvider.getUserId(token)).isEqualTo(userId); + assertThat(jwtProvider.parseRefreshToken(token).userId()).isEqualTo(userId); + } + + @Test + void should_issueDistinctSessionIds_when_generateRefreshTokenTwiceInSameSecond() { + // given + Long userId = 1L; + + // when + String first = jwtProvider.generateRefreshToken(userId); + String second = jwtProvider.generateRefreshToken(userId); + + // then + assertThat(first).isNotEqualTo(second); + assertThat(jwtProvider.parseRefreshToken(first).sessionId()) + .isNotBlank() + .isNotEqualTo(jwtProvider.parseRefreshToken(second).sessionId()); + } + + @Test + void should_keepSessionId_when_generateRefreshTokenWithSessionId() { + // given + String sessionId = "session-1"; + + // when + String token = jwtProvider.generateRefreshToken(1L, sessionId); + + // then + assertThat(jwtProvider.parseRefreshToken(token)) + .isEqualTo(new RefreshTokenClaims(1L, sessionId)); + } + + @Test + void should_throwInvalidTokenException_when_parseRefreshTokenWithAccessToken() { + // given + String accessToken = jwtProvider.generateAccessToken(1L, "USER"); + + // when & then + assertThatThrownBy(() -> jwtProvider.parseRefreshToken(accessToken)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void should_throwInvalidTokenException_when_refreshTokenHasNoJtiOrType() { + // given — 세션 식별자·토큰 종류 도입 이전 형식의 Refresh Token + String legacyToken = Jwts.builder() + .subject("1") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRY)) + .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET))) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.parseRefreshToken(legacyToken)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void should_returnTrue_when_checkAccessTokenType() { + // given + String accessToken = jwtProvider.generateAccessToken(1L, "USER"); + + // when + boolean isAccessToken = jwtProvider.isAccessToken(jwtProvider.parseClaims(accessToken)); + + // then + assertThat(isAccessToken).isTrue(); + } + + @Test + void should_returnFalse_when_checkRefreshTokenAsAccessToken() { + // given + String refreshToken = jwtProvider.generateRefreshToken(1L); + + // when + boolean isAccessToken = jwtProvider.isAccessToken(jwtProvider.parseClaims(refreshToken)); + + // then + assertThat(isAccessToken).isFalse(); } @Test diff --git a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java index 57562b19..b6bf90fa 100644 --- a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java +++ b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java @@ -8,6 +8,7 @@ import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.auth.repository.TokenRepository; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -62,6 +63,8 @@ void should_redirect_with_isNewUser_false_when_existing_user_logs_in() throws Ex CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -79,6 +82,8 @@ void should_redirect_with_isNewUser_true_when_new_user_logs_in() throws Exceptio CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), true); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -96,6 +101,8 @@ void should_redirect_with_isNewUser_true_when_returning_pending_user_logs_in() t CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -113,6 +120,8 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -128,11 +137,13 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro } @Test - void should_call_tokenRepository_save_when_authentication_success() throws Exception { + void should_saveNewSession_when_authentication_success() throws Exception { // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -141,6 +152,6 @@ void should_call_tokenRepository_save_when_authentication_success() throws Excep oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); // then - verify(tokenRepository).save(1L, "refresh-token-value", REFRESH_TOKEN_EXPIRY); + verify(tokenRepository).saveSession(1L, "session-1", "refresh-token-value", REFRESH_TOKEN_EXPIRY); } } diff --git a/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java b/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java index 8825eba9..078c8c1f 100644 --- a/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java @@ -1,22 +1,34 @@ package com.Coming.Backend.auth.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import java.util.Optional; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.script.RedisScript; @ExtendWith(MockitoExtension.class) class TokenRepositoryTest { + private static final long TTL = 604_800_000L; + @InjectMocks private TokenRepository tokenRepository; @@ -26,55 +38,131 @@ class TokenRepositoryTest { @Mock private ValueOperations valueOperations; + @Mock + private ZSetOperations zSetOperations; + @Test - void should_return_token_when_find_after_save() { + void should_storeTokenAndRegisterSession_when_saveSession() { // given - Long userId = 1L; - String refreshToken = "refresh-token-value"; - long ttlMillis = 604_800_000L; - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:1")).willReturn(refreshToken); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(Set.of("s1")); + given(redisTemplate.hasKey("RT:1:s1")).willReturn(true); // when - tokenRepository.save(userId, refreshToken, ttlMillis); - Optional result = tokenRepository.find(userId); + tokenRepository.saveSession(1L, "s1", "token", TTL); // then - assertThat(result).isPresent().contains(refreshToken); - verify(valueOperations).set("RT:1", refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + verify(valueOperations).set("RT:1:s1", "token", TTL, TimeUnit.MILLISECONDS); + verify(zSetOperations).add(eq("RT_SESSIONS:1"), eq("s1"), anyDouble()); + verify(redisTemplate).expire("RT_SESSIONS:1", TTL, TimeUnit.MILLISECONDS); + verify(redisTemplate, never()).delete(anyString()); } @Test - void should_return_empty_when_find_after_delete() { + void should_removeSessionFromList_when_itsTokenHasExpired() { // given - Long userId = 1L; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(orderedSet("expired", "s1")); + given(redisTemplate.hasKey("RT:1:expired")).willReturn(false); + given(redisTemplate.hasKey("RT:1:s1")).willReturn(true); + + // when + tokenRepository.saveSession(1L, "s1", "token", TTL); + + // then + verify(zSetOperations).remove("RT_SESSIONS:1", "expired"); + verify(zSetOperations, never()).remove("RT_SESSIONS:1", "s1"); + } + @Test + void should_evictOldestSession_when_sessionCountExceedsLimit() { + // given + Set sessionIds = orderedSet("s0", "s1", "s2", "s3", "s4", "s5"); given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:1")).willReturn(null); - given(redisTemplate.delete("RT:1")).willReturn(true); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(sessionIds); + given(redisTemplate.hasKey(anyString())).willReturn(true); // when - tokenRepository.delete(userId); - Optional result = tokenRepository.find(userId); + tokenRepository.saveSession(1L, "s5", "token", TTL); // then - assertThat(result).isEmpty(); - verify(redisTemplate).delete("RT:1"); + verify(redisTemplate).delete("RT:1:s0"); + verify(zSetOperations).remove("RT_SESSIONS:1", "s0"); + verify(redisTemplate, never()).delete("RT:1:s1"); } @Test - void should_return_empty_when_find_with_unsaved_userId() { + void should_returnRotated_when_storedTokenMatches() { // given - Long userId = 999L; + givenRotateScriptReturns(1L); - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:999")).willReturn(null); + // when + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(result).isEqualTo(RotationResult.ROTATED); + } + + @Test + void should_returnReuseDetected_when_storedTokenDoesNotMatch() { + // given — 스크립트가 불일치 시 세션을 폐기하고 0을 반환 + givenRotateScriptReturns(0L); // when - Optional result = tokenRepository.find(userId); + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); // then - assertThat(result).isEmpty(); + assertThat(result).isEqualTo(RotationResult.REUSE_DETECTED); + } + + @Test + void should_returnSessionNotFound_when_sessionDoesNotExist() { + // given + givenRotateScriptReturns(-1L); + + // when + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(result).isEqualTo(RotationResult.SESSION_NOT_FOUND); + } + + @Test + void should_deleteOnlyThatSession_when_deleteSession() { + // given + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + + // when + tokenRepository.deleteSession(1L, "s1"); + + // then + verify(redisTemplate).delete("RT:1:s1"); + verify(zSetOperations).remove("RT_SESSIONS:1", "s1"); + } + + @Test + void should_deleteEverySessionAndList_when_deleteAllSessions() { + // given + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(orderedSet("s1", "s2")); + + // when + tokenRepository.deleteAllSessions(1L); + + // then + verify(redisTemplate).delete(List.of("RT:1:s1", "RT:1:s2", "RT_SESSIONS:1")); + } + + private void givenRotateScriptReturns(Long scriptResult) { + given(redisTemplate.execute(ArgumentMatchers.>any(), + eq(List.of("RT:1:s1", "RT_SESSIONS:1")), + eq("old"), eq("new"), eq(String.valueOf(TTL)), eq("s1"))).willReturn(scriptResult); + } + + private static Set orderedSet(String... values) { + return new LinkedHashSet<>(List.of(values)); } } diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 97056881..708b277b 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -4,9 +4,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -21,15 +21,18 @@ import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; import com.Coming.Backend.auth.exception.ExpiredTokenException; +import com.Coming.Backend.auth.exception.InvalidTokenException; import com.Coming.Backend.auth.exception.NicknameDuplicateException; import com.Coming.Backend.auth.exception.RefreshTokenExpiredException; import com.Coming.Backend.auth.exception.RefreshTokenInvalidException; import com.Coming.Backend.auth.exception.UserNotFoundException; import org.springframework.dao.DataIntegrityViolationException; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.auth.repository.BlacklistRepository; import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; @@ -87,6 +90,9 @@ class AuthServiceTest { private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; private static final String ACCESS_TOKEN = "valid-access-token"; private static final String NEW_ACCESS_TOKEN = "new-access-token"; + private static final String SESSION_ID = "session-1"; + private static final long REFRESH_EXPIRY = 604_800_000L; + private static final RefreshTokenClaims REFRESH_CLAIMS = new RefreshTokenClaims(USER_ID, SESSION_ID); private User buildUser() { return User.builder() @@ -128,12 +134,13 @@ private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { void should_return_new_token_pair_and_rotate_refresh_token_when_valid() { // given User user = buildUser(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); - given(jwtProvider.generateRefreshToken(USER_ID)).willReturn(NEW_REFRESH_TOKEN); - given(jwtProvider.getRefreshTokenExpiry()).willReturn(604800000L); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.ROTATED); // when TokenPair result = authService.refreshToken(REFRESH_TOKEN); @@ -141,13 +148,13 @@ void should_return_new_token_pair_and_rotate_refresh_token_when_valid() { // then assertThat(result.accessToken()).isEqualTo(NEW_ACCESS_TOKEN); assertThat(result.refreshToken()).isEqualTo(NEW_REFRESH_TOKEN); - verify(tokenRepository).save(USER_ID, NEW_REFRESH_TOKEN, 604800000L); + verify(tokenRepository).rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY); } @Test void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired() { // given - given(jwtProvider.getUserId(REFRESH_TOKEN)).willThrow(new ExpiredTokenException()); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new ExpiredTokenException()); // when & then assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) @@ -155,6 +162,18 @@ void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired( .hasMessage(ErrorCode.REFRESH_TOKEN_EXPIRED.getMessage()); } + @Test + void should_throw_RefreshTokenInvalidException_when_token_is_not_a_valid_refresh_token() { + // given — Access Token 또는 jti·typ 도입 이전에 발급된 Refresh Token + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); + + // when & then + assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) + .isInstanceOf(RefreshTokenInvalidException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_INVALID.getMessage()); + verify(tokenRepository, never()).rotate(any(), any(), any(), any(), anyLong()); + } + @Test void should_throw_RefreshTokenInvalidException_when_inactive_user_refreshes_token() { // given @@ -165,8 +184,7 @@ void should_throw_RefreshTokenInvalidException_when_inactive_user_refreshes_toke .provider("google") .providerId("google-123") .build(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(inactiveUser)); // when & then @@ -185,8 +203,7 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok .provider("google") .providerId("google-123") .build(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(suspendedUser)); // when & then @@ -196,10 +213,34 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok } @Test - void should_throw_refresh_token_invalid_exception_when_refresh_token_does_not_match_redis() { - // given - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of("different-token")); + void should_throw_RefreshTokenInvalidException_when_reused_refresh_token_is_detected() { + // given — 이미 회전된 토큰 재제출 (세션은 저장소에서 폐기됨) + User user = buildUser(); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.REUSE_DETECTED); + + // when & then + assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) + .isInstanceOf(RefreshTokenInvalidException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_INVALID.getMessage()); + } + + @Test + void should_throw_RefreshTokenInvalidException_when_session_does_not_exist() { + // given — 로그아웃·세션 상한 초과·탈퇴로 제거된 세션 + User user = buildUser(); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.SESSION_NOT_FOUND); // when & then assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) @@ -212,19 +253,59 @@ void should_throw_refresh_token_invalid_exception_when_refresh_token_does_not_ma // ------------------------------------------------------------------------- @Test - void should_register_blacklist_and_delete_refresh_token_when_logout() { + void should_register_blacklist_and_delete_current_session_when_logout() { + // given + long remainingExpiry = 900_000L; + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); + verify(tokenRepository).deleteSession(USER_ID, SESSION_ID); + verify(tokenRepository, never()).deleteAllSessions(any()); + } + + @Test + void should_only_register_blacklist_when_logout_without_refresh_token() { // given long remainingExpiry = 900_000L; given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); - willDoNothing().given(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - willDoNothing().given(tokenRepository).delete(USER_ID); // when - authService.logout(ACCESS_TOKEN, USER_ID); + authService.logout(ACCESS_TOKEN, USER_ID, null); // then verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - verify(tokenRepository).delete(USER_ID); + verify(tokenRepository, never()).deleteSession(any(), any()); + } + + @Test + void should_not_delete_session_when_logout_with_invalid_refresh_token() { + // given + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(tokenRepository, never()).deleteSession(any(), any()); + } + + @Test + void should_not_delete_session_when_logout_with_other_users_refresh_token() { + // given + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(new RefreshTokenClaims(2L, SESSION_ID)); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(tokenRepository, never()).deleteSession(any(), any()); } // ------------------------------------------------------------------------- @@ -232,14 +313,12 @@ void should_register_blacklist_and_delete_refresh_token_when_logout() { // ------------------------------------------------------------------------- @Test - void should_call_withdraw_and_invalidate_tokens_when_withdraw() { + void should_call_withdraw_and_invalidate_all_sessions_when_withdraw() { // given User user = buildUser(); long remainingExpiry = 900_000L; given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); - willDoNothing().given(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - willDoNothing().given(tokenRepository).delete(USER_ID); // when authService.withdraw(ACCESS_TOKEN, USER_ID); @@ -251,7 +330,7 @@ void should_call_withdraw_and_invalidate_tokens_when_withdraw() { verify(userConcertCalendarRepository).deleteByUserId(USER_ID); verify(inquiryRepository).deleteByUserId(USER_ID); verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - verify(tokenRepository).delete(USER_ID); + verify(tokenRepository).deleteAllSessions(USER_ID); } // -------------------------------------------------------------------------