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..2323366c 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; @@ -19,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 @@ -66,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/controller/AuthControllerDocs.java b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java index bd5c3d79..dbcd5423 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 { @@ -243,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( @@ -258,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( @@ -273,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( @@ -288,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 + } + """) ) ) }) @@ -302,4 +304,88 @@ ResponseEntity> logout( HttpServletRequest request, HttpServletResponse response ); + + @Operation( + summary = "토큰 재발급", + description = "만료된 Access Token 대신 Refresh Token을 이용해 새로운 Access Token을 발급받습니다. " + + "Refresh Token은 HttpOnly 쿠키에서 추출하며, 재발급 성공 시 응답 헤더와 본문에 새로운 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": "AUTH_401", + "message": "만료된 리프레시 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "Refresh Token 위조/무효", value = """ + { + "success": false, + "code": "AUTH_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 + ); } 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..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; @@ -161,6 +160,47 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { CookieUtil.clearCookie(response, "refreshToken", "/api/auth"); } + /** + * 토큰 재발급 서비스 + * 1. 쿠키에서 Refresh Token 추출 + * 2. Refresh Token 검증 (만료/위조 확인) + * 3. DB에 저장된 Refresh Token 여부 확인 + * 4. 새 Access Token 발급 + */ + public String refreshToken(HttpServletRequest request, HttpServletResponse response) { + // Refresh Token 검증 + String refreshToken = resolveRefreshToken(request); + + // Refresh Token 존재 여부 확인 + if (refreshToken == null) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + // Refresh Token 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + + // DB에서 Refresh Token 조회 + UserToken userToken = userTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); + + // 사용자 정보 조회 + 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; + } + /** * 회원가입 시 중복 검증 * - username, email, nickname 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(); + } +}