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 86a225a26..764554ca1 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 92a2f4d11..89edc34ef 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/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 15e3985c2..5ccdbead3 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/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java index c6bb9dc83..86cc1ddfa 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; @@ -13,6 +15,21 @@ 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; @@ -26,13 +43,24 @@ 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); } + + /** + * 해당 토큰이 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/auth/TokenVersionRepository.java b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java new file mode 100644 index 000000000..a5736d2db --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java @@ -0,0 +1,46 @@ +package umc.cockple.demo.global.auth; + +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; + +@Repository +@RequiredArgsConstructor +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 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) { + 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/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index 020aeabc0..4d6955e79 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; @@ -25,9 +23,13 @@ @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; - private final MemberRepository memberRepository; @PostConstruct public void init() { @@ -35,19 +37,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(), TOKEN_TYPE_ACCESS); } - 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(), TOKEN_TYPE_REFRESH); } - 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, TOKEN_TYPE_ACCESS); } - private String createToken(Long memberId, String nickname, 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) { @@ -55,6 +57,8 @@ private String createToken(Long memberId, String nickname, long validity) { } claims.put("nickname", nickname); + claims.put("ver", tokenVersion); + claims.put(CLAIM_TYPE, type); Date now = new Date(); Date expiration = new Date(now.getTime() + validity); @@ -108,21 +112,44 @@ 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(); + } + + 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) .build() .parseClaimsJws(token) .getBody(); - - return Long.valueOf(claims.getSubject()); } 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/jwt/properties/JwtProperties.java b/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java index 13a906fb3..ddf0560e9 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 f3595a258..56c3148f5 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; @@ -10,6 +11,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; @@ -21,12 +23,14 @@ @Service @RequiredArgsConstructor +@Slf4j public class KakaoOauthService { private final KakaoClient kakaoClient; private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + private final TokenVersionRepository tokenVersionRepository; @Transactional public KakaoLoginResponseDTO signup(String code) { @@ -53,9 +57,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 +89,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 +115,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()); @@ -129,9 +136,15 @@ 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.consumeAndMark(refreshToken); + + // 활성 저장소에 없는 경우 - 재사용(탈취) 여부를 판별해 대응한 뒤 거부 + if (memberIdOpt.isEmpty()) { + detectAndHandleReuse(refreshToken); + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + Long memberId = memberIdOpt.get(); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -141,13 +154,37 @@ public TokenRefreshResponse validateMember(String refreshToken) { throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); } - // 액세스 토큰 재발급 - String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + // 토큰 버전 검증 - 강제 무효화(탈퇴/탈취 대응 등)된 리프레시 토큰 차단 + 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 저장 - 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); } + + 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; + } + // 재사용(탈취) 확정 + Long memberId = jwtTokenProvider.getUserId(refreshToken); + long newVersion = tokenVersionRepository.increment(memberId); + log.warn("리프레시 토큰 재사용 감지 - 회원 {} 의 모든 토큰을 무효화합니다. (tokenVersion={})", memberId, newVersion); + } } 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 f58ac28fa..c5b7faf79 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 @@ -12,11 +12,8 @@ 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.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; @@ -30,7 +27,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { public static final String MEMBER_ID = "memberId"; private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; private final RestAuthenticationEntryPoint restEntryPoint; @Override @@ -51,15 +47,13 @@ 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)); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); - } Authentication auth = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 566ea401b..ef55b172f 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 async: # 실시간 채팅 풀 (저지연 내부 I/O) 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 000000000..05fae20be --- /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; 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 9c2453ac1..3ac62b18e 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; 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 000000000..abbb9486a --- /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/auth/TokenVersionRepositoryDbFallbackTest.java b/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java new file mode 100644 index 000000000..648333ede --- /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 000000000..8734b1ef9 --- /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 new file mode 100644 index 000000000..ac743a3ef --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -0,0 +1,179 @@ +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.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); + 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).consumeAndMark(RT); + verify(refreshTokenRepository).save("newRefresh", MEMBER_ID); + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } + + @Nested + @DisplayName("활성 저장소에 없는 토큰") + class TokenNotInStore { + + @Test + @DisplayName("서명 유효 + grace 경과 → 재사용으로 판정하고 전체 토큰을 무효화한다") + void 재사용_확정() { + // given + 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); + 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.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(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.consumeAndMark(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()); + } + + @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 2c30969c8..1d8711922 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,18 +10,17 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; -import java.util.Optional; +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) @@ -30,9 +29,6 @@ class JwtAuthenticationFilterMdcTest { @Mock private JwtTokenProvider jwtTokenProvider; - @Mock - private MemberRepository memberRepository; - @Mock private RestAuthenticationEntryPoint restEntryPoint; @@ -44,21 +40,15 @@ void tearDown() { @Test @DisplayName("인증 성공 시 요청 처리 동안 memberId를 MDC에 넣고 종료 후 정리한다") void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); request.addHeader("Authorization", "Bearer token"); MockHttpServletResponse response = new MockHttpServletResponse(); - Member member = Member.builder() - .id(7L) - .nickname("member7") - .isActive(MemberStatus.ACTIVE) - .socialId(700L) - .build(); AtomicReference memberIdInChain = new AtomicReference<>(); given(jwtTokenProvider.validateToken("token")).willReturn(true); + given(jwtTokenProvider.isAccessToken("token")).willReturn(true); given(jwtTokenProvider.getUserId("token")).willReturn(7L); - given(memberRepository.findById(7L)).willReturn(Optional.of(member)); given(jwtTokenProvider.getAuthentication("token")) .willReturn(new UsernamePasswordAuthenticationToken("member7", "")); @@ -73,7 +63,7 @@ void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception @Test @DisplayName("토큰이 없는 요청은 이전 memberId MDC를 제거한 상태로 통과시키고 종료 후에도 정리한다") void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicReference memberIdInChain = new AtomicReference<>(); @@ -85,13 +75,13 @@ void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { assertThat(memberIdInChain.get()).isNull(); assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + verifyNoInteractions(jwtTokenProvider, restEntryPoint); } @Test @DisplayName("토큰이 없는 요청의 downstream 예외는 인증 실패로 변환하지 않고 전파한다") void propagateDownstreamExceptionWhenRequestHasNoToken() { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, 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"); @@ -103,6 +93,25 @@ void propagateDownstreamExceptionWhenRequestHasNoToken() { .hasMessage("downstream failure"); assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + 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()); } } diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index c56f08c72..07569887b 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: