diff --git a/src/main/java/org/runimo/runimo/auth/domain/RefreshToken.java b/src/main/java/org/runimo/runimo/auth/domain/RefreshToken.java new file mode 100644 index 00000000..0dda599b --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/domain/RefreshToken.java @@ -0,0 +1,52 @@ +package org.runimo.runimo.auth.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.common.CreateUpdateAuditEntity; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.context.annotation.Profile; + +@Profile({"prod", "dev"}) +@Table(name = "user_refresh_token") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken extends CreateUpdateAuditEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + @Builder + private RefreshToken(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public static RefreshToken of(Long userId, String refreshToken) { + return RefreshToken.builder() + .userId(userId) + .refreshToken(refreshToken) + .build(); + } + + public void update(String refreshToken) { + if (refreshToken == null || refreshToken.isEmpty()) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL); + } + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java new file mode 100644 index 00000000..e7e81b5c --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java @@ -0,0 +1,46 @@ +package org.runimo.runimo.auth.repository; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.domain.RefreshToken; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +@Profile({"prod", "dev"}) +@Repository +@RequiredArgsConstructor +public class DatabaseTokenRepository implements JwtTokenRepository { + + private final RefreshTokenJpaRepository refreshTokenJpaRepository; + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpiryMillis; + + @Override + public Optional findRefreshTokenByUserId(final Long userId) { + return refreshTokenJpaRepository.findByUserId(userId) + .map(RefreshToken::getRefreshToken); + } + + @Override + public void saveRefreshTokenWithUserId(final Long userId, final String refreshToken) { + LocalDateTime REPLACE_CUTOFF_TIME = LocalDateTime.now() + .minus(refreshTokenExpiryMillis, ChronoUnit.MILLIS); + + RefreshToken updatedRefreshToken = refreshTokenJpaRepository.findByUserIdAfterCutoffTime(userId, + REPLACE_CUTOFF_TIME) + .map(existingToken -> { + existingToken.update(refreshToken); + return existingToken; + }) + .orElseGet(() -> + RefreshToken.of(userId, refreshToken) + ); + + refreshTokenJpaRepository.save(updatedRefreshToken); + } + + +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java new file mode 100644 index 00000000..d6172a7a --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java @@ -0,0 +1,30 @@ +package org.runimo.runimo.auth.repository; + +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.cache.InMemoryCache; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +@Profile({"test", "local"}) +@Repository +@RequiredArgsConstructor +public class InMemoryTokenRepository implements JwtTokenRepository { + + private final InMemoryCache refreshTokenCache; + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpiry; + + + @Override + public Optional findRefreshTokenByUserId(Long userId) { + return refreshTokenCache.get(userId); + } + + @Override + public void saveRefreshTokenWithUserId(Long userId, String refreshToken) { + refreshTokenCache.put(userId, refreshToken, Duration.ofMillis(refreshTokenExpiry)); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java new file mode 100644 index 00000000..4b92078f --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.auth.repository; + +import java.util.Optional; + +public interface JwtTokenRepository { + + Optional findRefreshTokenByUserId(Long userId); + + void saveRefreshTokenWithUserId(Long userId, String refreshToken); +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java b/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java new file mode 100644 index 00000000..cae782cd --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java @@ -0,0 +1,18 @@ +package org.runimo.runimo.auth.repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import org.runimo.runimo.auth.domain.RefreshToken; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +@Profile({"prod", "dev"}) +public interface RefreshTokenJpaRepository extends JpaRepository { + + Optional findByUserId(Long id); + + @Query("select distinct r from RefreshToken r " + + "where r.userId = :userId and r.updatedAt > :cutOffDateTime") + Optional findByUserIdAfterCutoffTime(Long userId, LocalDateTime cutOffDateTime); +} diff --git a/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java index 4511f531..209b273f 100644 --- a/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java +++ b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java @@ -1,49 +1,50 @@ package org.runimo.runimo.auth.service; -import java.time.Duration; import lombok.RequiredArgsConstructor; import org.runimo.runimo.auth.exceptions.UserJwtException; import org.runimo.runimo.auth.jwt.JwtResolver; import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.repository.JwtTokenRepository; import org.runimo.runimo.auth.service.dto.TokenPair; -import org.runimo.runimo.common.cache.InMemoryCache; +import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.enums.UserHttpResponseCode; -import org.springframework.beans.factory.annotation.Value; +import org.runimo.runimo.user.service.UserFinder; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class TokenRefreshService { - private final JwtResolver jwtResolver; - private final InMemoryCache refreshTokenCache; - private final JwtTokenFactory jwtTokenFactory; - @Value("${jwt.refresh.expiration}") - private Long refreshTokenExpiry; - - public void putRefreshToken(String userId, String refreshToken) { - refreshTokenCache.put(userId, refreshToken, Duration.ofMillis(refreshTokenExpiry)); + private final JwtResolver jwtResolver; + private final JwtTokenRepository jwtTokenRepository; + private final JwtTokenFactory jwtTokenFactory; + private final UserFinder userFinder; + + public void putRefreshToken(String userPublicId, String refreshToken) { + User user = userFinder.findUserByPublicId(userPublicId) + .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL)); + jwtTokenRepository.saveRefreshTokenWithUserId(user.getId(), refreshToken); + } + + public TokenPair refreshAccessToken(String refreshToken) { + String userPublicId; + try { + jwtResolver.verifyJwtToken(refreshToken); + userPublicId = jwtResolver.getUserIdFromJwtToken(refreshToken); + } catch (Exception e) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL); } - public TokenPair refreshAccessToken(String refreshToken) { - String userId; - try { - jwtResolver.verifyJwtToken(refreshToken); - userId = jwtResolver.getUserIdFromJwtToken(refreshToken); - } catch (Exception e) { - throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL); - } - - String storedToken = refreshTokenCache.get(userId).orElse(null); - if (storedToken == null || !storedToken.equals(refreshToken)) { - throw new IllegalArgumentException("Refresh token mismatch"); - } - - String newAccessToken = jwtTokenFactory.generateAccessToken(userId); - String newRefreshToken = jwtTokenFactory.generateRefreshToken(userId); - - // 갱신한 리프레시 토큰 저장 (기존 토큰 갱신) - refreshTokenCache.put(userId, newRefreshToken, Duration.ofMillis(refreshTokenExpiry)); - return new TokenPair(newAccessToken, newRefreshToken); + User user = userFinder.findUserByPublicId(userPublicId) + .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL)); + + String storedToken = jwtTokenRepository.findRefreshTokenByUserId(user.getId()) + .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.REFRESH_EXPIRED)); + if (!storedToken.equals(refreshToken)) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); } + + String newAccessToken = jwtTokenFactory.generateAccessToken(userPublicId); + return new TokenPair(newAccessToken, refreshToken); + } } diff --git a/src/main/java/org/runimo/runimo/common/CreateUpdateAuditEntity.java b/src/main/java/org/runimo/runimo/common/CreateUpdateAuditEntity.java new file mode 100644 index 00000000..63a35555 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/CreateUpdateAuditEntity.java @@ -0,0 +1,21 @@ +package org.runimo.runimo.common; + +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Getter +@NoArgsConstructor +@MappedSuperclass +public abstract class CreateUpdateAuditEntity { + + @CreationTimestamp + protected LocalDateTime createdAt; + + @UpdateTimestamp + protected LocalDateTime updatedAt; + +} diff --git a/src/main/java/org/runimo/runimo/config/CacheConfig.java b/src/main/java/org/runimo/runimo/config/CacheConfig.java index e4782d46..8e469446 100644 --- a/src/main/java/org/runimo/runimo/config/CacheConfig.java +++ b/src/main/java/org/runimo/runimo/config/CacheConfig.java @@ -13,38 +13,38 @@ @Configuration public class CacheConfig { - @Value("${cache.cleanup.interval:300}") - private int cleanupIntervalSeconds; + @Value("${cache.cleanup.interval:300}") + private int cleanupIntervalSeconds; - @Value("${cache.cleanup.thread-pool-size:1}") - private int cleanupThreadPoolSize; + @Value("${cache.cleanup.thread-pool-size:1}") + private int cleanupThreadPoolSize; - @Bean - public TaskScheduler cacheCleanupScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(cleanupThreadPoolSize); - scheduler.setThreadNamePrefix("cache-cleanup-"); - scheduler.setDaemon(true); - scheduler.setWaitForTasksToCompleteOnShutdown(true); - scheduler.setAwaitTerminationSeconds(10); - return scheduler; - } + @Bean + public TaskScheduler cacheCleanupScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(cleanupThreadPoolSize); + scheduler.setThreadNamePrefix("cache-cleanup-"); + scheduler.setDaemon(true); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(10); + return scheduler; + } - @Bean - public InMemoryCache refreshTokenCache(TaskScheduler cacheCleanupScheduler) { - return new SpringInMemoryCache<>( - cacheCleanupScheduler, - Duration.ofSeconds(cleanupIntervalSeconds) - ); - } + @Bean + public InMemoryCache refreshTokenCache(TaskScheduler cacheCleanupScheduler) { + return new SpringInMemoryCache<>( + cacheCleanupScheduler, + Duration.ofSeconds(cleanupIntervalSeconds) + ); + } - @Bean - public InMemoryCache userPrincipalCache( - TaskScheduler cacheCleanupScheduler - ) { - return new SpringInMemoryCache<>( - cacheCleanupScheduler, - Duration.ofSeconds(cleanupIntervalSeconds) - ); - } + @Bean + public InMemoryCache userPrincipalCache( + TaskScheduler cacheCleanupScheduler + ) { + return new SpringInMemoryCache<>( + cacheCleanupScheduler, + Duration.ofSeconds(cleanupIntervalSeconds) + ); + } } diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index f3b26adf..ddbd6a6e 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -21,7 +21,8 @@ public enum UserHttpResponseCode implements CustomResponseCode { SIGNIN_FAIL_ALREADY_EXIST(HttpStatus.CONFLICT, "로그인 실패 - 이미 존재하는 사용자", "로그인 실패 - 이미 존재하는 사용자"), JWT_TOKEN_BROKEN(HttpStatus.BAD_REQUEST, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), TOKEN_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 재발급 실패", "Refresh 토큰이 유효하지 않습니다."), - TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT 토큰 인증 실패"); + TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT 토큰 인증 실패"), + REFRESH_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰 만료", "리프레시 토큰 만료"); private final HttpStatus code; private final String clientMessage; diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index ed638e7b..4dde842d 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -12,6 +12,7 @@ DROP TABLE IF EXISTS runimo; DROP TABLE IF EXISTS item_activity; DROP TABLE IF EXISTS runimo_definition; DROP TABLE IF EXISTS item; +DROP TABLE IF EXISTS user_refresh_token; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS user_love_point; DROP TABLE IF EXISTS incubating_egg; @@ -197,6 +198,15 @@ CREATE TABLE `runimo` `deleted_at` TIMESTAMP NULL ); +CREATE TABLE `user_refresh_token` +( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL UNIQUE , + `refresh_token` TEXT NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + ALTER TABLE `user_token` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`id`); diff --git a/src/test/java/org/runimo/runimo/auth/service/TokenRefreshAcceptanceTest.java b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshAcceptanceTest.java new file mode 100644 index 00000000..5a0ebc75 --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshAcceptanceTest.java @@ -0,0 +1,142 @@ +package org.runimo.runimo.auth.service; + +import static io.restassured.RestAssured.given; +import static org.mockito.ArgumentMatchers.any; + +import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.controller.request.KakaoLoginRequest; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.apple.KakaoUserInfo; +import org.runimo.runimo.auth.service.kakao.KakaoTokenVerifier; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.repository.OAuthInfoRepository; +import org.runimo.runimo.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class TokenRefreshAcceptanceTest { + + private static final String TEST_PROVIDER_ID = "test-provider-id"; + private static final String TEST_PUBLIC_ID = "test-public-id"; + + @LocalServerPort + private int port; + + @Autowired + private CleanUpUtil cleanUpUtil; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private OAuthInfoRepository oAuthInfoRepository; + + @MockitoBean + private KakaoTokenVerifier kakaoTokenVerifier; + + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @BeforeEach + void setup() { + RestAssured.port = port; + cleanUpUtil.cleanUpUserInfos(); + + User user = UserFixtures.getDefaultUser(); + userRepository.save(user); + OAuthInfo oAuthInfo = OAuthInfo.builder() + .user(user) + .providerId(TEST_PROVIDER_ID) + .provider(SocialProvider.KAKAO) + .build(); + oAuthInfoRepository.save(oAuthInfo); + } + + + + @Test + void 로그인_후_토큰_갱신_성공_200() throws JsonProcessingException { + + String testOidcToken = jwtTokenFactory.generateAccessToken(TEST_PUBLIC_ID); + KakaoLoginRequest request = new KakaoLoginRequest(testOidcToken); + + Mockito.when(kakaoTokenVerifier.verifyToken(any(DecodedJWT.class))) + .thenReturn(new KakaoUserInfo(TEST_PROVIDER_ID)); + + // login + String refreshToken = given() + .header("Authorization", "Bearer test-access-token") + .contentType("application/json") + .body(objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/kakao") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().path("payload.refresh_token"); + + + given() + .contentType("application/json") + .header("Authorization", refreshToken) + .when() + .post("/api/v1/auth/refresh") + .then() + .statusCode(HttpStatus.OK.value()); + + } + + @Test + void 로그인_후_RefreshToken_오류_시_갱신_실패_403() throws JsonProcessingException { + + String testOidcToken = jwtTokenFactory.generateAccessToken(TEST_PUBLIC_ID); + KakaoLoginRequest request = new KakaoLoginRequest(testOidcToken); + + Mockito.when(kakaoTokenVerifier.verifyToken(any(DecodedJWT.class))) + .thenReturn(new KakaoUserInfo(TEST_PROVIDER_ID)); + + // login + String accessToken = given() + .header("Authorization", "Bearer test-access-token") + .contentType("application/json") + .body(objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/kakao") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().path("payload.access_token"); + + // 리프레쉬가 아니라 액세스토큰을 넘겨서 오류 상황을 재현 + given() + .contentType("application/json") + .header("Authorization", accessToken) + .when() + .post("/api/v1/auth/refresh") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()); + + } + +} diff --git a/src/test/java/org/runimo/runimo/auth/service/TokenRefreshDevTest.java b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshDevTest.java new file mode 100644 index 00000000..6e99e207 --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshDevTest.java @@ -0,0 +1,85 @@ +package org.runimo.runimo.auth.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.dto.TokenPair; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + + +@SpringBootTest +@ActiveProfiles("dev") +public class TokenRefreshDevTest { + + @Autowired + private TokenRefreshService tokenRefreshService; + + @MockitoBean + private UserFinder userFinder; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Test + void 배포_환경_리프레시_토큰_저장_성공() { + //given + String userPublicId = "test-user-public-id"; + when(userFinder.findUserByPublicId(userPublicId)) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = "test-refresh-token"; + tokenRefreshService.putRefreshToken(userPublicId, refreshToken); + } + + @Test + void 배포_환경_리프레시_토큰_검증_실패_시_예외() { + //given + when(userFinder.findUserByPublicId(any())) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = "test-refresh-token"; + + //when & then + assertThrows( + UserJwtException.class, + () -> tokenRefreshService.refreshAccessToken(refreshToken), + "토큰 검증 실패 시 예외가 발생해야 합니다." + ); + } + + @Test + void 배포_환경_리프레시_토큰_탐색_실패_예외() { + String testRefreshToken = jwtTokenFactory.generateRefreshToken("test-user-public-id-2"); + + when(userFinder.findUserByPublicId(any())) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + + //when & then + assertThrows( + UserJwtException.class, + () -> tokenRefreshService.refreshAccessToken(testRefreshToken), + "리프레시 토큰 탐색 실패 시 예외가 발생해야 합니다." + ); + } + + @Test + void 배포_환경_리프레시_토큰_저장_후_조회_성공() { + //given + String userPublicId = "test-user-public-id"; + when(userFinder.findUserByPublicId(userPublicId)) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = jwtTokenFactory.generateRefreshToken(userPublicId); + tokenRefreshService.putRefreshToken(userPublicId, refreshToken); + + TokenPair tokenPair = tokenRefreshService.refreshAccessToken(refreshToken); + assertEquals(refreshToken, tokenPair.refreshToken()); + } +} diff --git a/src/test/java/org/runimo/runimo/auth/service/TokenRefreshLocalTest.java b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshLocalTest.java new file mode 100644 index 00000000..25e385ea --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/service/TokenRefreshLocalTest.java @@ -0,0 +1,85 @@ +package org.runimo.runimo.auth.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.dto.TokenPair; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest +@ActiveProfiles("test") +public class TokenRefreshLocalTest { + + @Autowired + private TokenRefreshService tokenRefreshService; + + @MockitoBean + private UserFinder userFinder; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Test + void 테스트_환경_리프레시_토큰_저장_성공() { + //given + String userPublicId = "test-user-public-id"; + when(userFinder.findUserByPublicId(userPublicId)) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = "test-refresh-token"; + tokenRefreshService.putRefreshToken(userPublicId, refreshToken); + } + + @Test + void 테스트_환경_리프레시_토큰_검증_실패_시_예외() { + //given + when(userFinder.findUserByPublicId(any())) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = "test-refresh-token"; + + //when & then + assertThrows( + UserJwtException.class, + () -> tokenRefreshService.refreshAccessToken(refreshToken), + "토큰 검증 실패 시 예외가 발생해야 합니다." + ); + } + + @Test + void 테스트_환경_리프레시_토큰_탐색_실패_예외() { + String testRefreshToken = jwtTokenFactory.generateRefreshToken("test-user-public-id"); + + when(userFinder.findUserByPublicId(any())) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + + //when & then + assertThrows( + UserJwtException.class, + () -> tokenRefreshService.refreshAccessToken(testRefreshToken), + "리프레시 토큰 탐색 실패 시 예외가 발생해야 합니다." + ); + } + + @Test + void 테스트_환경_리프레시_토큰_저장_후_조회_성공() { + //given + String userPublicId = "test-user-public-id"; + when(userFinder.findUserByPublicId(userPublicId)) + .thenReturn(Optional.ofNullable(UserFixtures.getUserWithId(1L))); + String refreshToken = jwtTokenFactory.generateRefreshToken(userPublicId); + tokenRefreshService.putRefreshToken(userPublicId, refreshToken); + + TokenPair tokenPair = tokenRefreshService.refreshAccessToken(refreshToken); + assertEquals(refreshToken, tokenPair.refreshToken()); + } + +} diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index 916ff5e1..d84e05c1 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -12,6 +12,7 @@ DROP TABLE IF EXISTS runimo; DROP TABLE IF EXISTS item_activity; DROP TABLE IF EXISTS runimo_definition; DROP TABLE IF EXISTS item; +DROP TABLE IF EXISTS user_refresh_token; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS user_love_point; DROP TABLE IF EXISTS incubating_egg; @@ -197,6 +198,15 @@ CREATE TABLE `runimo` `deleted_at` TIMESTAMP NULL ); +CREATE TABLE `user_refresh_token` +( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL UNIQUE , + `refresh_token` TEXT NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + ALTER TABLE `user_token` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`id`);