From ef8fe76d238ffd68365fbc3861298bd32baece04 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sun, 5 Jul 2026 01:08:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20tokenVersion=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/TokenVersionRepository.java | 28 ++++++++++++++++ .../global/jwt/domain/JwtTokenProvider.java | 32 +++++++++++++------ .../oauth2/service/KakaoOauthService.java | 26 +++++++++------ 3 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java diff --git a/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java new file mode 100644 index 00000000..a4359c10 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java @@ -0,0 +1,28 @@ +package umc.cockple.demo.global.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +/** + * 회원별 토큰 버전(tokenVersion)을 관리하는 저장소 + * 키가 존재하지 않으면 버전은 0으로 간주 (신규 회원은 별도 초기화 없이 0에서 시작) + */ +@Repository +@RequiredArgsConstructor +public class TokenVersionRepository { + + private static final String KEY_PREFIX = "member:tokenVersion:"; + + private final StringRedisTemplate stringRedisTemplate; + + public long getVersion(Long memberId) { + String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + memberId); + return value == null ? 0L : Long.parseLong(value); + } + + public long increment(Long memberId) { + Long newVersion = stringRedisTemplate.opsForValue().increment(KEY_PREFIX + memberId); + return newVersion == null ? 0L : newVersion; + } +} diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index 020aeabc..91cefae6 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -35,19 +35,19 @@ public void init() { this.key = Keys.hmacShaKeyFor(keyBytes); } - public String createAccessToken(Long memberId, String nickname) { - return createToken(memberId, nickname, jwtProperties.getAccessTokenValidity()); + public String createAccessToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, jwtProperties.getAccessTokenValidity()); } - public String createRefreshToken(Long memberId, String nickname) { - return createToken(memberId, nickname, jwtProperties.getRefreshTokenValidity()); + public String createRefreshToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, jwtProperties.getRefreshTokenValidity()); } - public String createDevToken(Long memberId, String nickname) { - return createToken(memberId, nickname, 1209600000L * 2); + public String createDevToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, 1209600000L * 2); } - private String createToken(Long memberId, String nickname, long validity) { + private String createToken(Long memberId, String nickname, long tokenVersion, long validity) { Claims claims = Jwts.claims().setSubject(String.valueOf(memberId)); if (nickname == null) { @@ -55,6 +55,7 @@ private String createToken(Long memberId, String nickname, long validity) { } claims.put("nickname", nickname); + claims.put("ver", tokenVersion); Date now = new Date(); Date expiration = new Date(now.getTime() + validity); @@ -108,13 +109,24 @@ public boolean validateToken(String token) { public Long getUserId(String token) { - Claims claims = Jwts.parserBuilder() + return Long.valueOf(parseClaims(token).getSubject()); + } + + public String getNickname(String token) { + return parseClaims(token).get("nickname", String.class); + } + + public long getTokenVersion(String token) { + Object ver = parseClaims(token).get("ver"); + return ver == null ? 0L : ((Number) ver).longValue(); + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); - - return Long.valueOf(claims.getSubject()); } public Authentication getAuthentication(String token) { diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index f3595a25..2e5d7c41 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -10,6 +10,7 @@ import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; import umc.cockple.demo.global.oauth2.domain.KakaoClient; @@ -27,6 +28,7 @@ public class KakaoOauthService { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + private final TokenVersionRepository tokenVersionRepository; @Transactional public KakaoLoginResponseDTO signup(String code) { @@ -53,9 +55,10 @@ public KakaoLoginResponseDTO signup(String code) { newMember = true; } - // 4. jwt 발급 - String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + // 4. jwt 발급 (현재 tokenVersion 주입) + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname(), tokenVersion); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // 5. refresh는 redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -84,10 +87,11 @@ public KakaoLoginResponseDTO createDevToken() { .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken Redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -109,10 +113,11 @@ public KakaoLoginResponseDTO createOtherDevToken() { .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken Redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -141,11 +146,12 @@ public TokenRefreshResponse validateMember(String refreshToken) { throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); } - // 액세스 토큰 재발급 - String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + // 액세스 토큰 재발급 (현재 tokenVersion 주입) + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname(), tokenVersion); // 새 리프레시 토큰 발급 및 Redis 저장 - String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); refreshTokenRepository.save(newRefreshToken, member.getId()); return new TokenRefreshResponse(newAccessToken, newRefreshToken); From 2811ed8d135eb4232828a7fa1ceda590767f2ef7 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sun, 5 Jul 2026 01:30:28 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20tokenVersion=20=EA=B2=80=EC=A6=9D=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20DB=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberCommandService.java | 8 +++++++- .../demo/global/jwt/domain/JwtTokenProvider.java | 11 ++++------- .../global/oauth2/service/KakaoOauthService.java | 7 ++++++- .../security/filter/JwtAuthenticationFilter.java | 15 +++++---------- .../member/service/MemberCommandServiceTest.java | 2 ++ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 15e3985c..5ccdbead 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -17,6 +17,7 @@ import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; +import umc.cockple.demo.global.auth.TokenVersionRepository; import java.time.LocalDate; import java.time.LocalTime; @@ -41,6 +42,7 @@ public class MemberCommandService { private final KakaoOauthService kakaoOauthService; private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + private final TokenVersionRepository tokenVersionRepository; // ==================== 회원 관련 =================== @@ -93,8 +95,12 @@ public void withdrawMember(Long memberId) { // 카카오 연결 끊기 kakaoOauthService.unlinkAccess(member); - // 활성화 여부 해제, 리프레시 토큰 삭제 + // 활성화 여부 해제 member.withdraw(); + + // 토큰 버전 증가 - 발급된 모든 토큰(AT/RT)을 즉시 무효화 + tokenVersionRepository.increment(member.getId()); + applicationEventPublisher.publishEvent(MemberWithdrawnEvent.withdrawn(member.getId())); } diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index 91cefae6..65dfc0f2 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -10,10 +10,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; -import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.jwt.properties.JwtProperties; import umc.cockple.demo.global.security.domain.CustomUserDetails; @@ -27,7 +25,6 @@ public class JwtTokenProvider { private Key key; private final JwtProperties jwtProperties; - private final MemberRepository memberRepository; @PostConstruct public void init() { @@ -130,11 +127,11 @@ private Claims parseClaims(String token) { } public Authentication getAuthentication(String token) { - Long memberId = getUserId(token); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Claims claims = parseClaims(token); + Long memberId = Long.valueOf(claims.getSubject()); + String nickname = claims.get("nickname", String.class); - UserDetails userDetails = new CustomUserDetails(member.getId(), member.getNickname()); + UserDetails userDetails = new CustomUserDetails(memberId, nickname); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } } diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 2e5d7c41..51a12826 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -146,8 +146,13 @@ public TokenRefreshResponse validateMember(String refreshToken) { throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); } - // 액세스 토큰 재발급 (현재 tokenVersion 주입) + // 토큰 버전 검증 - 강제 무효화(탈퇴/탈취 대응 등)된 리프레시 토큰 차단 long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + if (jwtTokenProvider.getTokenVersion(refreshToken) != tokenVersion) { + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + // 액세스 토큰 재발급 (현재 tokenVersion 주입) String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname(), tokenVersion); // 새 리프레시 토큰 발급 및 Redis 저장 diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 6fb22a18..379ea418 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -11,11 +11,9 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; @@ -27,7 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; + private final TokenVersionRepository tokenVersionRepository; private final RestAuthenticationEntryPoint restEntryPoint; @Override @@ -48,14 +46,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throw new MemberException(MemberErrorCode.INVALID_TOKEN); } - Long memberId = jwtTokenProvider.getUserId(token); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); + long currentVersion = tokenVersionRepository.getVersion(memberId); + if (jwtTokenProvider.getTokenVersion(token) != currentVersion) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); } Authentication auth = jwtTokenProvider.getAuthentication(token); diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index 9c2453ac..3ac62b18 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -27,6 +27,7 @@ import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.events.MemberWithdrawnEvent; import umc.cockple.demo.domain.member.repository.*; +import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Keyword; import umc.cockple.demo.global.enums.Level; @@ -66,6 +67,7 @@ class MemberCommandServiceTest { @Mock private ApplicationEventPublisher applicationEventPublisher; @Mock private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; @Mock private KakaoOauthService kakaoOauthService; + @Mock private TokenVersionRepository tokenVersionRepository; private Member normalMember; From 82834b7b17c73c6bdae43f24b51c9d15ce507ad7 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sun, 5 Jul 2026 01:49:28 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/RefreshTokenRepository.java | 21 +++ .../global/jwt/properties/JwtProperties.java | 1 + .../oauth2/service/KakaoOauthService.java | 35 ++++- src/main/resources/application.yml | 1 + .../oauth2/service/KakaoOauthServiceTest.java | 133 ++++++++++++++++++ .../resources/application-integrationtest.yml | 1 + 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java diff --git a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java index c6bb9dc8..f29bc0ce 100644 --- a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java +++ b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java @@ -13,6 +13,7 @@ public class RefreshTokenRepository { private static final String KEY_PREFIX = "refresh:"; + private static final String CONSUMED_PREFIX = "refresh:consumed:"; private final StringRedisTemplate stringRedisTemplate; private final JwtProperties jwtProperties; @@ -35,4 +36,24 @@ public Optional findAndDeleteByToken(String refreshToken) { public void delete(String refreshToken) { stringRedisTemplate.delete(KEY_PREFIX + refreshToken); } + + /** + * 동시 재발급 경쟁을 재사용(탈취)이 아닌 정상 상황으로 식별하기 위한 마커 + */ + public void markConsumed(String refreshToken, Long memberId) { + stringRedisTemplate.opsForValue().set( + CONSUMED_PREFIX + refreshToken, + String.valueOf(memberId), + jwtProperties.getRefreshTokenReuseGrace(), + TimeUnit.MILLISECONDS + ); + } + + /** + * 해당 토큰이 grace window 이내에 정상 소비된 이력이 있는지 여부 + * true 이면 정상 경쟁/재시도, false 이면 grace 를 지난 재사용(탈취 의심) + */ + public boolean isRecentlyConsumed(String refreshToken) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(CONSUMED_PREFIX + refreshToken)); + } } diff --git a/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java b/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java index 13a906fb..ddf0560e 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java +++ b/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java @@ -13,4 +13,5 @@ public class JwtProperties { private String secret; private long accessTokenValidity; private long refreshTokenValidity; + private long refreshTokenReuseGrace; } diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 51a12826..69b3e913 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -1,6 +1,7 @@ package umc.cockple.demo.global.oauth2.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.member.domain.Member; @@ -22,6 +23,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class KakaoOauthService { private final KakaoClient kakaoClient; @@ -134,9 +136,18 @@ public KakaoLoginResponseDTO createOtherDevToken() { } public TokenRefreshResponse validateMember(String refreshToken) { - // Redis에서 memberId 조회 및 삭제 (GETDEL - 원자적 처리로 동시 요청 시 중복 발급 방지) - Long memberId = refreshTokenRepository.findAndDeleteByToken(refreshToken) - .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); + Optional memberIdOpt = refreshTokenRepository.findAndDeleteByToken(refreshToken); + + // 활성 저장소에 없는 경우 - 재사용(탈취) 여부를 판별해 대응한 뒤 거부 + if (memberIdOpt.isEmpty()) { + detectAndHandleReuse(refreshToken); + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + Long memberId = memberIdOpt.get(); + + // 정상 회전 - grace window 동안 소비 이력 기록 (동시 재발급 경쟁/재시도 오탐 방지) + refreshTokenRepository.markConsumed(refreshToken, memberId); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -161,4 +172,22 @@ public TokenRefreshResponse validateMember(String refreshToken) { return new TokenRefreshResponse(newAccessToken, newRefreshToken); } + + /** + * 활성 저장소에 없는 리프레시 토큰이 "재사용(탈취)"인지 판별하고, 맞다면 해당 회원의 모든 토큰을 무효화 + * 재사용 여부를 공격자에게 노출하지 않기 위해 호출부에서는 동일한 예외로 응답 + */ + private void detectAndHandleReuse(String refreshToken) { + if (!jwtTokenProvider.validateToken(refreshToken)) { + return; + } + // grace window 이내 정상 소비 이력 존재 → 동시 재발급 경쟁/재시도로 판단, 무효화 x + if (refreshTokenRepository.isRecentlyConsumed(refreshToken)) { + return; + } + // 재사용(탈취) 확정 + Long memberId = jwtTokenProvider.getUserId(refreshToken); + long newVersion = tokenVersionRepository.increment(memberId); + log.warn("리프레시 토큰 재사용 감지 - 회원 {} 의 모든 토큰을 무효화합니다. (tokenVersion={})", memberId, newVersion); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 258ef906..fc3446b9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -84,6 +84,7 @@ jwt: secret: ${JWT_SECRET_KEY} access-token-validity: 900000 refresh-token-validity: 1209600000 + refresh-token-reuse-grace: 30000 logging: level: diff --git a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java new file mode 100644 index 00000000..824bcec1 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -0,0 +1,133 @@ +package umc.cockple.demo.global.oauth2.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.auth.TokenVersionRepository; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; +import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; +import umc.cockple.demo.global.oauth2.domain.KakaoClient; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("KakaoOauthService.validateMember - 리프레시 토큰 재사용 탐지") +class KakaoOauthServiceTest { + + @InjectMocks + private KakaoOauthService kakaoOauthService; + + @Mock private KakaoClient kakaoClient; + @Mock private MemberRepository memberRepository; + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock private TokenVersionRepository tokenVersionRepository; + + private static final String RT = "refresh.token.value"; + private static final Long MEMBER_ID = 1L; + + @Nested + @DisplayName("정상 회전") + class NormalRotation { + + @Test + @DisplayName("활성 토큰이면 소비 이력을 남기고 새 토큰을 재발급한다") + void 정상_회전() { + // given + Member member = mock(Member.class); + given(member.getId()).willReturn(MEMBER_ID); + given(member.getNickname()).willReturn("와나"); + given(member.getIsActive()).willReturn(MemberStatus.ACTIVE); + + given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.of(MEMBER_ID)); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(tokenVersionRepository.getVersion(MEMBER_ID)).willReturn(0L); + given(jwtTokenProvider.getTokenVersion(RT)).willReturn(0L); + given(jwtTokenProvider.createAccessToken(eq(MEMBER_ID), any(), eq(0L))).willReturn("newAccess"); + given(jwtTokenProvider.createRefreshToken(eq(MEMBER_ID), any(), eq(0L))).willReturn("newRefresh"); + + // when + TokenRefreshResponse response = kakaoOauthService.validateMember(RT); + + // then + assertThat(response.accessToken()).isEqualTo("newAccess"); + assertThat(response.refreshToken()).isEqualTo("newRefresh"); + verify(refreshTokenRepository).markConsumed(RT, MEMBER_ID); + verify(refreshTokenRepository).save("newRefresh", MEMBER_ID); + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } + + @Nested + @DisplayName("활성 저장소에 없는 토큰") + class TokenNotInStore { + + @Test + @DisplayName("서명 유효 + grace 경과 → 재사용으로 판정하고 전체 토큰을 무효화한다") + void 재사용_확정() { + // given + given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(false); + given(jwtTokenProvider.getUserId(RT)).willReturn(MEMBER_ID); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository).increment(MEMBER_ID); + } + + @Test + @DisplayName("grace window 이내 소비 이력이 있으면(동시요청/재시도) 무효화하지 않는다") + void grace_이내_재시도() { + // given + given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(true); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository, never()).increment(anyLong()); + } + + @Test + @DisplayName("서명 무효/만료 토큰이면 무효화하지 않고 일반 거부한다") + void 만료_또는_무효_토큰() { + // given + given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(false); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } +} diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index c56f08c7..07569887 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -47,6 +47,7 @@ jwt: secret: dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0LWxvbmctZW5vdWdoLTI1Ng access-token-validity: 3600000 refresh-token-validity: 1209600000 + refresh-token-reuse-grace: 30000 logging: level: From 5e0679e0f9ba08fcf55fbb1b4d19d0ccfecc673f Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 6 Jul 2026 23:18:21 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20JWT=20claim=EC=97=90=20type=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 --- .../global/jwt/domain/JwtTokenProvider.java | 26 ++++++++++++++++--- .../oauth2/service/KakaoOauthService.java | 8 +++--- .../filter/JwtAuthenticationFilter.java | 5 ++++ .../oauth2/service/KakaoOauthServiceTest.java | 2 ++ .../JwtAuthenticationFilterMdcTest.java | 1 + 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index 65dfc0f2..4d6955e7 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -23,6 +23,11 @@ @RequiredArgsConstructor public class JwtTokenProvider { + /** 토큰 용도 구분 claim (access ↔ refresh 상호 오용 방지) */ + public static final String TOKEN_TYPE_ACCESS = "access"; + public static final String TOKEN_TYPE_REFRESH = "refresh"; + private static final String CLAIM_TYPE = "type"; + private Key key; private final JwtProperties jwtProperties; @@ -33,18 +38,18 @@ public void init() { } public String createAccessToken(Long memberId, String nickname, long tokenVersion) { - return createToken(memberId, nickname, tokenVersion, jwtProperties.getAccessTokenValidity()); + return createToken(memberId, nickname, tokenVersion, jwtProperties.getAccessTokenValidity(), TOKEN_TYPE_ACCESS); } public String createRefreshToken(Long memberId, String nickname, long tokenVersion) { - return createToken(memberId, nickname, tokenVersion, jwtProperties.getRefreshTokenValidity()); + return createToken(memberId, nickname, tokenVersion, jwtProperties.getRefreshTokenValidity(), TOKEN_TYPE_REFRESH); } public String createDevToken(Long memberId, String nickname, long tokenVersion) { - return createToken(memberId, nickname, tokenVersion, 1209600000L * 2); + return createToken(memberId, nickname, tokenVersion, 1209600000L * 2, TOKEN_TYPE_ACCESS); } - private String createToken(Long memberId, String nickname, long tokenVersion, long validity) { + private String createToken(Long memberId, String nickname, long tokenVersion, long validity, String type) { Claims claims = Jwts.claims().setSubject(String.valueOf(memberId)); if (nickname == null) { @@ -53,6 +58,7 @@ private String createToken(Long memberId, String nickname, long tokenVersion, lo claims.put("nickname", nickname); claims.put("ver", tokenVersion); + claims.put(CLAIM_TYPE, type); Date now = new Date(); Date expiration = new Date(now.getTime() + validity); @@ -118,6 +124,18 @@ public long getTokenVersion(String token) { return ver == null ? 0L : ((Number) ver).longValue(); } + public String getTokenType(String token) { + return parseClaims(token).get(CLAIM_TYPE, String.class); + } + + public boolean isAccessToken(String token) { + return TOKEN_TYPE_ACCESS.equals(getTokenType(token)); + } + + public boolean isRefreshToken(String token) { + return TOKEN_TYPE_REFRESH.equals(getTokenType(token)); + } + private Claims parseClaims(String token) { return Jwts.parserBuilder() .setSigningKey(key) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 69b3e913..1d557c1e 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -173,14 +173,14 @@ public TokenRefreshResponse validateMember(String refreshToken) { return new TokenRefreshResponse(newAccessToken, newRefreshToken); } - /** - * 활성 저장소에 없는 리프레시 토큰이 "재사용(탈취)"인지 판별하고, 맞다면 해당 회원의 모든 토큰을 무효화 - * 재사용 여부를 공격자에게 노출하지 않기 위해 호출부에서는 동일한 예외로 응답 - */ private void detectAndHandleReuse(String refreshToken) { if (!jwtTokenProvider.validateToken(refreshToken)) { return; } + // refresh 용도가 아닌 토큰(access를 재발급에 오용)은 탈취가 아니므로 무효화하지 않고 단순 거부 + if (!jwtTokenProvider.isRefreshToken(refreshToken)) { + return; + } // grace window 이내 정상 소비 이력 존재 → 동시 재발급 경쟁/재시도로 판단, 무효화 x if (refreshTokenRepository.isRecentlyConsumed(refreshToken)) { return; diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 0c0fc4c4..b3eef26c 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -49,6 +49,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throw new MemberException(MemberErrorCode.INVALID_TOKEN); } + // refresh 토큰을 일반 API 인증에 사용하는 것을 차단 + if (!jwtTokenProvider.isAccessToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } + Long memberId = jwtTokenProvider.getUserId(token); MDC.put(MEMBER_ID, String.valueOf(memberId)); diff --git a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java index 824bcec1..bc1aac20 100644 --- a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -88,6 +88,7 @@ class TokenNotInStore { // given given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(false); given(jwtTokenProvider.getUserId(RT)).willReturn(MEMBER_ID); @@ -105,6 +106,7 @@ class TokenNotInStore { // given given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(true); // when & then diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java index 2aeb1371..8130bc05 100644 --- a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -48,6 +48,7 @@ void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception AtomicReference memberIdInChain = new AtomicReference<>(); given(jwtTokenProvider.validateToken("token")).willReturn(true); + given(jwtTokenProvider.isAccessToken("token")).willReturn(true); given(jwtTokenProvider.getUserId("token")).willReturn(7L); given(tokenVersionRepository.getVersion(7L)).willReturn(0L); given(jwtTokenProvider.getTokenVersion("token")).willReturn(0L); From d6ffa96f64c65352a0fa6b3315ddf1aaa4575b58 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 6 Jul 2026 23:31:20 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20redis=EB=A5=BC=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/member/domain/Member.java | 4 +++ .../member/repository/MemberRepository.java | 10 ++++++ .../global/auth/TokenVersionRepository.java | 34 ++++++++++++++----- ....07.06.00.00__add_member_token_version.sql | 1 + 4 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index 86a225a2..764554ca 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -60,6 +60,10 @@ public class Member extends BaseEntity { private String fcmToken; + @ColumnDefault("0") + @Column(nullable = false) + private long tokenVersion; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) @Builder.Default private List contests = new ArrayList<>(); diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index 92a2f4d1..89edc34e 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; @@ -50,4 +51,13 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m """) Optional findMemberWithProfileById(@Param("memberId") Long memberId); + /** 토큰 버전 원자적 증가 (탈퇴/재사용 탐지 시 발급된 모든 토큰 무효화) */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Member m SET m.tokenVersion = m.tokenVersion + 1 WHERE m.id = :memberId") + void incrementTokenVersion(@Param("memberId") Long memberId); + + /** 토큰 버전만 조회 (Redis 캐시 miss 시 SoT fallback) */ + @Query("SELECT m.tokenVersion FROM Member m WHERE m.id = :memberId") + Optional findTokenVersionById(@Param("memberId") Long memberId); + } diff --git a/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java index a4359c10..a5736d2d 100644 --- a/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java +++ b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java @@ -3,11 +3,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.repository.MemberRepository; -/** - * 회원별 토큰 버전(tokenVersion)을 관리하는 저장소 - * 키가 존재하지 않으면 버전은 0으로 간주 (신규 회원은 별도 초기화 없이 0에서 시작) - */ @Repository @RequiredArgsConstructor public class TokenVersionRepository { @@ -15,14 +13,34 @@ public class TokenVersionRepository { private static final String KEY_PREFIX = "member:tokenVersion:"; private final StringRedisTemplate stringRedisTemplate; + private final MemberRepository memberRepository; + /** + * 현재 토큰 버전을 반환한다. + * 캐시 hit 시 Redis, miss 시 DB에서 읽어 재적재 + */ public long getVersion(Long memberId) { - String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + memberId); - return value == null ? 0L : Long.parseLong(value); + String cached = stringRedisTemplate.opsForValue().get(KEY_PREFIX + memberId); + if (cached != null) { + return Long.parseLong(cached); + } + long version = memberRepository.findTokenVersionById(memberId).orElse(0L); + cache(memberId, version); + return version; } + /** + * 토큰 버전을 1 증가시키고 새 값을 반환 + */ + @Transactional public long increment(Long memberId) { - Long newVersion = stringRedisTemplate.opsForValue().increment(KEY_PREFIX + memberId); - return newVersion == null ? 0L : newVersion; + memberRepository.incrementTokenVersion(memberId); + long newVersion = memberRepository.findTokenVersionById(memberId).orElse(0L); + cache(memberId, newVersion); + return newVersion; + } + + private void cache(Long memberId, long version) { + stringRedisTemplate.opsForValue().set(KEY_PREFIX + memberId, String.valueOf(version)); } } diff --git a/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql b/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql new file mode 100644 index 00000000..05fae20b --- /dev/null +++ b/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql @@ -0,0 +1 @@ +ALTER TABLE member ADD COLUMN token_version BIGINT NOT NULL DEFAULT 0; From a1d2851cb1a6bb87d4a7e594d907cd0844fde5e3 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 6 Jul 2026 23:44:52 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20AT=20=EB=AC=B4=ED=9A=A8=ED=99=94=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/filter/JwtAuthenticationFilter.java | 8 -------- .../filter/JwtAuthenticationFilterMdcTest.java | 16 +++++----------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index b3eef26c..c5b7faf7 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -14,7 +14,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; @@ -28,7 +27,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { public static final String MEMBER_ID = "memberId"; private final JwtTokenProvider jwtTokenProvider; - private final TokenVersionRepository tokenVersionRepository; private final RestAuthenticationEntryPoint restEntryPoint; @Override @@ -57,12 +55,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long memberId = jwtTokenProvider.getUserId(token); MDC.put(MEMBER_ID, String.valueOf(memberId)); - // 토큰 버전 검증 - 강제 무효화(탈퇴/탈취 대응 등)된 토큰 차단 (DB 조회 없이 Redis version만 확인) - long currentVersion = tokenVersionRepository.getVersion(memberId); - if (jwtTokenProvider.getTokenVersion(token) != currentVersion) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - Authentication auth = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java index 8130bc05..d3efae7d 100644 --- a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -10,7 +10,6 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; @@ -27,9 +26,6 @@ class JwtAuthenticationFilterMdcTest { @Mock private JwtTokenProvider jwtTokenProvider; - @Mock - private TokenVersionRepository tokenVersionRepository; - @Mock private RestAuthenticationEntryPoint restEntryPoint; @@ -41,7 +37,7 @@ void tearDown() { @Test @DisplayName("인증 성공 시 요청 처리 동안 memberId를 MDC에 넣고 종료 후 정리한다") void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, tokenVersionRepository, restEntryPoint); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); request.addHeader("Authorization", "Bearer token"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -50,8 +46,6 @@ void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception given(jwtTokenProvider.validateToken("token")).willReturn(true); given(jwtTokenProvider.isAccessToken("token")).willReturn(true); given(jwtTokenProvider.getUserId("token")).willReturn(7L); - given(tokenVersionRepository.getVersion(7L)).willReturn(0L); - given(jwtTokenProvider.getTokenVersion("token")).willReturn(0L); given(jwtTokenProvider.getAuthentication("token")) .willReturn(new UsernamePasswordAuthenticationToken("member7", "")); @@ -66,7 +60,7 @@ void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception @Test @DisplayName("토큰이 없는 요청은 이전 memberId MDC를 제거한 상태로 통과시키고 종료 후에도 정리한다") void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, tokenVersionRepository, restEntryPoint); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicReference memberIdInChain = new AtomicReference<>(); @@ -78,13 +72,13 @@ void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { assertThat(memberIdInChain.get()).isNull(); assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - verifyNoInteractions(jwtTokenProvider, tokenVersionRepository, restEntryPoint); + verifyNoInteractions(jwtTokenProvider, restEntryPoint); } @Test @DisplayName("토큰이 없는 요청의 downstream 예외는 인증 실패로 변환하지 않고 전파한다") void propagateDownstreamExceptionWhenRequestHasNoToken() { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, tokenVersionRepository, restEntryPoint); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); MockHttpServletResponse response = new MockHttpServletResponse(); MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); @@ -96,6 +90,6 @@ void propagateDownstreamExceptionWhenRequestHasNoToken() { .hasMessage("downstream failure"); assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - verifyNoInteractions(jwtTokenProvider, tokenVersionRepository, restEntryPoint); + verifyNoInteractions(jwtTokenProvider, restEntryPoint); } } From f703a1baf3ea227252bce02974846b67e612b10e Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 6 Jul 2026 23:52:12 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20lua=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=B4=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/RefreshTokenRepository.java | 39 +++++++++------- .../oauth2/service/KakaoOauthService.java | 5 +-- ...reshTokenRepositoryConsumeAndMarkTest.java | 44 +++++++++++++++++++ .../oauth2/service/KakaoOauthServiceTest.java | 10 ++--- 4 files changed, 73 insertions(+), 25 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java diff --git a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java index f29bc0ce..86cc1ddf 100644 --- a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java +++ b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java @@ -2,9 +2,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; import umc.cockple.demo.global.jwt.properties.JwtProperties; +import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -15,6 +17,20 @@ public class RefreshTokenRepository { private static final String KEY_PREFIX = "refresh:"; private static final String CONSUMED_PREFIX = "refresh:consumed:"; + /**. + * KEYS[1]=활성키, KEYS[2]=소비 마커키, ARGV[1]=grace TTL(ms) + * @return memberId 또는 nil. + */ + private static final RedisScript CONSUME_AND_MARK_SCRIPT = RedisScript.of(""" + local memberId = redis.call('GET', KEYS[1]) + if not memberId then + return nil + end + redis.call('DEL', KEYS[1]) + redis.call('SET', KEYS[2], memberId, 'PX', ARGV[1]) + return memberId + """, String.class); + private final StringRedisTemplate stringRedisTemplate; private final JwtProperties jwtProperties; @@ -27,28 +43,19 @@ public void save(String refreshToken, Long memberId) { ); } - public Optional findAndDeleteByToken(String refreshToken) { - String value = stringRedisTemplate.opsForValue().getAndDelete(KEY_PREFIX + refreshToken); - if (value == null) return Optional.empty(); - return Optional.of(Long.valueOf(value)); + public Optional consumeAndMark(String refreshToken) { + String memberId = stringRedisTemplate.execute( + CONSUME_AND_MARK_SCRIPT, + List.of(KEY_PREFIX + refreshToken, CONSUMED_PREFIX + refreshToken), + String.valueOf(jwtProperties.getRefreshTokenReuseGrace()) + ); + return memberId == null ? Optional.empty() : Optional.of(Long.valueOf(memberId)); } public void delete(String refreshToken) { stringRedisTemplate.delete(KEY_PREFIX + refreshToken); } - /** - * 동시 재발급 경쟁을 재사용(탈취)이 아닌 정상 상황으로 식별하기 위한 마커 - */ - public void markConsumed(String refreshToken, Long memberId) { - stringRedisTemplate.opsForValue().set( - CONSUMED_PREFIX + refreshToken, - String.valueOf(memberId), - jwtProperties.getRefreshTokenReuseGrace(), - TimeUnit.MILLISECONDS - ); - } - /** * 해당 토큰이 grace window 이내에 정상 소비된 이력이 있는지 여부 * true 이면 정상 경쟁/재시도, false 이면 grace 를 지난 재사용(탈취 의심) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 1d557c1e..56c3148f 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -136,7 +136,7 @@ public KakaoLoginResponseDTO createOtherDevToken() { } public TokenRefreshResponse validateMember(String refreshToken) { - Optional memberIdOpt = refreshTokenRepository.findAndDeleteByToken(refreshToken); + Optional memberIdOpt = refreshTokenRepository.consumeAndMark(refreshToken); // 활성 저장소에 없는 경우 - 재사용(탈취) 여부를 판별해 대응한 뒤 거부 if (memberIdOpt.isEmpty()) { @@ -146,9 +146,6 @@ public TokenRefreshResponse validateMember(String refreshToken) { Long memberId = memberIdOpt.get(); - // 정상 회전 - grace window 동안 소비 이력 기록 (동시 재발급 경쟁/재시도 오탐 방지) - refreshTokenRepository.markConsumed(refreshToken, memberId); - Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); diff --git a/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java b/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java new file mode 100644 index 00000000..abbb9486 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java @@ -0,0 +1,44 @@ +package umc.cockple.demo.global.auth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import umc.cockple.demo.support.IntegrationTestBase; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("RefreshTokenRepository.consumeAndMark - GETDEL+소비마커 원자 스크립트") +class RefreshTokenRepositoryConsumeAndMarkTest extends IntegrationTestBase { + + @Autowired + RefreshTokenRepository refreshTokenRepository; + + @Test + @DisplayName("활성 토큰을 소비하면 memberId 반환 + 활성키 삭제 + 소비 마커 기록을 원자적으로 수행한다") + void consumeActiveToken() { + String token = "rt-" + UUID.randomUUID(); + refreshTokenRepository.save(token, 42L); + + Optional result = refreshTokenRepository.consumeAndMark(token); + + assertThat(result).contains(42L); + // 활성 키가 삭제되어 두 번째 소비는 empty (GETDEL 의미) + assertThat(refreshTokenRepository.consumeAndMark(token)).isEmpty(); + // 삭제와 동시에 소비 마커가 원자적으로 기록됨 (동시 재발급 오탐 방지의 핵심) + assertThat(refreshTokenRepository.isRecentlyConsumed(token)).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 토큰이면 empty 반환 + 소비 마커도 남기지 않는다") + void consumeMissingToken() { + String token = "rt-" + UUID.randomUUID(); + + Optional result = refreshTokenRepository.consumeAndMark(token); + + assertThat(result).isEmpty(); + assertThat(refreshTokenRepository.isRecentlyConsumed(token)).isFalse(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java index bc1aac20..151ece79 100644 --- a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -59,7 +59,7 @@ class NormalRotation { given(member.getNickname()).willReturn("와나"); given(member.getIsActive()).willReturn(MemberStatus.ACTIVE); - given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.of(MEMBER_ID)); + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.of(MEMBER_ID)); given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); given(tokenVersionRepository.getVersion(MEMBER_ID)).willReturn(0L); given(jwtTokenProvider.getTokenVersion(RT)).willReturn(0L); @@ -72,7 +72,7 @@ class NormalRotation { // then assertThat(response.accessToken()).isEqualTo("newAccess"); assertThat(response.refreshToken()).isEqualTo("newRefresh"); - verify(refreshTokenRepository).markConsumed(RT, MEMBER_ID); + verify(refreshTokenRepository).consumeAndMark(RT); verify(refreshTokenRepository).save("newRefresh", MEMBER_ID); verify(tokenVersionRepository, never()).increment(anyLong()); } @@ -86,7 +86,7 @@ class TokenNotInStore { @DisplayName("서명 유효 + grace 경과 → 재사용으로 판정하고 전체 토큰을 무효화한다") void 재사용_확정() { // given - given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); given(jwtTokenProvider.validateToken(RT)).willReturn(true); given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(false); @@ -104,7 +104,7 @@ class TokenNotInStore { @DisplayName("grace window 이내 소비 이력이 있으면(동시요청/재시도) 무효화하지 않는다") void grace_이내_재시도() { // given - given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); given(jwtTokenProvider.validateToken(RT)).willReturn(true); given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(true); @@ -121,7 +121,7 @@ class TokenNotInStore { @DisplayName("서명 무효/만료 토큰이면 무효화하지 않고 일반 거부한다") void 만료_또는_무효_토큰() { // given - given(refreshTokenRepository.findAndDeleteByToken(RT)).willReturn(Optional.empty()); + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); given(jwtTokenProvider.validateToken(RT)).willReturn(false); // when & then From 7535106894b652d7b56af00d4d612b434c5e3086 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Tue, 7 Jul 2026 00:12:53 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenVersionRepositoryDbFallbackTest.java | 70 +++++++++++++++++++ .../jwt/domain/JwtTokenProviderTest.java | 55 +++++++++++++++ .../oauth2/service/KakaoOauthServiceTest.java | 44 ++++++++++++ .../JwtAuthenticationFilterMdcTest.java | 22 ++++++ 4 files changed, 191 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java create mode 100644 src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java diff --git a/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java b/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java new file mode 100644 index 00000000..648333ed --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java @@ -0,0 +1,70 @@ +package umc.cockple.demo.global.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TokenVersionRepository - DB(SoT) fallback + Redis 캐시") +class TokenVersionRepositoryDbFallbackTest extends IntegrationTestBase { + + private static final String KEY_PREFIX = "member:tokenVersion:"; + + @Autowired TokenVersionRepository tokenVersionRepository; + @Autowired MemberRepository memberRepository; + @Autowired StringRedisTemplate redis; + + private Long memberId; + + @AfterEach + void tearDown() { + if (memberId != null) { + redis.delete(KEY_PREFIX + memberId); + memberRepository.deleteById(memberId); + } + } + + @Test + @DisplayName("Redis 캐시 miss여도 DB의 token_version을 읽어 반환하고 캐시에 재적재한다") + void getVersionFallsBackToDbAndRepopulatesCache() { + Member member = memberRepository.save( + MemberFixture.createMember("버전회원", Gender.MALE, Level.A, 990001L)); + memberId = member.getId(); + + // 무효화 발생 -> DB=1, Redis=1 + tokenVersionRepository.increment(memberId); + // Redis 키가 eviction/유실된 상황 재현 + redis.delete(KEY_PREFIX + memberId); + + long version = tokenVersionRepository.getVersion(memberId); + + // 기존 코드였다면 miss=0으로 오판했을 값을 DB(SoT)에서 1로 복구 + assertThat(version).isEqualTo(1L); + // 캐시에 재적재됨 + assertThat(redis.opsForValue().get(KEY_PREFIX + memberId)).isEqualTo("1"); + } + + @Test + @DisplayName("increment는 DB를 원자적으로 올리고 캐시도 갱신한다") + void incrementUpdatesDbAndCache() { + Member member = memberRepository.save( + MemberFixture.createMember("증가회원", Gender.MALE, Level.A, 990002L)); + memberId = member.getId(); + + long v1 = tokenVersionRepository.increment(memberId); + long v2 = tokenVersionRepository.increment(memberId); + + assertThat(v1).isEqualTo(1L); + assertThat(v2).isEqualTo(2L); + assertThat(redis.opsForValue().get(KEY_PREFIX + memberId)).isEqualTo("2"); + } +} diff --git a/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java b/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java new file mode 100644 index 00000000..8734b1ef --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java @@ -0,0 +1,55 @@ +package umc.cockple.demo.global.jwt.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.global.jwt.properties.JwtProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("JwtTokenProvider - 토큰 type claim으로 access/refresh 구분") +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + JwtProperties props = new JwtProperties(); + props.setSecret("dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0LWxvbmctZW5vdWdoLTI1Ng"); + props.setAccessTokenValidity(900000); + props.setRefreshTokenValidity(1209600000); + jwtTokenProvider = new JwtTokenProvider(props); + jwtTokenProvider.init(); + } + + @Test + @DisplayName("access 토큰은 isAccessToken=true, isRefreshToken=false 이고 ver/subject를 담는다") + void accessTokenIsAccessType() { + String token = jwtTokenProvider.createAccessToken(1L, "nick", 3L); + + assertThat(jwtTokenProvider.getTokenType(token)).isEqualTo("access"); + assertThat(jwtTokenProvider.isAccessToken(token)).isTrue(); + assertThat(jwtTokenProvider.isRefreshToken(token)).isFalse(); + assertThat(jwtTokenProvider.getTokenVersion(token)).isEqualTo(3L); + assertThat(jwtTokenProvider.getUserId(token)).isEqualTo(1L); + } + + @Test + @DisplayName("refresh 토큰은 isRefreshToken=true, isAccessToken=false") + void refreshTokenIsRefreshType() { + String token = jwtTokenProvider.createRefreshToken(1L, "nick", 0L); + + assertThat(jwtTokenProvider.getTokenType(token)).isEqualTo("refresh"); + assertThat(jwtTokenProvider.isRefreshToken(token)).isTrue(); + assertThat(jwtTokenProvider.isAccessToken(token)).isFalse(); + } + + @Test + @DisplayName("dev 토큰은 access 용도로 발급된다") + void devTokenIsAccessType() { + String token = jwtTokenProvider.createDevToken(1L, "nick", 0L); + + assertThat(jwtTokenProvider.isAccessToken(token)).isTrue(); + assertThat(jwtTokenProvider.isRefreshToken(token)).isFalse(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java index 151ece79..ac743a3e 100644 --- a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -131,5 +131,49 @@ class TokenNotInStore { verify(tokenVersionRepository, never()).increment(anyLong()); } + + @Test + @DisplayName("access 토큰을 재발급에 오용하면(refresh 타입 아님) 무효화 없이 거부한다") + void access_토큰을_refresh로_오용() { + // given - 활성 저장소에 없음(access는 refresh 저장소에 저장된 적 없음) + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(false); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + // 탈취가 아니므로 전체 무효화하지 않는다 + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } + + @Nested + @DisplayName("토큰 버전 검증") + class TokenVersionCheck { + + @Test + @DisplayName("활성 토큰이어도 tokenVersion이 올라가 있으면(탈퇴/무효화된 예전 RT) 재발급을 거부한다") + void 버전_불일치_거부() { + // given + Member member = mock(Member.class); + given(member.getId()).willReturn(MEMBER_ID); + given(member.getIsActive()).willReturn(MemberStatus.ACTIVE); + + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.of(MEMBER_ID)); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(tokenVersionRepository.getVersion(MEMBER_ID)).willReturn(1L); // 현재 버전(무효화 후) + given(jwtTokenProvider.getTokenVersion(RT)).willReturn(0L); // 예전에 발급된 토큰 + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + // 새 토큰을 발급/저장하지 않는다 + verify(refreshTokenRepository, never()).save(any(), anyLong()); + } } } diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java index d3efae7d..1d871192 100644 --- a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -13,11 +13,14 @@ import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -92,4 +95,23 @@ void propagateDownstreamExceptionWhenRequestHasNoToken() { assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); verifyNoInteractions(jwtTokenProvider, restEntryPoint); } + + @Test + @DisplayName("refresh 토큰으로 일반 API에 접근하면 401로 거부하고 체인을 진행하지 않는다") + void rejectRefreshTokenOnApiRequest() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.addHeader("Authorization", "Bearer refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + given(jwtTokenProvider.validateToken("refresh")).willReturn(true); + given(jwtTokenProvider.isAccessToken("refresh")).willReturn(false); // refresh 타입 + + filter.doFilter(request, response, (req, res) -> chainCalled.set(true)); + + assertThat(chainCalled).isFalse(); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verify(restEntryPoint).commence(any(), any(), any()); + } }