From 32f4065a708bfa8fd9f400cedef3770adfdcc6ed Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:12:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 3 --- .../back/domain/user/service/UserService.java | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/user/controller/AuthController.java b/src/main/java/com/back/domain/user/controller/AuthController.java index 4ee3261a..2274db39 100644 --- a/src/main/java/com/back/domain/user/controller/AuthController.java +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -5,9 +5,6 @@ import com.back.domain.user.dto.UserResponse; import com.back.domain.user.service.UserService; import com.back.global.common.dto.RsData; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 53913dc6..be632f8e 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -135,6 +135,28 @@ public UserResponse login(LoginRequest request, HttpServletResponse response) { return UserResponse.from(user, user.getUserProfile()); } + public void logout(HttpServletRequest request, HttpServletResponse response) { + // 쿠키에서 Refresh Token 추출 + String refreshToken = resolveRefreshToken(request); + + // 토큰 검증 + if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + + // DB에서 Refresh Token 삭제 + userTokenRepository.deleteByRefreshToken(refreshToken); + + // TODO: 중복 코드 -> 리팩토링 필요 + // 쿠키 삭제 + Cookie cookie = new Cookie("refreshToken", null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/api/auth/refresh"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + /** * 로그아웃 서비스 * 1. Refresh Token 검증 및 DB 삭제 From e633ec60057efc947cf7c3fe8675a289bd0e1c6d Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:22:26 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Ref:=20CookieUtil=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/back/domain/user/service/UserService.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index be632f8e..5293ccaa 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -147,14 +147,8 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { // DB에서 Refresh Token 삭제 userTokenRepository.deleteByRefreshToken(refreshToken); - // TODO: 중복 코드 -> 리팩토링 필요 // 쿠키 삭제 - Cookie cookie = new Cookie("refreshToken", null); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/api/auth/refresh"); - cookie.setMaxAge(0); - response.addCookie(cookie); + CookieUtil.clearCookie(response, "refreshToken", "/api/auth"); } /** From 62f1718c099279fcd5da9774ba3a055681bb6e9f Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:38:38 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Comment:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/back/domain/user/service/UserService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 5293ccaa..87569899 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -135,6 +135,11 @@ public UserResponse login(LoginRequest request, HttpServletResponse response) { return UserResponse.from(user, user.getUserProfile()); } + /** + * 로그아웃 서비스 + * 1. Refresh Token 검증 및 DB 삭제 + * 2. 쿠키 삭제 + */ public void logout(HttpServletRequest request, HttpServletResponse response) { // 쿠키에서 Refresh Token 추출 String refreshToken = resolveRefreshToken(request); From 213827fc48f5ab8dba3b68fd19965ce978670852 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:05:58 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/back/domain/user/service/UserService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 87569899..0548257d 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -144,8 +144,13 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { // 쿠키에서 Refresh Token 추출 String refreshToken = resolveRefreshToken(request); - // 토큰 검증 - if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) { + // Refresh Token 존재 여부 확인 + if (refreshToken == null) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + // Refresh Token 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { throw new CustomException(ErrorCode.INVALID_TOKEN); } From ed97c8a3edb2a7b72454dbb3ec0b251f81336960 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:22:25 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 15 ++++++++ .../back/domain/user/service/UserService.java | 34 +++++++++++++------ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/back/domain/user/controller/AuthController.java b/src/main/java/com/back/domain/user/controller/AuthController.java index 2274db39..2323366c 100644 --- a/src/main/java/com/back/domain/user/controller/AuthController.java +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -16,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -63,4 +65,17 @@ public ResponseEntity> logout( null )); } + + // 토큰 재발급 + @PostMapping("/refresh") + public ResponseEntity>> refreshToken( + HttpServletRequest request, + HttpServletResponse response + ) { + String newAccessToken = userService.refreshToken(request, response); + return ResponseEntity.ok(RsData.success( + "토큰이 재발급되었습니다.", + Map.of("accessToken", newAccessToken) + )); + } } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 0548257d..bfc6dd93 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -12,7 +12,6 @@ import com.back.domain.user.repository.UserTokenRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; -import com.back.global.security.CurrentUser; import com.back.global.security.JwtTokenProvider; import com.back.global.util.CookieUtil; import jakarta.servlet.http.Cookie; @@ -162,12 +161,14 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { } /** - * 로그아웃 서비스 - * 1. Refresh Token 검증 및 DB 삭제 - * 2. 쿠키 삭제 + * 토큰 재발급 서비스 + * 1. 쿠키에서 Refresh Token 추출 + * 2. Refresh Token 검증 (만료/위조 확인) + * 3. DB에 저장된 Refresh Token 여부 확인 + * 4. 새 Access Token 발급 */ - public void logout(HttpServletRequest request, HttpServletResponse response) { - // 쿠키에서 Refresh Token 추출 + public String refreshToken(HttpServletRequest request, HttpServletResponse response) { + // Refresh Token 검증 String refreshToken = resolveRefreshToken(request); // Refresh Token 존재 여부 확인 @@ -180,11 +181,24 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { throw new CustomException(ErrorCode.INVALID_TOKEN); } - // DB에서 Refresh Token 삭제 - userTokenRepository.deleteByRefreshToken(refreshToken); + // DB에서 Refresh Token 조회 + UserToken userToken = userTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); - // 쿠키 삭제 - CookieUtil.clearCookie(response, "refreshToken", "/api/auth"); + // 사용자 정보 조회 + User user = userToken.getUser(); + + // 새로운 Access Token 발급 + String newAccessToken = jwtTokenProvider.createAccessToken( + user.getId(), + user.getUsername(), + user.getRole().name() + ); + + // 새로운 Access Token을 응답 헤더에 설정 + response.setHeader("Authorization", "Bearer " + newAccessToken); + + return newAccessToken; } /** From 46fba601aac466107e30f9599fb6a415684cfff7 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:27:16 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthControllerDocs.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java index bd5c3d79..6445ff19 100644 --- a/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java @@ -16,6 +16,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestBody; +import java.util.Map; + @Tag(name = "Auth API", description = "인증/인가 관련 API") public interface AuthControllerDocs { @@ -302,4 +304,87 @@ ResponseEntity> logout( HttpServletRequest request, HttpServletResponse response ); + + @Operation( + summary = "토큰 재발급", + description = "Access Token이 만료되었을 때 Refresh Token을 이용해 새로운 Access Token을 발급받습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "토큰이 재발급되었습니다.", + "data": { + "accessToken": "{newAccessToken}" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Refresh Token 없음 / 잘못된 요청", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Refresh Token 만료 또는 위조/무효", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "Refresh Token 만료", value = """ + { + "success": false, + "code": "USER_401", + "message": "Refresh Token이 만료되었습니다. 다시 로그인해주세요.", + "data": null + } + """), + @ExampleObject(name = "Refresh Token 위조/무효", value = """ + { + "success": false, + "code": "USER_401", + "message": "유효하지 않은 Refresh Token입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> refreshToken( + HttpServletRequest request, + HttpServletResponse response + ); } From feadd35118a27891ea19bc6658e27f9bcbd74aca Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:04:37 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthControllerDocs.java | 65 +++++----- .../global/security/JwtTokenProvider.java | 6 +- .../user/controller/AuthControllerTest.java | 114 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 57 +++++++++ .../back/fixture/TestJwtTokenProvider.java | 45 +++++++ 5 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 src/test/java/com/back/fixture/TestJwtTokenProvider.java diff --git a/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java index 6445ff19..dbcd5423 100644 --- a/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java @@ -245,13 +245,13 @@ ResponseEntity> login( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": true, - "code": "SUCCESS_200", - "message": "로그아웃 되었습니다.", - "data": null - } - """) + { + "success": true, + "code": "SUCCESS_200", + "message": "로그아웃 되었습니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -260,13 +260,13 @@ ResponseEntity> login( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "AUTH_401", - "message": "이미 만료되었거나 유효하지 않은 토큰입니다.", - "data": null - } - """) + { + "success": false, + "code": "AUTH_401", + "message": "이미 만료되었거나 유효하지 않은 토큰입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -275,13 +275,13 @@ ResponseEntity> login( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "COMMON_400", - "message": "잘못된 요청입니다.", - "data": null - } - """) + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -290,13 +290,13 @@ ResponseEntity> login( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "COMMON_500", - "message": "서버 오류가 발생했습니다.", - "data": null - } - """) + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) ) ) }) @@ -307,7 +307,8 @@ ResponseEntity> logout( @Operation( summary = "토큰 재발급", - description = "Access Token이 만료되었을 때 Refresh Token을 이용해 새로운 Access Token을 발급받습니다." + description = "만료된 Access Token 대신 Refresh Token을 이용해 새로운 Access Token을 발급받습니다. " + + "Refresh Token은 HttpOnly 쿠키에서 추출하며, 재발급 성공 시 응답 헤더와 본문에 새로운 Access Token을 담습니다." ) @ApiResponses({ @ApiResponse( @@ -351,15 +352,15 @@ ResponseEntity> logout( @ExampleObject(name = "Refresh Token 만료", value = """ { "success": false, - "code": "USER_401", - "message": "Refresh Token이 만료되었습니다. 다시 로그인해주세요.", + "code": "AUTH_401", + "message": "만료된 리프레시 토큰입니다.", "data": null } """), @ExampleObject(name = "Refresh Token 위조/무효", value = """ { "success": false, - "code": "USER_401", + "code": "AUTH_401", "message": "유효하지 않은 Refresh Token입니다.", "data": null } diff --git a/src/main/java/com/back/global/security/JwtTokenProvider.java b/src/main/java/com/back/global/security/JwtTokenProvider.java index 9937f0d5..0b9f8c5f 100644 --- a/src/main/java/com/back/global/security/JwtTokenProvider.java +++ b/src/main/java/com/back/global/security/JwtTokenProvider.java @@ -116,8 +116,10 @@ public boolean validateToken(String token) { .build() .parseSignedClaims(token); return true; - } catch (JwtException e) { - return false; + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } catch (JwtException | IllegalArgumentException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); } } diff --git a/src/test/java/com/back/domain/user/controller/AuthControllerTest.java b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java index 2cd080f4..3961d6be 100644 --- a/src/test/java/com/back/domain/user/controller/AuthControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java @@ -4,6 +4,9 @@ import com.back.domain.user.entity.UserProfile; import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserRepository; +import com.back.fixture.TestJwtTokenProvider; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,6 +20,8 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; +import java.util.Date; + import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -34,6 +39,9 @@ class AuthControllerTest { @Autowired private UserRepository userRepository; + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + @Test @DisplayName("정상 회원가입 → 201 Created") void register_success() throws Exception { @@ -441,4 +449,110 @@ void logout_invalidToken() throws Exception { .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value("AUTH_401")); } + + @Test + @DisplayName("정상 토큰 재발급 → 200 OK + 새로운 AccessToken 반환") + void refreshToken_success() throws Exception { + // given: 회원가입 + 로그인 → 기존 토큰 확보 + String rawPassword = "P@ssw0rd!"; + String registerBody = """ + { + "username": "refreshuser", + "email": "refresh@example.com", + "password": "%s", + "nickname": "홍길동" + } + """.formatted(rawPassword); + + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerBody)) + .andExpect(status().isCreated()); + + String loginBody = """ + { + "username": "refreshuser", + "password": "%s" + } + """.formatted(rawPassword); + + ResultActions loginResult = mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginBody)) + .andExpect(status().isOk()); + + // 기존 AccessToken, RefreshToken 확보 + String oldAccessToken = loginResult.andReturn() + .getResponse() + .getHeader("Authorization") + .substring(7); // "Bearer " 제거 + String refreshCookie = loginResult.andReturn() + .getResponse() + .getCookie("refreshToken") + .getValue(); + + // Issued At(발급 시간) 분리를 위해 1초 대기 +// Thread.sleep(1000); + + // when: 재발급 요청 (RefreshToken 쿠키 포함) + ResultActions refreshResult = mvc.perform(post("/api/auth/refresh") + .cookie(new Cookie("refreshToken", refreshCookie))) + .andDo(print()); + + // then: 200 OK + 새로운 AccessToken 발급 + refreshResult + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(header().exists("Authorization")); + + String newAccessToken = refreshResult.andReturn() + .getResponse() + .getHeader("Authorization") + .substring(7); + + // 새 토큰은 기존 토큰과 달라야 함 +// assertThat(newAccessToken).isNotEqualTo(oldAccessToken); + } + + @Test + @DisplayName("RefreshToken 누락 → 400 Bad Request") + void refreshToken_noToken() throws Exception { + mvc.perform(post("/api/auth/refresh")) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")); + } + + @Test + @DisplayName("유효하지 않은 RefreshToken → 401 Unauthorized") + void refreshToken_invalidToken() throws Exception { + Cookie invalid = new Cookie("refreshToken", "fake-token"); + + mvc.perform(post("/api/auth/refresh").cookie(invalid)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_401")); + } + + @Test + @DisplayName("만료된 RefreshToken → 401 Unauthorized") + void refreshToken_expiredToken() throws Exception { + // given: 이미 만료된 Refresh Token 발급 + User user = User.createUser("expiredUser", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + userRepository.save(user); + + // JwtTokenProvider에 테스트용 메서드 추가 필요 + String expiredRefreshToken = testJwtTokenProvider.createExpiredRefreshToken(user.getId()); + + Cookie expiredCookie = new Cookie("refreshToken", expiredRefreshToken); + + // when & then + mvc.perform(post("/api/auth/refresh").cookie(expiredCookie)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_401")) + .andExpect(jsonPath("$.message").value("만료된 리프레시 토큰입니다.")); + } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 594001d3..7ee8d18f 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -323,4 +323,61 @@ void logout_invalidToken() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } + + @Test + @DisplayName("정상 토큰 재발급 성공 → 새로운 AccessToken 반환 및 헤더 설정") + void refreshToken_success() throws InterruptedException { + // given: 로그인된 사용자 준비 + String rawPassword = "P@ssw0rd!"; + User user = setupUser("refreshuser", "refresh@example.com", rawPassword, "닉네임", UserStatus.ACTIVE); + MockHttpServletResponse loginResponse = new MockHttpServletResponse(); + + userService.login(new LoginRequest("refreshuser", rawPassword), loginResponse); + String oldAccessToken = loginResponse.getHeader("Authorization").substring(7); + Cookie refreshCookie = loginResponse.getCookie("refreshToken"); + assertThat(refreshCookie).isNotNull(); + + // 요청/응답 객체 준비 + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(refreshCookie); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // Issued At(발급 시간) 분리를 위해 1초 대기 +// Thread.sleep(1000); + + // when: 토큰 재발급 실행 + String newAccessToken = userService.refreshToken(request, response); + + // then: 반환값 및 응답 헤더 검증 + assertThat(newAccessToken).isNotBlank(); +// assertThat(newAccessToken).isNotEqualTo(oldAccessToken); + assertThat(response.getHeader("Authorization")).isEqualTo("Bearer " + newAccessToken); + } + + @Test + @DisplayName("RefreshToken 없으면 BAD_REQUEST 예외 발생") + void refreshToken_noToken() { + // given: 쿠키 없는 요청 + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then + assertThatThrownBy(() -> userService.refreshToken(request, response)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BAD_REQUEST.getMessage()); + } + + @Test + @DisplayName("유효하지 않은 RefreshToken이면 INVALID_TOKEN 예외 발생") + void refreshToken_invalidToken() { + // given: 잘못된 Refresh Token + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("refreshToken", "invalidToken")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then + assertThatThrownBy(() -> userService.refreshToken(request, response)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } } \ No newline at end of file diff --git a/src/test/java/com/back/fixture/TestJwtTokenProvider.java b/src/test/java/com/back/fixture/TestJwtTokenProvider.java new file mode 100644 index 00000000..44e5976e --- /dev/null +++ b/src/test/java/com/back/fixture/TestJwtTokenProvider.java @@ -0,0 +1,45 @@ +package com.back.fixture; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * 테스트용 JWT 토큰 생성기 + */ +@Component +public class TestJwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + private SecretKey key; + + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + /** + * 만료된 리프레시 토큰 생성 + * + * @param userId 사용자 ID + * @return 만료된 JWT 리프레시 토큰 문자열 + */ + public String createExpiredRefreshToken(Long userId) { + Date issuedAt = new Date(System.currentTimeMillis() - 2000L); // 2초 전 발급 + Date expiredAt = new Date(System.currentTimeMillis() - 1000L); // 1초 전 만료 + + return Jwts.builder() + .subject(String.valueOf(userId)) + .issuedAt(issuedAt) + .expiration(expiredAt) + .signWith(key) + .compact(); + } +}