From 295e1e7627b28bbe8830a388e396b4d7935df5e2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 27 Sep 2026 20:31:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[feat]=20Refresh=20Token=EC=97=90=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=8B=9D=EB=B3=84=EC=9E=90(jti)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 로그인마다 UUID jti를 발급하고, 회전 시 같은 jti로 재발급하는 오버로드 추가 - getSessionId로 jti 조회 (jti 도입 이전 토큰은 null) - 같은 초에 발급된 Refresh Token 값이 동일해지던 문제 해소 Co-Authored-By: Claude Opus 5.5 --- .../Coming/Backend/auth/jwt/JwtProvider.java | 19 +++++++ .../Backend/auth/jwt/JwtProviderTest.java | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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..aa8aed80 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; @@ -41,10 +42,21 @@ public String generateAccessToken(Long userId, String role) { .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) .issuedAt(now) .expiration(new Date(now.getTime() + refreshTokenExpiry)) .signWith(secretKey) @@ -59,6 +71,13 @@ public Long getUserId(String token) { return Long.parseLong(parseClaims(token).getSubject()); } + /** + * Refresh Token의 세션 식별자(jti)를 반환한다. jti가 없는 토큰이면 null. + */ + public String getSessionId(String token) { + return parseClaims(token).getId(); + } + public String getRole(String token) { return parseClaims(token).get("role", String.class); } 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..33c5f091 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; @@ -60,6 +64,52 @@ void should_returnUserId_when_parseRefreshToken() { assertThat(jwtProvider.getUserId(token)).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.getSessionId(first)) + .isNotBlank() + .isNotEqualTo(jwtProvider.getSessionId(second)); + } + + @Test + void should_keepSessionId_when_generateRefreshTokenWithSessionId() { + // given + String sessionId = "session-1"; + + // when + String token = jwtProvider.generateRefreshToken(1L, sessionId); + + // then + assertThat(jwtProvider.getSessionId(token)).isEqualTo(sessionId); + assertThat(jwtProvider.getUserId(token)).isEqualTo(1L); + } + + @Test + void should_returnNullSessionId_when_refreshTokenHasNoJti() { + // given — jti 도입 이전 형식의 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 + String sessionId = jwtProvider.getSessionId(legacyToken); + + // then + assertThat(sessionId).isNull(); + } + @Test void should_returnNull_when_parseRoleFromRefreshToken() { // given From d6cd28bedcd945bd8cc4663e594f8ac516ce174a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 27 Sep 2026 20:31:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[feat]=20=EB=8B=A4=EC=A4=91=20=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Refresh=20Token=20=EC=84=B8=EC=85=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=A0=80=EC=9E=A5=C2=B7=ED=9A=8C=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenRepository: RT:{userId}:{sessionId} + 세션 목록 ZSET(RT_SESSIONS:{userId})으로 변경, 사용자당 최대 5세션(초과 시 가장 오래된 세션 제거) - 회전은 Lua 스크립트로 저장값 비교 후 교체해 동시 refresh 중 1건만 성공, 세션 목록 TTL도 함께 연장 - 로그인(OAuth2·Dev)마다 새 세션 저장 — 다른 기기의 Refresh Token을 덮어쓰지 않음 - refresh: 해당 세션만 검증·교체, jti 없는 기존 토큰은 REFRESH_TOKEN_INVALID(재로그인) - logout: Refresh Token 쿠키로 현재 기기 세션만 삭제 (쿠키 없거나 무효·타 사용자 토큰이면 삭제 생략, 200 유지) - withdraw: 모든 기기 세션 삭제 Co-Authored-By: Claude Opus 5.5 --- .../auth/controller/AuthController.java | 3 +- .../Backend/auth/dev/DevAuthService.java | 3 +- .../auth/oauth2/OAuth2SuccessHandler.java | 3 +- .../auth/repository/TokenRepository.java | 96 ++++++++++++-- .../Backend/auth/service/AuthService.java | 51 +++++--- .../auth/controller/AuthControllerTest.java | 33 +++++ .../auth/oauth2/OAuth2SuccessHandlerTest.java | 5 +- .../auth/repository/TokenRepositoryTest.java | 123 ++++++++++++++---- .../Backend/auth/service/AuthServiceTest.java | 102 ++++++++++++--- 9 files changed, 346 insertions(+), 73 deletions(-) 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..0ef1815f 100644 --- a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java +++ b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java @@ -77,8 +77,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(); } 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..bb8e5f4a 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.getSessionId(refreshToken), 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/oauth2/OAuth2SuccessHandler.java b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java index 320b74f4..fe741795 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.getSessionId(refreshToken), 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..feec5215 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,36 @@ 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; + + // 저장된 Refresh Token이 일치할 때만 교체한다. 같은 토큰으로 동시에 들어온 refresh 중 1건만 성공시키고, + // 세션이 refresh로만 연장되는 경우에도 세션 목록이 먼저 만료되지 않도록 목록 TTL을 함께 연장한다. + private static final RedisScript ROTATE_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end " + + "redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3]) " + + "redis.call('pexpire', KEYS[2], ARGV[3]) " + + "return 1", + Long.class); private final RedisTemplate redisTemplate; @@ -16,17 +38,75 @@ 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으로 교체한다. + * + * @return 교체에 성공하면 true, 세션이 없거나 값이 다르면 false + */ + public boolean 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)); + return Long.valueOf(1L).equals(result); + } + + 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..a6ec494b 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -33,6 +33,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 +57,48 @@ 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); + String sessionId = jwtProvider.getSessionId(refreshToken); + if (sessionId == null) { + // 세션 식별자(jti) 도입 이전에 발급된 토큰 — 재로그인 필요 + throw new RefreshTokenInvalidException(); + } 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); + if (!tokenRepository.rotate(userId, sessionId, refreshToken, newRefreshToken, + jwtProvider.getRefreshTokenExpiry())) { + 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 +112,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("회원 탈퇴"); } @@ -216,11 +228,18 @@ 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 { + if (!userId.equals(jwtProvider.getUserId(refreshToken))) { + return Optional.empty(); + } + return Optional.ofNullable(jwtProvider.getSessionId(refreshToken)); + } 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..4134daa0 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,15 @@ package com.Coming.Backend.auth.controller; +import static org.hamcrest.Matchers.containsString; 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; @@ -21,6 +24,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; @@ -184,4 +188,33 @@ 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); + } } 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..95f4948f 100644 --- a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java +++ b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java @@ -128,11 +128,12 @@ 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.getSessionId("refresh-token-value")).willReturn("session-1"); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -141,6 +142,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..addb5ae9 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,33 @@ 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 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 +37,117 @@ 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_returnTrue_when_rotateScriptSucceeds() { // given - Long userId = 999L; + given(redisTemplate.execute(ArgumentMatchers.>any(), + eq(List.of("RT:1:s1", "RT_SESSIONS:1")), + eq("old"), eq("new"), eq(String.valueOf(TTL)))).willReturn(1L); - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:999")).willReturn(null); + // when + boolean rotated = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(rotated).isTrue(); + } + + @Test + void should_returnFalse_when_storedTokenDoesNotMatch() { + // given + given(redisTemplate.execute(ArgumentMatchers.>any(), + eq(List.of("RT:1:s1", "RT_SESSIONS:1")), + eq("stale"), eq("new"), eq(String.valueOf(TTL)))).willReturn(0L); + + // when + boolean rotated = tokenRepository.rotate(1L, "s1", "stale", "new", TTL); + + // then + assertThat(rotated).isFalse(); + } + + @Test + void should_deleteOnlyThatSession_when_deleteSession() { + // given + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); // when - Optional result = tokenRepository.find(userId); + tokenRepository.deleteSession(1L, "s1"); // then - assertThat(result).isEmpty(); + 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 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..c3948d8a 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,6 +21,7 @@ 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; @@ -87,6 +88,8 @@ 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 User buildUser() { return User.builder() @@ -129,11 +132,13 @@ 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.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); 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(true); // when TokenPair result = authService.refreshToken(REFRESH_TOKEN); @@ -141,7 +146,7 @@ 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 @@ -155,6 +160,19 @@ void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired( .hasMessage(ErrorCode.REFRESH_TOKEN_EXPIRED.getMessage()); } + @Test + void should_throw_RefreshTokenInvalidException_when_refresh_token_has_no_session_id() { + // given — jti 도입 이전에 발급된 토큰 + given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); + given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(null); + + // 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 @@ -166,7 +184,7 @@ void should_throw_RefreshTokenInvalidException_when_inactive_user_refreshes_toke .providerId("google-123") .build(); given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); given(userRepository.findById(USER_ID)).willReturn(Optional.of(inactiveUser)); // when & then @@ -186,7 +204,7 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok .providerId("google-123") .build(); given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); given(userRepository.findById(USER_ID)).willReturn(Optional.of(suspendedUser)); // when & then @@ -196,10 +214,17 @@ 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 + void should_throw_RefreshTokenInvalidException_when_rotation_fails() { + // given — 저장값 불일치(이미 회전된 토큰 재사용, 동시 refresh 경쟁에서 패배, 세션 제거됨) + User user = buildUser(); given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of("different-token")); + given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + 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(false); // when & then assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) @@ -212,19 +237,60 @@ 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); - willDoNothing().given(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - willDoNothing().given(tokenRepository).delete(USER_ID); + given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); + given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); // when - authService.logout(ACCESS_TOKEN, USER_ID); + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); // then verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - verify(tokenRepository).delete(USER_ID); + 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); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, null); + + // then + verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); + 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.getUserId(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.getUserId(REFRESH_TOKEN)).willReturn(2L); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(tokenRepository, never()).deleteSession(any(), any()); } // ------------------------------------------------------------------------- @@ -232,14 +298,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 +315,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); } // ------------------------------------------------------------------------- From 1ac5d1fbbdc42a731a62780f5ac970f7e03bf34a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 27 Sep 2026 20:47:03 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[feat]=20=ED=86=A0=ED=81=B0=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98(typ)=20=EA=B5=AC=EB=B6=84=20=EB=B0=8F=20Refresh=20Tok?= =?UTF-8?q?en=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EA=B0=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AT에 typ=access, RT에 typ=refresh claim 추가, 인증 필터는 typ=access만 인증 (로그아웃·세션 제거 후에도 서명이 유효한 RT가 Bearer로 통과하던 문제 차단) - JwtProvider.parseRefreshToken: typ·jti를 검증해 userId·sessionId 반환 (getSessionId·getUserId 대체) - 회전 Lua 스크립트: 저장값 불일치 시 이미 회전된 RT의 재사용으로 보고 해당 세션만 원자적으로 폐기 - AuthService: 재사용 감지 시 WARN 로그 후 REFRESH_TOKEN_INVALID Co-Authored-By: Claude Opus 5.5 --- .../Backend/auth/dev/DevAuthService.java | 4 +- .../auth/jwt/JwtAuthenticationFilter.java | 2 +- .../Coming/Backend/auth/jwt/JwtProvider.java | 25 ++++++-- .../Backend/auth/jwt/RefreshTokenClaims.java | 7 +++ .../auth/oauth2/OAuth2SuccessHandler.java | 4 +- .../auth/repository/TokenRepository.java | 30 +++++++--- .../Backend/auth/service/AuthService.java | 31 +++++----- .../auth/jwt/JwtAuthenticationFilterTest.java | 20 +++++++ .../Backend/auth/jwt/JwtProviderTest.java | 52 +++++++++++++---- .../auth/oauth2/OAuth2SuccessHandlerTest.java | 12 +++- .../auth/repository/TokenRepositoryTest.java | 39 +++++++++---- .../Backend/auth/service/AuthServiceTest.java | 57 ++++++++++++------- 12 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java 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 bb8e5f4a..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,8 +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.saveSession(user.getId(), jwtProvider.getSessionId(refreshToken), 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 aa8aed80..439b623d 100644 --- a/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java +++ b/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java @@ -17,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; @@ -36,6 +40,7 @@ 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) @@ -57,6 +62,7 @@ public String generateRefreshToken(Long userId, String sessionId) { 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) @@ -67,15 +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()); } /** - * Refresh Token의 세션 식별자(jti)를 반환한다. jti가 없는 토큰이면 null. + * API 인증에 사용할 수 있는 Access Token인지 확인한다. Refresh Token은 서명이 유효해도 false. */ - public String getSessionId(String token) { - return parseClaims(token).getId(); + 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 fe741795..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,8 +45,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.info("OAuth2 로그인 성공 — userId: {}, isNewUser: {}", user.getId(), oAuth2User.isNewUser()); String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.saveSession(user.getId(), jwtProvider.getSessionId(refreshToken), 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 feec5215..29001c42 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java @@ -23,15 +23,26 @@ public class TokenRepository { private static final String SESSIONS_KEY_PREFIX = "RT_SESSIONS:"; private static final int MAX_SESSIONS = 5; - // 저장된 Refresh Token이 일치할 때만 교체한다. 같은 토큰으로 동시에 들어온 refresh 중 1건만 성공시키고, + // 저장값이 일치하면 교체하고(1), 다르면 이미 회전된 토큰의 재사용으로 보고 세션을 폐기하며(0), 세션이 없으면 -1. // 세션이 refresh로만 연장되는 경우에도 세션 목록이 먼저 만료되지 않도록 목록 TTL을 함께 연장한다. private static final RedisScript ROTATE_SCRIPT = new DefaultRedisScript<>( - "if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end " + "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; public TokenRepository(RedisTemplate redisTemplate) { @@ -53,16 +64,21 @@ public void saveSession(Long userId, String sessionId, String refreshToken, long /** * 저장된 Refresh Token이 currentToken과 일치할 때만 newToken으로 교체한다. - * - * @return 교체에 성공하면 true, 세션이 없거나 값이 다르면 false + * 일치하지 않으면 이미 회전된 토큰의 재사용(탈취 의심)으로 보고 해당 세션을 폐기한다. */ - public boolean rotate(Long userId, String sessionId, String currentToken, String 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)); - return Long.valueOf(1L).equals(result); + 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) { 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 a6ec494b..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; @@ -57,17 +59,14 @@ public class AuthService { /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. - * 해당 세션(기기)의 Refresh Token만 교체되며, 기존 Refresh Token은 즉시 재사용이 불가능하다. + * 해당 세션(기기)의 Refresh Token만 교체되며, 이미 회전된 Refresh Token이 다시 제출되면 그 세션을 폐기한다. * * @param refreshToken HttpOnly Cookie에서 추출한 Refresh Token */ public TokenPair refreshToken(String refreshToken) { - Long userId = extractUserIdFromRefreshToken(refreshToken); - String sessionId = jwtProvider.getSessionId(refreshToken); - if (sessionId == null) { - // 세션 식별자(jti) 도입 이전에 발급된 토큰 — 재로그인 필요 - throw new RefreshTokenInvalidException(); - } + 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) { @@ -75,8 +74,12 @@ public TokenPair refreshToken(String refreshToken) { } String newAccessToken = jwtProvider.generateAccessToken(userId, user.getRole().name()); String newRefreshToken = jwtProvider.generateRefreshToken(userId, sessionId); - if (!tokenRepository.rotate(userId, sessionId, refreshToken, newRefreshToken, - jwtProvider.getRefreshTokenExpiry())) { + 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); @@ -218,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) { @@ -234,10 +237,8 @@ private Optional findOwnSessionId(Long userId, String refreshToken) { return Optional.empty(); } try { - if (!userId.equals(jwtProvider.getUserId(refreshToken))) { - return Optional.empty(); - } - return Optional.ofNullable(jwtProvider.getSessionId(refreshToken)); + 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/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 33c5f091..e02a366d 100644 --- a/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java @@ -36,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 @@ -61,7 +61,7 @@ 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 @@ -75,9 +75,9 @@ void should_issueDistinctSessionIds_when_generateRefreshTokenTwiceInSameSecond() // then assertThat(first).isNotEqualTo(second); - assertThat(jwtProvider.getSessionId(first)) + assertThat(jwtProvider.parseRefreshToken(first).sessionId()) .isNotBlank() - .isNotEqualTo(jwtProvider.getSessionId(second)); + .isNotEqualTo(jwtProvider.parseRefreshToken(second).sessionId()); } @Test @@ -89,13 +89,23 @@ void should_keepSessionId_when_generateRefreshTokenWithSessionId() { String token = jwtProvider.generateRefreshToken(1L, sessionId); // then - assertThat(jwtProvider.getSessionId(token)).isEqualTo(sessionId); - assertThat(jwtProvider.getUserId(token)).isEqualTo(1L); + assertThat(jwtProvider.parseRefreshToken(token)) + .isEqualTo(new RefreshTokenClaims(1L, sessionId)); } @Test - void should_returnNullSessionId_when_refreshTokenHasNoJti() { - // given — jti 도입 이전 형식의 Refresh Token + 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()) @@ -103,11 +113,33 @@ void should_returnNullSessionId_when_refreshTokenHasNoJti() { .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 - String sessionId = jwtProvider.getSessionId(legacyToken); + boolean isAccessToken = jwtProvider.isAccessToken(jwtProvider.parseClaims(refreshToken)); // then - assertThat(sessionId).isNull(); + 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 95f4948f..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(); @@ -133,7 +142,8 @@ void should_saveNewSession_when_authentication_success() throws Exception { 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.getSessionId("refresh-token-value")).willReturn("session-1"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); 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 addb5ae9..078c8c1f 100644 --- a/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java @@ -12,6 +12,7 @@ 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; @@ -94,31 +95,39 @@ void should_evictOldestSession_when_sessionCountExceedsLimit() { } @Test - void should_returnTrue_when_rotateScriptSucceeds() { + void should_returnRotated_when_storedTokenMatches() { // given - given(redisTemplate.execute(ArgumentMatchers.>any(), - eq(List.of("RT:1:s1", "RT_SESSIONS:1")), - eq("old"), eq("new"), eq(String.valueOf(TTL)))).willReturn(1L); + givenRotateScriptReturns(1L); // when - boolean rotated = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); // then - assertThat(rotated).isTrue(); + assertThat(result).isEqualTo(RotationResult.ROTATED); } @Test - void should_returnFalse_when_storedTokenDoesNotMatch() { + void should_returnReuseDetected_when_storedTokenDoesNotMatch() { + // given — 스크립트가 불일치 시 세션을 폐기하고 0을 반환 + givenRotateScriptReturns(0L); + + // when + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(result).isEqualTo(RotationResult.REUSE_DETECTED); + } + + @Test + void should_returnSessionNotFound_when_sessionDoesNotExist() { // given - given(redisTemplate.execute(ArgumentMatchers.>any(), - eq(List.of("RT:1:s1", "RT_SESSIONS:1")), - eq("stale"), eq("new"), eq(String.valueOf(TTL)))).willReturn(0L); + givenRotateScriptReturns(-1L); // when - boolean rotated = tokenRepository.rotate(1L, "s1", "stale", "new", TTL); + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); // then - assertThat(rotated).isFalse(); + assertThat(result).isEqualTo(RotationResult.SESSION_NOT_FOUND); } @Test @@ -147,6 +156,12 @@ void should_deleteEverySessionAndList_when_deleteAllSessions() { 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 c3948d8a..708b277b 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -28,9 +28,11 @@ 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; @@ -90,6 +92,7 @@ class AuthServiceTest { 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() @@ -131,14 +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(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + 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(true); + .willReturn(RotationResult.ROTATED); // when TokenPair result = authService.refreshToken(REFRESH_TOKEN); @@ -152,7 +154,7 @@ void should_return_new_token_pair_and_rotate_refresh_token_when_valid() { @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)) @@ -161,10 +163,9 @@ void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired( } @Test - void should_throw_RefreshTokenInvalidException_when_refresh_token_has_no_session_id() { - // given — jti 도입 이전에 발급된 토큰 - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(null); + 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)) @@ -183,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(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(inactiveUser)); // when & then @@ -203,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(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(suspendedUser)); // when & then @@ -214,17 +213,34 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok } @Test - void should_throw_RefreshTokenInvalidException_when_rotation_fails() { - // given — 저장값 불일치(이미 회전된 토큰 재사용, 동시 refresh 경쟁에서 패배, 세션 제거됨) + void should_throw_RefreshTokenInvalidException_when_reused_refresh_token_is_detected() { + // given — 이미 회전된 토큰 재제출 (세션은 저장소에서 폐기됨) User user = buildUser(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + 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(false); + .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)) @@ -241,8 +257,7 @@ void should_register_blacklist_and_delete_current_session_when_logout() { // given long remainingExpiry = 900_000L; given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(jwtProvider.getSessionId(REFRESH_TOKEN)).willReturn(SESSION_ID); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); // when authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); @@ -271,7 +286,7 @@ void should_only_register_blacklist_when_logout_without_refresh_token() { void should_not_delete_session_when_logout_with_invalid_refresh_token() { // given given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); // when authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); @@ -284,7 +299,7 @@ void should_not_delete_session_when_logout_with_invalid_refresh_token() { void should_not_delete_session_when_logout_with_other_users_refresh_token() { // given given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(2L); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(new RefreshTokenClaims(2L, SESSION_ID)); // when authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); From 2bc7e03c061ed5435741c3a39e2aed84fd6dc564 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 27 Sep 2026 20:47:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[fix]=20Refresh=20Token=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=9E=AC=EB=B0=9C=EA=B8=89=C2=B7=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20secure=20=EC=84=A4=EC=A0=95=EA=B0=92=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthController가 secure(true)를 하드코딩해 로컬(http)에서 refresh 후 쿠키가 Secure로 재발급되던 문제를 OAuth2SuccessHandler와 같이 app.cookie.secure 설정을 따르도록 수정 Co-Authored-By: Claude Opus 5.5 --- .../auth/controller/AuthController.java | 7 ++-- .../auth/controller/AuthControllerTest.java | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) 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 0ef1815f..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 = "소셜 로그인 페이지로 리다이렉트") @@ -138,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") @@ -149,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/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java index 4134daa0..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,6 +1,7 @@ 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; @@ -17,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; @@ -39,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) @@ -217,4 +220,33 @@ void should_return_200_when_logout_without_refresh_token_cookie() throws Excepti .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"))); + } }