diff --git a/src/main/java/com/dnd/moddo/auth/application/AuthService.java b/src/main/java/com/dnd/moddo/auth/application/AuthService.java index 54ca5dc..614a77c 100644 --- a/src/main/java/com/dnd/moddo/auth/application/AuthService.java +++ b/src/main/java/com/dnd/moddo/auth/application/AuthService.java @@ -30,6 +30,7 @@ public class AuthService { private final QueryUserService queryUserService; private final JwtProvider jwtProvider; private final KakaoClient kakaoClient; + private final RefreshTokenBlacklist refreshTokenBlacklist; @Transactional public TokenResponse loginWithGuest() { @@ -63,6 +64,11 @@ public TokenResponse loginOrRegisterWithKakao(String code, String state) { } public void logout(Long userId) { + logout(userId, null); + } + + public void logout(Long userId, String refreshToken) { + refreshTokenBlacklist.revoke(refreshToken); queryUserService.findKakaoIdById(userId).ifPresent(kakaoId -> { KakaoLogoutResponse logoutResponse = kakaoClient.logout(kakaoId); diff --git a/src/main/java/com/dnd/moddo/auth/application/RefreshTokenBlacklist.java b/src/main/java/com/dnd/moddo/auth/application/RefreshTokenBlacklist.java new file mode 100644 index 0000000..3d9f70a --- /dev/null +++ b/src/main/java/com/dnd/moddo/auth/application/RefreshTokenBlacklist.java @@ -0,0 +1,117 @@ +package com.dnd.moddo.auth.application; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import com.dnd.moddo.auth.infrastructure.security.JwtProvider; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenBlacklist { + private static final String KEY_PREFIX = "auth:refresh:blacklist:"; + + private final JwtProvider jwtProvider; + private final ObjectProvider> redisTemplateProvider; + private final Map localBlacklist = new ConcurrentHashMap<>(); + + public void revoke(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + return; + } + + try { + Claims claims = jwtProvider.parseClaims(refreshToken); + Date expiration = claims.getExpiration(); + if (expiration == null) { + return; + } + + Instant expiresAt = expiration.toInstant(); + Duration ttl = Duration.between(Instant.now(), expiresAt); + if (ttl.isNegative() || ttl.isZero()) { + return; + } + + String key = key(refreshToken); + localBlacklist.put(key, expiresAt); + saveToRedis(key, ttl); + } catch (Exception exception) { + log.warn("Failed to revoke refresh token", exception); + } + } + + public boolean isRevoked(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + return false; + } + + String key = key(refreshToken); + if (isRevokedInLocal(key)) { + return true; + } + + try { + RedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } catch (Exception exception) { + log.warn("Redis blacklist read failed for key={}", key, exception); + return false; + } + } + + private void saveToRedis(String key, Duration ttl) { + try { + RedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate != null) { + redisTemplate.opsForValue().set(key, true, ttl); + } + } catch (Exception exception) { + log.warn("Redis blacklist write failed for key={}", key, exception); + } + } + + private boolean isRevokedInLocal(String key) { + Instant expiresAt = localBlacklist.get(key); + if (expiresAt == null) { + return false; + } + if (Instant.now().isAfter(expiresAt)) { + localBlacklist.remove(key); + return false; + } + return true; + } + + private String key(String refreshToken) { + return KEY_PREFIX + hash(refreshToken); + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } +} diff --git a/src/main/java/com/dnd/moddo/auth/application/RefreshTokenService.java b/src/main/java/com/dnd/moddo/auth/application/RefreshTokenService.java index bb73feb..56e1cb2 100644 --- a/src/main/java/com/dnd/moddo/auth/application/RefreshTokenService.java +++ b/src/main/java/com/dnd/moddo/auth/application/RefreshTokenService.java @@ -19,9 +19,14 @@ public class RefreshTokenService { private final JwtUtil jwtUtil; private final UserRepository userRepository; private final JwtProvider jwtProvider; + private final RefreshTokenBlacklist refreshTokenBlacklist; public RefreshResponse execute(String token) { try { + if (refreshTokenBlacklist.isRevoked(token)) { + throw new TokenInvalidException(); + } + String tokenType = jwtProvider.getTokenType(token); if (!JwtConstants.REFRESH_KEY.message.equals(tokenType)) { throw new TokenInvalidException(); diff --git a/src/main/java/com/dnd/moddo/auth/presentation/AuthController.java b/src/main/java/com/dnd/moddo/auth/presentation/AuthController.java index 9b463f9..8b63fc6 100644 --- a/src/main/java/com/dnd/moddo/auth/presentation/AuthController.java +++ b/src/main/java/com/dnd/moddo/auth/presentation/AuthController.java @@ -87,10 +87,11 @@ public ResponseEntity kakaoLoginCallback(@RequestParam @NotBlank String co @PostMapping("/logout") public ResponseEntity kakaoLogout(@CookieValue(value = "accessToken") String token, + @CookieValue(value = "refreshToken", required = false) String refreshToken, @LoginUser LoginUserInfo loginUser) { String accessTokenCookie = authCookieManager.expireCookie("accessToken"); String refreshTokenCookie = authCookieManager.expireCookie("refreshToken"); - authService.logout(loginUser.userId()); + authService.logout(loginUser.userId(), refreshToken); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, accessTokenCookie, refreshTokenCookie) .body(Collections.singletonMap("message", "Logout successful")); diff --git a/src/main/java/com/dnd/moddo/common/config/CacheConfig.java b/src/main/java/com/dnd/moddo/common/config/CacheConfig.java index 096b502..8766e41 100644 --- a/src/main/java/com/dnd/moddo/common/config/CacheConfig.java +++ b/src/main/java/com/dnd/moddo/common/config/CacheConfig.java @@ -10,6 +10,7 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -39,13 +40,26 @@ public class CacheConfig { @Value("${spring.data.redis.password:}") private String password; + @Value("${spring.data.redis.ssl.enabled:false}") + private boolean sslEnabled; + + @Value("${spring.data.redis.timeout:300ms}") + private Duration commandTimeout; + @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); if (password != null && !password.isBlank()) { config.setPassword(password); } - LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + + LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder() + .commandTimeout(commandTimeout); + if (sslEnabled) { + builder.useSsl(); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config, builder.build()); factory.afterPropertiesSet(); factory.setValidateConnection(false); return factory; diff --git a/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java index 491577b..ef28b58 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/controller/AuthControllerTest.java @@ -171,18 +171,20 @@ void kakaoLogout() throws Exception { given(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any())) .willReturn(new LoginUserInfo(1L, "USER")); - doNothing().when(authService).logout(any()); + doNothing().when(authService).logout(any(), any()); //when & then mockMvc.perform(post("/api/v1/logout") - .cookie(new Cookie("accessToken", "access-token"))) + .cookie(new Cookie("accessToken", "access-token")) + .cookie(new Cookie("refreshToken", "refresh-token"))) .andExpect(status().isOk()) .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, hasItems(org.hamcrest.Matchers.startsWith("accessToken="), org.hamcrest.Matchers.startsWith("refreshToken=")))) .andDo(document("logout", requestCookies( - cookieWithName("accessToken").description("액세스 토큰") + cookieWithName("accessToken").description("액세스 토큰"), + cookieWithName("refreshToken").description("리프레시 토큰") ), responseCookies( cookieWithName("accessToken").description("만료된 액세스 토큰"), diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java index e5e4fee..f531916 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/service/AuthServiceTest.java @@ -15,6 +15,7 @@ import com.dnd.moddo.auth.application.AuthService; import com.dnd.moddo.auth.application.KakaoClient; +import com.dnd.moddo.auth.application.RefreshTokenBlacklist; import com.dnd.moddo.auth.infrastructure.security.JwtProvider; import com.dnd.moddo.auth.presentation.response.KakaoLogoutResponse; import com.dnd.moddo.auth.presentation.response.KakaoProfile; @@ -34,6 +35,8 @@ public class AuthServiceTest { private QueryUserService queryUserService; @Mock private KakaoClient kakaoClient; + @Mock + private RefreshTokenBlacklist refreshTokenBlacklist; @InjectMocks private AuthService authService; @@ -88,11 +91,13 @@ void whenKakaoUserExists_thenTokenIsIssued() { void whenKakaoIdMatches_thenKakaoLogoutSuccess() { //given Long kakaoId = 123456L; + String refreshToken = "refresh-token"; when(queryUserService.findKakaoIdById(any())).thenReturn(Optional.of(kakaoId)); when(kakaoClient.logout(any())).thenReturn(new KakaoLogoutResponse(kakaoId)); //when - authService.logout(1L); + authService.logout(1L, refreshToken); //then + verify(refreshTokenBlacklist, times(1)).revoke(refreshToken); verify(queryUserService, times(1)).findKakaoIdById(1L); verify(kakaoClient, times(1)).logout(kakaoId); } diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenBlacklistTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenBlacklistTest.java new file mode 100644 index 0000000..ea235ea --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenBlacklistTest.java @@ -0,0 +1,193 @@ +package com.dnd.moddo.domain.auth.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.dnd.moddo.auth.application.RefreshTokenBlacklist; +import com.dnd.moddo.auth.infrastructure.security.JwtProvider; + +import io.jsonwebtoken.Claims; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenBlacklistTest { + private static final String KEY_PREFIX = "auth:refresh:blacklist:"; + + @Mock + private JwtProvider jwtProvider; + + @Mock + private ObjectProvider> redisTemplateProvider; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private Claims claims; + + @Test + void givenBlankRefreshToken_thenDoNothing() { + // given + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + + // when + blacklist.revoke(" "); + + // then + then(jwtProvider).should(never()).parseClaims(anyString()); + then(redisTemplateProvider).should(never()).getIfAvailable(); + assertThat(blacklist.isRevoked(" ")).isFalse(); + } + + @Test + void givenRefreshTokenWithFutureExpiration_thenSaveHashedKeyToRedisAndMarkRevoked() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(jwtProvider.parseClaims(refreshToken)).willReturn(claims); + given(claims.getExpiration()).willReturn(Date.from(Instant.now().plusSeconds(60))); + given(redisTemplateProvider.getIfAvailable()).willReturn(redisTemplate); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // when + blacklist.revoke(refreshToken); + + // then + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); + then(valueOperations).should(times(1)).set(keyCaptor.capture(), eq(true), ttlCaptor.capture()); + assertThat(keyCaptor.getValue()).startsWith(KEY_PREFIX); + assertThat(keyCaptor.getValue()).doesNotContain(refreshToken); + assertThat(ttlCaptor.getValue()).isPositive(); + assertThat(blacklist.isRevoked(refreshToken)).isTrue(); + } + + @Test + void givenRefreshTokenWithoutExpiration_thenDoNotSaveToRedis() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(jwtProvider.parseClaims(refreshToken)).willReturn(claims); + given(claims.getExpiration()).willReturn(null); + + // when + blacklist.revoke(refreshToken); + + // then + then(redisTemplateProvider).should(never()).getIfAvailable(); + assertThat(blacklist.isRevoked(refreshToken)).isFalse(); + } + + @Test + void givenExpiredRefreshToken_thenDoNotSaveToRedis() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(jwtProvider.parseClaims(refreshToken)).willReturn(claims); + given(claims.getExpiration()).willReturn(Date.from(Instant.now().minusSeconds(1))); + + // when + blacklist.revoke(refreshToken); + + // then + then(redisTemplateProvider).should(never()).getIfAvailable(); + assertThat(blacklist.isRevoked(refreshToken)).isFalse(); + } + + @Test + void givenRedisTemplateIsMissing_whenRevoke_thenUseLocalBlacklist() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(jwtProvider.parseClaims(refreshToken)).willReturn(claims); + given(claims.getExpiration()).willReturn(Date.from(Instant.now().plusSeconds(60))); + given(redisTemplateProvider.getIfAvailable()).willReturn(null); + + // when + blacklist.revoke(refreshToken); + + // then + assertThat(blacklist.isRevoked(refreshToken)).isTrue(); + } + + @Test + void givenRedisWriteFails_whenRevoke_thenUseLocalBlacklist() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(jwtProvider.parseClaims(refreshToken)).willReturn(claims); + given(claims.getExpiration()).willReturn(Date.from(Instant.now().plusSeconds(60))); + given(redisTemplateProvider.getIfAvailable()).willReturn(redisTemplate); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + willThrow(new RuntimeException("redis down")) + .given(valueOperations).set(anyString(), eq(true), any(Duration.class)); + + // when + blacklist.revoke(refreshToken); + + // then + assertThat(blacklist.isRevoked(refreshToken)).isTrue(); + } + + @Test + void givenRedisHasBlacklistKey_thenReturnRevoked() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(redisTemplateProvider.getIfAvailable()).willReturn(redisTemplate); + given(redisTemplate.hasKey(argThat(key -> key.startsWith(KEY_PREFIX)))).willReturn(true); + + // when + boolean revoked = blacklist.isRevoked(refreshToken); + + // then + assertThat(revoked).isTrue(); + } + + @Test + void givenRedisDoesNotHaveBlacklistKey_thenReturnNotRevoked() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(redisTemplateProvider.getIfAvailable()).willReturn(redisTemplate); + given(redisTemplate.hasKey(argThat(key -> key.startsWith(KEY_PREFIX)))).willReturn(false); + + // when + boolean revoked = blacklist.isRevoked(refreshToken); + + // then + assertThat(revoked).isFalse(); + } + + @Test + void givenRedisReadFails_thenReturnNotRevoked() { + // given + String refreshToken = "refresh-token"; + RefreshTokenBlacklist blacklist = new RefreshTokenBlacklist(jwtProvider, redisTemplateProvider); + given(redisTemplateProvider.getIfAvailable()).willReturn(redisTemplate); + given(redisTemplate.hasKey(anyString())).willThrow(new RuntimeException("redis down")); + + // when + boolean revoked = blacklist.isRevoked(refreshToken); + + // then + assertThat(revoked).isFalse(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java index 44dc300..f70c863 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/service/RefreshTokenServiceTest.java @@ -13,6 +13,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.dnd.moddo.auth.application.RefreshTokenService; +import com.dnd.moddo.auth.application.RefreshTokenBlacklist; import com.dnd.moddo.auth.infrastructure.security.JwtConstants; import com.dnd.moddo.auth.infrastructure.security.JwtProvider; import com.dnd.moddo.auth.infrastructure.security.JwtUtil; @@ -36,6 +37,9 @@ public class RefreshTokenServiceTest { @Mock private JwtProvider jwtProvider; + @Mock + private RefreshTokenBlacklist refreshTokenBlacklist; + @InjectMocks private RefreshTokenService refreshTokenService; @@ -86,6 +90,21 @@ public void shouldThrowOnInvalidToken() { .isInstanceOf(TokenInvalidException.class); } + @Test + public void shouldThrowWhenRefreshTokenIsRevoked() { + // given + String revokedToken = "revokedToken"; + when(refreshTokenBlacklist.isRevoked(revokedToken)).thenReturn(true); + + // when & then + thenThrownBy(() -> refreshTokenService.execute(revokedToken)) + .isInstanceOf(TokenInvalidException.class); + + verify(jwtProvider, never()).getTokenType(anyString()); + verify(jwtUtil, never()).getJwt(anyString()); + verify(userRepository, never()).getById(anyLong()); + } + @Test public void shouldThrowWhenUserIdIsMissingInToken() { // given diff --git a/src/test/java/com/dnd/moddo/domain/auth/service/implementation/AuthServiceTest.java b/src/test/java/com/dnd/moddo/domain/auth/service/implementation/AuthServiceTest.java index 1a95080..c4280eb 100644 --- a/src/test/java/com/dnd/moddo/domain/auth/service/implementation/AuthServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/auth/service/implementation/AuthServiceTest.java @@ -18,6 +18,7 @@ import com.dnd.moddo.auth.application.AuthService; import com.dnd.moddo.auth.application.KakaoClient; +import com.dnd.moddo.auth.application.RefreshTokenBlacklist; import com.dnd.moddo.auth.infrastructure.security.JwtProvider; import com.dnd.moddo.auth.presentation.response.KakaoLogoutResponse; import com.dnd.moddo.auth.presentation.response.KakaoProfile; @@ -45,6 +46,9 @@ public class AuthServiceTest { @Mock private KakaoClient kakaoClient; + @Mock + private RefreshTokenBlacklist refreshTokenBlacklist; + @InjectMocks private AuthService authService; @@ -127,13 +131,15 @@ void logout_success() { // given Long userId = 1L; Long kakaoId = 12345L; + String refreshToken = "refresh-token"; when(queryUserService.findKakaoIdById(userId)).thenReturn(Optional.of(kakaoId)); when(kakaoClient.logout(kakaoId)).thenReturn(new KakaoLogoutResponse(kakaoId)); // when - authService.logout(userId); + authService.logout(userId, refreshToken); // then + verify(refreshTokenBlacklist).revoke(refreshToken); verify(queryUserService).findKakaoIdById(userId); verify(kakaoClient).logout(kakaoId); }