diff --git a/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java index ad91fddb..a77202a6 100644 --- a/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java +++ b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java @@ -8,7 +8,9 @@ @AllArgsConstructor public enum AuthRedisKey implements RedisKeys { EMAIL_CODE("auth:email:code:%s", VerificationConstants.CODE_TTL_MINUTES * 60), - EMAIL_VERIFIED("auth:email:verified:%s", 600); + EMAIL_VERIFIED("auth:email:verified:%s", 600), + TOKEN_VERSION("auth:token:version:%d", 3600), + ; private final String pattern; private final int ttlSeconds; diff --git a/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java b/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java new file mode 100644 index 00000000..13dc1241 --- /dev/null +++ b/src/main/java/project/flipnote/auth/repository/TokenVersionRedisRepository.java @@ -0,0 +1,37 @@ +package project.flipnote.auth.repository; + +import java.time.Duration; +import java.util.Optional; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; +import project.flipnote.auth.constants.AuthRedisKey; + +@RequiredArgsConstructor +@Repository +public class TokenVersionRedisRepository { + + private final RedisTemplate tokenVersionRedisTemplate; + + public void saveTokenVersion(long userId, long tokenVersion) { + String key = AuthRedisKey.TOKEN_VERSION.key(userId); + Duration ttl = AuthRedisKey.TOKEN_VERSION.getTtl(); + + tokenVersionRedisTemplate.opsForValue().set(key, tokenVersion, ttl); + } + + public Optional getTokenVersion(long userId) { + String key = AuthRedisKey.TOKEN_VERSION.key(userId); + Long value = tokenVersionRedisTemplate.opsForValue().get(key); + + return Optional.ofNullable(value); + } + + public void deleteTokenVersion(long userId) { + String key = AuthRedisKey.TOKEN_VERSION.key(userId); + + tokenVersionRedisTemplate.delete(key); + } +} diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index 790011ea..6b4b7cbf 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -41,7 +41,7 @@ public TokenPair login(UserLoginRequest req) { validatePasswordMatch(req.password(), user.getPassword()); - return jwtComponent.generateTokenPair(user.getEmail(), user.getId(), user.getRole().name()); + return jwtComponent.generateTokenPair(user); } public void sendEmailVerificationCode(EmailVerificationRequest req) { diff --git a/src/main/java/project/flipnote/auth/service/TokenVersionService.java b/src/main/java/project/flipnote/auth/service/TokenVersionService.java new file mode 100644 index 00000000..c89aa613 --- /dev/null +++ b/src/main/java/project/flipnote/auth/service/TokenVersionService.java @@ -0,0 +1,28 @@ +package project.flipnote.auth.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import project.flipnote.auth.repository.TokenVersionRedisRepository; +import project.flipnote.user.repository.UserRepository; + +@RequiredArgsConstructor +@Service +public class TokenVersionService { + + private final TokenVersionRedisRepository tokenVersionRedisRepository; + private final UserRepository userRepository; + + public Optional findTokenVersion(long userId) { + return tokenVersionRedisRepository.getTokenVersion(userId) + .or(() -> { + Optional dbTokenVersion = userRepository.findTokenVersionById(userId); + dbTokenVersion.ifPresent( + tokenVersion -> tokenVersionRedisRepository.saveTokenVersion(userId, tokenVersion) + ); + return dbTokenVersion; + }); + } +} diff --git a/src/main/java/project/flipnote/common/config/RedisConfig.java b/src/main/java/project/flipnote/common/config/RedisConfig.java index ea4e10dc..8b148200 100644 --- a/src/main/java/project/flipnote/common/config/RedisConfig.java +++ b/src/main/java/project/flipnote/common/config/RedisConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -17,4 +18,13 @@ public RedisTemplate emailRedisTemplate(RedisConnectionFactory c template.setValueSerializer(new StringRedisSerializer()); return template; } + + @Bean + public RedisTemplate tokenVersionRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericToStringSerializer<>(Long.class)); + return template; + } } diff --git a/src/main/java/project/flipnote/common/security/dto/UserAuth.java b/src/main/java/project/flipnote/common/security/dto/UserAuth.java index 5ba7022f..5308a961 100644 --- a/src/main/java/project/flipnote/common/security/dto/UserAuth.java +++ b/src/main/java/project/flipnote/common/security/dto/UserAuth.java @@ -11,7 +11,8 @@ public record UserAuth( Long userId, String email, - UserRole userRole + UserRole userRole, + long tokenVersion ) { public Collection getAuthorities() { diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java index 91e486d6..d966861c 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java @@ -13,9 +13,11 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import project.flipnote.auth.model.TokenPair; +import project.flipnote.auth.service.TokenVersionService; import project.flipnote.common.security.dto.UserAuth; import project.flipnote.common.security.exception.SecurityErrorCode; import project.flipnote.common.security.exception.SecurityException; +import project.flipnote.user.entity.User; import project.flipnote.user.entity.UserRole; @RequiredArgsConstructor @@ -23,6 +25,7 @@ public class JwtComponent { private final JwtProperties jwtProperties; + private final TokenVersionService tokenVersionService; private SecretKey secretKey; @PostConstruct @@ -30,35 +33,33 @@ public void init() { this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes()); } - public TokenPair generateTokenPair(String email, Long userId, String role) { - return TokenPair.from(generateAccessToken(email, userId, role), generateRefreshToken(email, userId, role)); + public TokenPair generateTokenPair(User user) { + String accessToken = generateAccessToken(user); + String refreshToken = generateRefreshToken(user); + return TokenPair.from(accessToken, refreshToken); } - private String generateAccessToken(String email, Long userId, String role) { + private String generateAccessToken(User user) { return generateToken( - email, - userId, - role, + user, jwtProperties.getAccessTokenExpiredDate(new Date()) ); } - private String generateRefreshToken(String email, Long userId, String role) { + private String generateRefreshToken(User user) { return generateToken( - email, - userId, - role, + user, jwtProperties.getRefreshTokenExpiredDate(new Date()) ); } - private String generateToken(String email, Long userId, String role, Date expiration) { + private String generateToken(User user, Date expiration) { Date now = new Date(); return Jwts.builder() - .subject(email) - .id(userId.toString()) - .claim(JwtConstants.ROLE, role) + .subject(user.getEmail()) + .id(String.valueOf(user.getId())) + .claim(JwtConstants.ROLE, user.getRole().name()) .issuedAt(now) .expiration(expiration) .signWith(secretKey, Jwts.SIG.HS256) @@ -67,8 +68,10 @@ private String generateToken(String email, Long userId, String role, Date expira public UserAuth extractUserAuthFromToken(String token) { Claims claims = parseClaims(token); + UserAuth userAuth = extractUserAuthFromClaims(claims); + validateToken(userAuth); - return extractUserAuthFromClaims(claims); + return userAuth; } private Claims parseClaims(String token) { @@ -85,13 +88,23 @@ private Claims parseClaims(String token) { } } + private void validateToken(UserAuth userAuth) { + long currentTokenVersion = tokenVersionService.findTokenVersion(userAuth.userId()) + .orElseThrow(() -> new SecurityException(SecurityErrorCode.NOT_VALID_JWT_TOKEN)); + + if (userAuth.tokenVersion() != currentTokenVersion) { + throw new SecurityException(SecurityErrorCode.NOT_VALID_JWT_TOKEN); + } + } + private UserAuth extractUserAuthFromClaims(Claims claims) { long userId = Long.parseLong(claims.getId()); UserRole userRole = UserRole.from( claims.get(JwtConstants.ROLE, String.class) ); String email = claims.getSubject(); + long tokenVersion = claims.get(JwtConstants.TOKEN_VERSION, Long.class); - return new UserAuth(userId, email, userRole); + return new UserAuth(userId, email, userRole, tokenVersion); } } diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java b/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java index 0ce6a9a6..9158223f 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtConstants.java @@ -9,6 +9,7 @@ public final class JwtConstants { public static final String REFRESH_TOKEN = "refreshToken"; public static final String ROLE = "role"; + public static final String TOKEN_VERSION = "token_version"; public static final String AUTH_HEADER = "Authorization"; public static final String TOKEN_PREFIX = "Bearer "; diff --git a/src/main/java/project/flipnote/user/entity/User.java b/src/main/java/project/flipnote/user/entity/User.java index 0256974c..2fb95cc7 100644 --- a/src/main/java/project/flipnote/user/entity/User.java +++ b/src/main/java/project/flipnote/user/entity/User.java @@ -55,6 +55,9 @@ public class User extends SoftDeletableEntity { @Column(nullable = false) private UserRole role; + @Column(nullable = false) + private long tokenVersion; + @Builder public User( String email, @@ -74,11 +77,18 @@ public User( this.smsAgree = smsAgree; this.status = UserStatus.ACTIVE; this.role = UserRole.USER; + this.tokenVersion = 0L; } - @Override - public void softDelete() { + public void unregister() { super.softDelete(); + this.status = UserStatus.INACTIVE; + + increaseTokenVersion(); + } + + public void increaseTokenVersion() { + this.tokenVersion++; } } diff --git a/src/main/java/project/flipnote/user/repository/UserRepository.java b/src/main/java/project/flipnote/user/repository/UserRepository.java index 007c48cb..66d8b02b 100644 --- a/src/main/java/project/flipnote/user/repository/UserRepository.java +++ b/src/main/java/project/flipnote/user/repository/UserRepository.java @@ -3,6 +3,8 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import project.flipnote.user.entity.User; import project.flipnote.user.entity.UserStatus; @@ -16,4 +18,7 @@ public interface UserRepository extends JpaRepository { Optional findByEmailAndStatus(String email, UserStatus status); Optional findByIdAndStatus(Long userId, UserStatus status); + + @Query("SELECT u.tokenVersion FROM User u WHERE u.id = :userId") + Optional findTokenVersionById(@Param("userId") Long userId); } diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index 85484f8a..9f1b63fd 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -7,6 +7,7 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import project.flipnote.auth.repository.TokenVersionRedisRepository; import project.flipnote.auth.service.AuthService; import project.flipnote.common.exception.BizException; import project.flipnote.user.entity.User; @@ -24,6 +25,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final AuthService authService; + private final TokenVersionRedisRepository tokenVersionRedisRepository; @Transactional public UserRegisterResponse register(UserRegisterRequest req) { @@ -55,7 +57,8 @@ public UserRegisterResponse register(UserRegisterRequest req) { public void unregister(Long userId) { User user = findActiveUserById(userId); - user.softDelete(); + user.unregister(); + tokenVersionRedisRepository.deleteTokenVersion(userId); } private User findActiveUserById(Long userId) { diff --git a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java index 77fb0eeb..7fce13ce 100644 --- a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java +++ b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java @@ -178,8 +178,7 @@ void success() { .willReturn(Optional.of(foundUser)); given(passwordEncoder.matches(req.password(), foundUser.getPassword())) .willReturn(true); - given(jwtComponent.generateTokenPair(foundUser.getEmail(), foundUser.getId(), foundUser.getRole().name())) - .willReturn(expectedTokenPair); + given(jwtComponent.generateTokenPair(foundUser)).willReturn(expectedTokenPair); TokenPair resultTokenPair = authService.login(req); @@ -189,7 +188,7 @@ void success() { verify(userRepository).findByEmailAndStatus(anyString(), any(UserStatus.class)); verify(passwordEncoder).matches(anyString(), anyString()); - verify(jwtComponent).generateTokenPair(anyString(), anyLong(), anyString()); + verify(jwtComponent).generateTokenPair(any(User.class)); } @Test @@ -209,7 +208,7 @@ void fail_invalidCredentials_wrongEmail() { assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); verify(passwordEncoder, never()).matches(anyString(), anyString()); - verify(jwtComponent, never()).generateTokenPair(anyString(), anyLong(), anyString()); + verify(jwtComponent, never()).generateTokenPair(any(User.class)); } @Test @@ -232,7 +231,7 @@ void fail_invalidCredentials_wrongPassword() { assertThat(exception).isNotNull(); assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); - verify(jwtComponent, never()).generateTokenPair(anyString(), anyLong(), anyString()); + verify(jwtComponent, never()).generateTokenPair(any(User.class)); } } diff --git a/src/test/java/project/flipnote/user/service/UserServiceTest.java b/src/test/java/project/flipnote/user/service/UserServiceTest.java index 6b5cb480..d64ece9c 100644 --- a/src/test/java/project/flipnote/user/service/UserServiceTest.java +++ b/src/test/java/project/flipnote/user/service/UserServiceTest.java @@ -15,9 +15,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; import project.flipnote.auth.exception.AuthErrorCode; +import project.flipnote.auth.repository.TokenVersionRedisRepository; import project.flipnote.auth.service.AuthService; import project.flipnote.common.exception.BizException; import project.flipnote.fixture.UserFixture; @@ -44,6 +44,9 @@ class UserServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private TokenVersionRedisRepository tokenVersionRedisRepository; + @DisplayName("회원가입 테스트") @Nested class Register { @@ -157,6 +160,7 @@ void success() { assertThat(user.getDeletedAt()).isNotNull(); verify(user, times(1)).softDelete(); + verify(tokenVersionRedisRepository, times(1)).deleteTokenVersion(anyLong()); } @DisplayName("회원 id가 존재하지 않는 경우 예외 발생")