diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java index a75c7d9..29ece7f 100644 --- a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticat .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/accounts/signup", "/api/accounts/login", "/").permitAll() + .requestMatchers("/api/accounts/signup", "/api/accounts/login", "/", "/api/accounts/token/reissue").permitAll() .anyRequest().authenticated() ) .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/account-service/src/main/java/com/synapse/account_service/controller/TokenReissueController.java b/account-service/src/main/java/com/synapse/account_service/controller/TokenReissueController.java new file mode 100644 index 0000000..72050d8 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/controller/TokenReissueController.java @@ -0,0 +1,32 @@ +package com.synapse.account_service.controller; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.account_service.dto.RefreshTokenResponse; +import com.synapse.account_service.dto.response.TokenResponse; +import com.synapse.account_service.service.TokenManagementService; +import com.synapse.account_service.util.AuthResponseWriter; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/accounts/token") +@RequiredArgsConstructor +public class TokenReissueController { + private final TokenManagementService tokenManagementService; + private final AuthResponseWriter authResponseWriter; + + @PostMapping("/reissue") + public ResponseEntity reissue(@CookieValue(name = "refreshToken") String refreshToken) { + TokenResponse newTokens = tokenManagementService.reissueTokens(refreshToken); + + RefreshTokenResponse response = authResponseWriter.writeSuccessResponse(newTokens); + + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, response.cookie().toString()).body(response.responseBody()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/RefreshToken.java b/account-service/src/main/java/com/synapse/account_service/domain/RefreshToken.java new file mode 100644 index 0000000..4c516e1 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.synapse.account_service.domain; + +import java.util.UUID; + +import com.synapse.account_service.common.BaseTimeEntity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "refresh_token") +public class RefreshToken extends BaseTimeEntity { + @Id + @Column(columnDefinition = "uuid") + private UUID memberId; + + @Column(nullable = false, length = 512) + private String token; + + @Builder + public RefreshToken(UUID memberId, String token) { + this.memberId = memberId; + this.token = token; + } + + public void updateToken(String newToken) { + this.token = newToken; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/AccessTokenResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/AccessTokenResponse.java new file mode 100644 index 0000000..4c37894 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/AccessTokenResponse.java @@ -0,0 +1,14 @@ +package com.synapse.account_service.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AccessTokenResponse( + @JsonProperty("accessToken") String token, + @JsonProperty("expiresAt") long expiresAt +) { + public static AccessTokenResponse from(TokenResult tokenResult) { + return new AccessTokenResponse( + tokenResult.token(), + tokenResult.expiresAt().toEpochMilli()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/RefreshTokenResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/RefreshTokenResponse.java new file mode 100644 index 0000000..f69ec60 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/RefreshTokenResponse.java @@ -0,0 +1,10 @@ +package com.synapse.account_service.dto; + +import org.springframework.http.ResponseCookie; + +public record RefreshTokenResponse( + ResponseCookie cookie, + AccessTokenResponse responseBody +) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java b/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java index 0539741..7105407 100644 --- a/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java +++ b/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java @@ -8,6 +8,9 @@ * @param token 생성된 JWT 문자열 * @param expiresAt 토큰의 만료 시간 */ -public record TokenResult(String token, Instant expiresAt) { +public record TokenResult( + String token, + Instant expiresAt +) { } diff --git a/account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/response/TokenResponse.java similarity index 72% rename from account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java rename to account-service/src/main/java/com/synapse/account_service/dto/response/TokenResponse.java index 8c609ae..c4564b0 100644 --- a/account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java +++ b/account-service/src/main/java/com/synapse/account_service/dto/response/TokenResponse.java @@ -1,4 +1,6 @@ -package com.synapse.account_service.dto; +package com.synapse.account_service.dto.response; + +import com.synapse.account_service.dto.TokenResult; /** * JwtService가 최종적으로 생성하여 반환할 인증 토큰 DTO diff --git a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java index f24b6c9..5c6ca01 100644 --- a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java +++ b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java @@ -20,6 +20,9 @@ public enum ExceptionType { NOT_FOUND_MEMBER(NOT_FOUND, "004", "존재하지 않는 사용자입니다."), DUPLICATED_USERNAME_AND_EMAIL(CONFLICT, "005", "이미 존재하는 사용자 이름과 이메일입니다."), + INVALID_REFRESH_TOKEN(UNAUTHORIZED, "006", "유효하지 않은 리프레시 토큰입니다."), + TAMPERED_REFRESH_TOKEN(UNAUTHORIZED, "007", "리프레시 토큰이 변조되었습니다."), + INVALID_TOKEN(UNAUTHORIZED, "005", "유효하지 않은 토큰입니다."), EXPIRED_TOKEN(UNAUTHORIZED, "006", "만료된 토큰입니다."), FAIL_LOGIN(UNAUTHORIZED, "007", "아이디 또는 비밀번호가 일치하지 않습니다.") diff --git a/account-service/src/main/java/com/synapse/account_service/repository/RefreshTokenRepository.java b/account-service/src/main/java/com/synapse/account_service/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..a8e0536 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.synapse.account_service.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.synapse.account_service.domain.RefreshToken; + +public interface RefreshTokenRepository extends JpaRepository { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java index 3388e07..57b19e3 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java +++ b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java @@ -9,8 +9,8 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.exceptions.TokenExpiredException; import com.auth0.jwt.interfaces.DecodedJWT; -import com.synapse.account_service.dto.TokenResponse; import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.dto.response.TokenResponse; import com.synapse.account_service.exception.ExceptionType; import com.synapse.account_service.exception.JWTTokenExpiredException; import com.synapse.account_service.exception.JWTValidationException; @@ -47,4 +47,9 @@ public UUID getMemberIdFrom(String token) { throw new JWTValidationException(ExceptionType.INVALID_TOKEN); } } + + // 테스트용 메서드 + public String createExpiredTokenForTest(String subject) { + return jwtTokenTemplate.createExpiredTokenForTest(subject); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java index 6bd585b..3fd0ef2 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java +++ b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java @@ -1,5 +1,6 @@ package com.synapse.account_service.service; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Map; @@ -41,4 +42,16 @@ public final TokenResult createToken(String subject, Map claims, long public final DecodedJWT verifyAndDecode(String token) throws JWTVerificationException { return verifier.verify(token); } + + // 테스트용 메서드 + public final String createExpiredTokenForTest(String subject) { + Instant now = Instant.now(); + Instant past = now.minus(Duration.ofMinutes(10)); // 10분 전 만료 + + return JWT.create() + .withSubject(subject) + .withIssuedAt(past) + .withExpiresAt(past) + .sign(algorithm); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/service/TokenManagementService.java b/account-service/src/main/java/com/synapse/account_service/service/TokenManagementService.java new file mode 100644 index 0000000..2a23bc4 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/TokenManagementService.java @@ -0,0 +1,62 @@ +package com.synapse.account_service.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.RefreshToken; +import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.dto.response.TokenResponse; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.JWTValidationException; +import com.synapse.account_service.exception.NotFoundException; +import com.synapse.account_service.repository.MemberRepository; +import com.synapse.account_service.repository.RefreshTokenRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TokenManagementService { + private final JwtTokenService jwtTokenService; + private final RefreshTokenRepository refreshTokenRepository; + private final MemberRepository memberRepository; + + public void saveOrUpdateRefreshToken(UUID memberId, TokenResult refreshToken) { + refreshTokenRepository.findById(memberId) + .ifPresentOrElse( + // 기존 토큰이 있으면, 새 토큰으로 값을 업데이트 (재로그인 시) + existingToken -> existingToken.updateToken(refreshToken.token()), + // 기존 토큰이 없으면, 새로 생성하여 저장 (최초 로그인) + () -> { + RefreshToken newRefreshToken = new RefreshToken(memberId, refreshToken.token()); + refreshTokenRepository.save(newRefreshToken); + }); + } + + public TokenResponse reissueTokens(String requestRefreshToken) { + UUID memberId = jwtTokenService.getMemberIdFrom(requestRefreshToken); + + RefreshToken storedToken = refreshTokenRepository.findById(memberId) + .orElseThrow(() -> new JWTValidationException(ExceptionType.INVALID_REFRESH_TOKEN)); + + if (!storedToken.getToken().equals(requestRefreshToken)) { + refreshTokenRepository.delete(storedToken); + throw new JWTValidationException(ExceptionType.TAMPERED_REFRESH_TOKEN); + } + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException(ExceptionType.NOT_FOUND_MEMBER)); + + String role = member.getRole().name(); + + TokenResponse newTokens = jwtTokenService.createTokenResponse(memberId.toString(), role); + + storedToken.updateToken(newTokens.refreshToken().token()); + + return newTokens; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java index a8ce8fe..107b872 100644 --- a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java @@ -1,25 +1,19 @@ package com.synapse.account_service.service.handler; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; +import java.util.UUID; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.databind.ObjectMapper; import com.synapse.account_service.domain.PrincipalUser; -import com.synapse.account_service.dto.TokenResponse; -import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.dto.response.TokenResponse; import com.synapse.account_service.service.JwtTokenService; +import com.synapse.account_service.service.TokenManagementService; +import com.synapse.account_service.util.AuthResponseWriter; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -30,7 +24,8 @@ @Component public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtTokenService jwtTokenService; - private final ObjectMapper objectMapper; + private final TokenManagementService tokenManagementService; + private final AuthResponseWriter authResponseWriter; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -46,29 +41,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId, role); - TokenResult refreshToken = tokenResponse.refreshToken(); - long maxAge = Duration.between(Instant.now(), refreshToken.expiresAt()).getSeconds(); + tokenManagementService.saveOrUpdateRefreshToken(UUID.fromString(memberId), tokenResponse.refreshToken()); - ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken.token()) - .maxAge(maxAge) - .path("/") - .httpOnly(true) - .secure(false) // 프로덕션 환경에서는 true로 설정 - .sameSite("None") // 프론트/백엔드 도메인이 다른 경우 - .build(); - - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); - - TokenResult accessToken = tokenResponse.accessToken(); - Map responseBody = Map.of( - "accessToken", accessToken.token(), - "expiresAt", accessToken.expiresAt().toEpochMilli() - ); - - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.getWriter().write(objectMapper.writeValueAsString(responseBody)); + authResponseWriter.writeSuccessResponse(response, tokenResponse); } } diff --git a/account-service/src/main/java/com/synapse/account_service/util/AuthResponseWriter.java b/account-service/src/main/java/com/synapse/account_service/util/AuthResponseWriter.java new file mode 100644 index 0000000..40e05f0 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/util/AuthResponseWriter.java @@ -0,0 +1,59 @@ +package com.synapse.account_service.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.dto.AccessTokenResponse; +import com.synapse.account_service.dto.RefreshTokenResponse; +import com.synapse.account_service.dto.response.TokenResponse; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthResponseWriter { + private final ObjectMapper objectMapper; + + public RefreshTokenResponse writeSuccessResponse(TokenResponse tokenResponse) { + AccessTokenResponse accessTokenResponse = AccessTokenResponse.from(tokenResponse.accessToken()); + long maxAge = Duration.between(Instant.now(), tokenResponse.refreshToken().expiresAt()).getSeconds(); + ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken().token()) + .maxAge(maxAge) + .path("/") + .httpOnly(true) + .secure(false) + .sameSite("None") + .build(); + + return new RefreshTokenResponse(cookie, accessTokenResponse); + } + + public void writeSuccessResponse(HttpServletResponse response, TokenResponse tokenResponse) throws IOException { + long maxAge = Duration.between(Instant.now(), tokenResponse.refreshToken().expiresAt()).getSeconds(); + ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken().token()) + .maxAge(maxAge) + .path("/") + .httpOnly(true) + .secure(false) + .sameSite("None") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + AccessTokenResponse responseBody = AccessTokenResponse.from(tokenResponse.accessToken()); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(responseBody)); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/integrationtest/TokenReissueIntegrationTest.java b/account-service/src/test/java/com/synapse/account_service/integrationtest/TokenReissueIntegrationTest.java new file mode 100644 index 0000000..0e9756d --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/integrationtest/TokenReissueIntegrationTest.java @@ -0,0 +1,121 @@ +package com.synapse.account_service.integrationtest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.RefreshToken; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.response.TokenResponse; +import com.synapse.account_service.repository.MemberRepository; +import com.synapse.account_service.repository.RefreshTokenRepository; +import com.synapse.account_service.service.JwtTokenService; + +import jakarta.servlet.http.Cookie; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class TokenReissueIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member testMember; + private String validRefreshToken; + + @BeforeEach + void setUp() { + // 테스트용 사용자 생성 및 저장 + testMember = Member.builder() + .email("reissue_user@example.com") + .username("reissue_user") + .password("password") + .role(MemberRole.USER) + .provider("local") + .build(); + memberRepository.save(testMember); + + // 테스트용 유효한 리프레시 토큰 생성 및 DB에 저장 + TokenResponse tokens = jwtTokenService.createTokenResponse(testMember.getId().toString(), "USER"); + validRefreshToken = tokens.refreshToken().token(); + refreshTokenRepository.save(new RefreshToken(testMember.getId(), validRefreshToken)); + } + + @Test + @DisplayName("토큰 재발급 API 성공: 유효한 쿠키로 요청 시, 새 토큰을 응답하고 쿠키를 갱신한다") + void reissueApi_success() throws Exception { + // given + Cookie refreshTokenCookie = new Cookie("refreshToken", validRefreshToken); + + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/token/reissue") + .cookie(refreshTokenCookie) + ); + + // then + actions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.expiresAt").isNumber()) + .andExpect(cookie().exists("refreshToken")) + .andExpect(cookie().httpOnly("refreshToken", true)); + } + + @Test + @DisplayName("토큰 재발급 API 실패: 쿠키가 없을 경우, 400 Bad Request를 응답한다") + void reissueApi_fail_whenCookieIsMissing() throws Exception { + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/token/reissue")); + + // then + // @CookieValue(required=true)에 의해 MissingCookieException이 발생하고, + // GlobalExceptionHandler가 이를 400 Bad Request로 처리한다고 가정합니다. + actions + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("토큰 재발급 API 실패: 만료되거나 유효하지 않은 토큰일 경우, 401 Unauthorized를 응답한다") + void reissueApi_fail_whenTokenIsInvalid() throws Exception { + // given + // 만료 시간을 과거로 설정한 유효하지 않은 토큰 + String expiredToken = jwtTokenService.createExpiredTokenForTest(testMember.getId().toString()); + Cookie expiredCookie = new Cookie("refreshToken", expiredToken); + + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/token/reissue") + .cookie(expiredCookie)); + + // then + // JwtService에서 TokenExpiredException이 발생하고, + // GlobalExceptionHandler가 이를 401 Unauthorized로 처리한다고 가정합니다. + actions + .andDo(print()) + .andExpect(status().isUnauthorized()); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java index 048b6b1..a3597da 100644 --- a/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java +++ b/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java @@ -19,8 +19,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import com.synapse.account_service.dto.TokenResponse; import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.dto.response.TokenResponse; @ExtendWith(MockitoExtension.class) public class JwtTokenServiceTest { diff --git a/account-service/src/test/java/com/synapse/account_service/service/TokenManagementServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/TokenManagementServiceTest.java new file mode 100644 index 0000000..013c966 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/TokenManagementServiceTest.java @@ -0,0 +1,108 @@ +package com.synapse.account_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.RefreshToken; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.dto.response.TokenResponse; +import com.synapse.account_service.exception.JWTValidationException; +import com.synapse.account_service.repository.MemberRepository; +import com.synapse.account_service.repository.RefreshTokenRepository; + +@ExtendWith(MockitoExtension.class) +public class TokenManagementServiceTest { + @InjectMocks + private TokenManagementService tokenManagementService; + + @Mock + private JwtTokenService jwtTokenService; + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock + private MemberRepository memberRepository; + + private UUID memberId; + private String validRefreshToken; + + @BeforeEach + void setUp() { + memberId = UUID.randomUUID(); + validRefreshToken = "valid.refresh.token"; + } + + @Test + @DisplayName("토큰 재발급 성공: 유효한 리프레시 토큰으로 요청 시, 새로운 토큰 쌍을 반환하고 DB를 갱신한다") + void reissueTokens_success() { + // given + RefreshToken storedToken = new RefreshToken(memberId, validRefreshToken); + Member member = Member.builder() + .role(MemberRole.USER) + .build(); + TokenResponse newTokens = new TokenResponse( + new TokenResult("new.access.token", Instant.now().plusSeconds(1800)), + new TokenResult("new.refresh.token", Instant.now().plusSeconds(86400)) + ); + + given(jwtTokenService.getMemberIdFrom(validRefreshToken)).willReturn(memberId); + given(refreshTokenRepository.findById(memberId)).willReturn(Optional.of(storedToken)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(jwtTokenService.createTokenResponse(memberId.toString(), "USER")).willReturn(newTokens); + + // when + TokenResponse result = tokenManagementService.reissueTokens(validRefreshToken); + + // then + assertThat(result.accessToken().token()).isEqualTo("new.access.token"); + assertThat(storedToken.getToken()).isEqualTo("new.refresh.token"); // Rotation 검증 + verify(refreshTokenRepository).findById(memberId); + verify(memberRepository).findById(memberId); + } + + @Test + @DisplayName("토큰 재발급 실패: DB에 저장된 토큰과 일치하지 않으면 InvalidTokenException을 던지고 DB에서 삭제한다 (탈취 의심)") + void reissueTokens_fail_whenTokenMismatched() { + // given + RefreshToken storedToken = new RefreshToken(memberId, "different.token.in.db"); + + given(jwtTokenService.getMemberIdFrom(validRefreshToken)).willReturn(memberId); + given(refreshTokenRepository.findById(memberId)).willReturn(Optional.of(storedToken)); + + // when & then + assertThrows(JWTValidationException.class, () -> { + tokenManagementService.reissueTokens(validRefreshToken); + }); + + // 탈취 시도로 간주하고, DB에서 해당 토큰을 삭제했는지 검증 + verify(refreshTokenRepository).delete(storedToken); + } + + @Test + @DisplayName("토큰 재발급 실패: DB에 리프레시 토큰이 없으면 InvalidTokenException을 던진다") + void reissueTokens_fail_whenTokenNotFoundInDb() { + // given + given(jwtTokenService.getMemberIdFrom(validRefreshToken)).willReturn(memberId); + given(refreshTokenRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThrows(JWTValidationException.class, () -> { + tokenManagementService.reissueTokens(validRefreshToken); + }); + } +}