Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Contest> contests = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -50,4 +51,13 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m
""")
Optional<Member> 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<Long> findTokenVersionById(@Param("memberId") Long memberId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +42,7 @@ public class MemberCommandService {

private final KakaoOauthService kakaoOauthService;
private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService;
private final TokenVersionRepository tokenVersionRepository;


// ==================== 회원 관련 ===================
Expand Down Expand Up @@ -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()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> 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;
Expand All @@ -26,13 +43,24 @@ public void save(String refreshToken, Long memberId) {
);
}

public Optional<Long> 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<Long> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,36 +23,42 @@
@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() {
byte[] keyBytes = Decoders.BASE64URL.decode(jwtProperties.getSecret());
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) {
throw new MemberException(MemberErrorCode.NICKNAME_IS_NULL);
}

claims.put("nickname", nickname);
claims.put("ver", tokenVersion);
Comment thread
kanghana1 marked this conversation as resolved.
claims.put(CLAIM_TYPE, type);

Date now = new Date();
Date expiration = new Date(now.getTime() + validity);
Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ public class JwtProperties {
private String secret;
private long accessTokenValidity;
private long refreshTokenValidity;
private long refreshTokenReuseGrace;
}
Loading
Loading