From 31306460b6c053bd07635d18e2636eccdbc0bce7 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:04:26 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Feat:=20Refesh=20Token=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=B0=8F=20=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/user/entity/UserToken.java | 2 ++ .../user/repository/UserTokenRepository.java | 13 +++++++++++++ .../back/domain/user/service/UserService.java | 17 +++++++++++++++-- .../back/global/security/JwtTokenProvider.java | 2 ++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/back/domain/user/repository/UserTokenRepository.java diff --git a/src/main/java/com/back/domain/user/entity/UserToken.java b/src/main/java/com/back/domain/user/entity/UserToken.java index be514ca0..0b98061c 100644 --- a/src/main/java/com/back/domain/user/entity/UserToken.java +++ b/src/main/java/com/back/domain/user/entity/UserToken.java @@ -2,6 +2,7 @@ import com.back.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,6 +10,7 @@ @Entity @NoArgsConstructor +@AllArgsConstructor @Getter public class UserToken extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/back/domain/user/repository/UserTokenRepository.java b/src/main/java/com/back/domain/user/repository/UserTokenRepository.java new file mode 100644 index 00000000..34f50460 --- /dev/null +++ b/src/main/java/com/back/domain/user/repository/UserTokenRepository.java @@ -0,0 +1,13 @@ +package com.back.domain.user.repository; + +import com.back.domain.user.entity.UserToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserTokenRepository extends JpaRepository { + Optional findByRefreshToken(String refreshToken); + void deleteByRefreshToken(String refreshToken); +} 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 4ce65773..31fcd8d3 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -6,25 +6,31 @@ import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.entity.UserToken; import com.back.domain.user.repository.UserProfileRepository; import com.back.domain.user.repository.UserRepository; +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 jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final UserTokenRepository userTokenRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -106,13 +112,20 @@ public UserResponse login(LoginRequest request, HttpServletResponse response) { String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); - // TODO: Refresh Token 저장소에 저장 로직 추가 예정 (현재는 stateless 방식) + // DB에 Refresh Token 저장 + UserToken userToken = new UserToken( + user, + refreshToken, + LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenExpirationInSeconds()) + ); + userTokenRepository.save(userToken); + // Refresh Token을 HttpOnly 쿠키로 설정 Cookie cookie = new Cookie("refreshToken", refreshToken); cookie.setHttpOnly(true); cookie.setSecure(true); cookie.setPath("/api/auth/refresh"); - cookie.setMaxAge(7 * 24 * 60 * 60); // TODO: 하드 코딩된 만료 시간 상수로 분리 + cookie.setMaxAge((int) jwtTokenProvider.getRefreshTokenExpirationInSeconds()); response.addCookie(cookie); // Access Token을 응답 헤더에 설정 diff --git a/src/main/java/com/back/global/security/JwtTokenProvider.java b/src/main/java/com/back/global/security/JwtTokenProvider.java index 3f34d4ef..9937f0d5 100644 --- a/src/main/java/com/back/global/security/JwtTokenProvider.java +++ b/src/main/java/com/back/global/security/JwtTokenProvider.java @@ -8,6 +8,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; +import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -33,6 +34,7 @@ public class JwtTokenProvider { @Value("${jwt.access-token-expiration}") private long accessTokenExpirationInSeconds; + @Getter @Value("${jwt.refresh-token-expiration}") private long refreshTokenExpirationInSeconds; From d0d53f0564c09517be05abfbdca6669c74cc259e 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 2/6] =?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 | 21 +++++++++++ .../back/domain/user/service/UserService.java | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+) 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 0d8c4545..8fbb1f95 100644 --- a/src/main/java/com/back/domain/user/controller/AuthController.java +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -8,6 +8,7 @@ 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; import lombok.RequiredArgsConstructor; @@ -66,4 +67,24 @@ public ResponseEntity> login( loginResponse )); } + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "Refresh Token을 무효화합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "401", description = "이미 만료되었거나 유효하지 않은 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> logout( + HttpServletRequest request, + HttpServletResponse response + ) { + userService.logout(request, response); + return ResponseEntity + .ok(RsData.success( + "로그아웃 되었습니다.", + null + )); + } } 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 31fcd8d3..62668316 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); + } + /** * 회원가입 시 중복 검증 * - username, email, nickname @@ -162,4 +184,18 @@ private void validatePasswordPolicy(String password) { throw new CustomException(ErrorCode.INVALID_PASSWORD); } } + + /** + * 쿠키에서 Refresh Token 추출 + */ + private String resolveRefreshToken(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } } From 1d64850af35f177cce225a79af3c7dd5134319d7 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 3/6] =?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 --- .../back/domain/user/service/UserService.java | 22 +++++++--------- .../java/com/back/global/util/CookieUtil.java | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/back/global/util/CookieUtil.java 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 62668316..4de60e53 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -14,6 +14,7 @@ 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; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -121,12 +122,13 @@ public UserResponse login(LoginRequest request, HttpServletResponse response) { userTokenRepository.save(userToken); // Refresh Token을 HttpOnly 쿠키로 설정 - Cookie cookie = new Cookie("refreshToken", refreshToken); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/api/auth/refresh"); - cookie.setMaxAge((int) jwtTokenProvider.getRefreshTokenExpirationInSeconds()); - response.addCookie(cookie); + CookieUtil.addCookie( + response, + "refreshToken", + refreshToken, + (int) jwtTokenProvider.getRefreshTokenExpirationInSeconds(), + "/api/auth" + ); // Access Token을 응답 헤더에 설정 response.setHeader("Authorization", "Bearer " + accessToken); @@ -147,14 +149,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"); } /** diff --git a/src/main/java/com/back/global/util/CookieUtil.java b/src/main/java/com/back/global/util/CookieUtil.java new file mode 100644 index 00000000..d8290fcc --- /dev/null +++ b/src/main/java/com/back/global/util/CookieUtil.java @@ -0,0 +1,25 @@ +package com.back.global.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +public class CookieUtil { + + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge, String path) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath(path); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void clearCookie(HttpServletResponse response, String name, String path) { + Cookie cookie = new Cookie(name, null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath(path); + cookie.setMaxAge(0); + response.addCookie(cookie); + } +} \ No newline at end of file From da29834abdf989f4ee3e778f5c16e3527ae1b9c9 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:35:55 +0900 Subject: [PATCH 4/6] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 29 +- .../user/controller/AuthControllerDocs.java | 305 ++++++++++++++++++ 2 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/back/domain/user/controller/AuthControllerDocs.java 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 8fbb1f95..4ee3261a 100644 --- a/src/main/java/com/back/domain/user/controller/AuthController.java +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -22,20 +22,11 @@ @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { private final UserService userService; + // 회원가입 @PostMapping("/register") - @Operation( - summary = "회원가입", - description = "신규 사용자를 등록합니다." - ) - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "회원가입 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 / 비밀번호 정책 위반"), - @ApiResponse(responseCode = "409", description = "중복된 아이디/이메일/닉네임"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) public ResponseEntity> register( @Valid @RequestBody UserRegisterRequest request ) { @@ -48,14 +39,8 @@ public ResponseEntity> register( )); } + // 로그인 @PostMapping("/login") - @Operation(summary = "로그인", description = "username + password로 로그인합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - @ApiResponse(responseCode = "401", description = "잘못된 아이디/비밀번호"), - @ApiResponse(responseCode = "403", description = "이메일 미인증/정지 계정"), - @ApiResponse(responseCode = "410", description = "탈퇴한 계정") - }) public ResponseEntity> login( @Valid @RequestBody LoginRequest request, HttpServletResponse response @@ -68,14 +53,8 @@ public ResponseEntity> login( )); } + // 로그아웃 @PostMapping("/logout") - @Operation(summary = "로그아웃", description = "Refresh Token을 무효화합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청"), - @ApiResponse(responseCode = "401", description = "이미 만료되었거나 유효하지 않은 토큰"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) public ResponseEntity> logout( HttpServletRequest request, HttpServletResponse response diff --git a/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java new file mode 100644 index 00000000..bd5c3d79 --- /dev/null +++ b/src/main/java/com/back/domain/user/controller/AuthControllerDocs.java @@ -0,0 +1,305 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.dto.LoginRequest; +import com.back.domain.user.dto.UserRegisterRequest; +import com.back.domain.user.dto.UserResponse; +import com.back.global.common.dto.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Auth API", description = "인증/인가 관련 API") +public interface AuthControllerDocs { + + @Operation( + summary = "회원가입", + description = "신규 사용자를 등록합니다. " + + "회원가입 시 기본 상태는 `PENDING`이며, 추후 이메일 인증 완료 시 `ACTIVE`로 변경됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "회원가입 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "회원가입이 성공적으로 완료되었습니다. 이메일 인증을 완료해주세요.", + "data": { + "userId": 1, + "username": "testuser", + "email": "test@example.com", + "nickname": "홍길동", + "role": "USER", + "status": "PENDING", + "createdAt": "2025-09-19T15:00:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "409", + description = "중복된 아이디/이메일/닉네임", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "중복 아이디", value = """ + { + "success": false, + "code": "USER_002", + "message": "이미 사용 중인 아이디입니다.", + "data": null + } + """), + @ExampleObject(name = "중복 이메일", value = """ + { + "success": false, + "code": "USER_003", + "message": "이미 사용 중인 이메일입니다.", + "data": null + } + """), + @ExampleObject(name = "중복 닉네임", value = """ + { + "success": false, + "code": "USER_004", + "message": "이미 사용 중인 닉네임입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 / 비밀번호 정책 위반", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "비밀번호 정책 위반", value = """ + { + "success": false, + "code": "USER_005", + "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 요청", value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> register( + @Valid @RequestBody UserRegisterRequest request + ); + + @Operation( + summary = "로그인", + description = "username + password로 로그인합니다. " + + "로그인 성공 시 Access Token은 `Authorization` 헤더에, Refresh Token은 HttpOnly 쿠키로 발급됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "로그인에 성공했습니다.", + "data": { + "userId": 1, + "username": "testuser", + "email": "test@example.com", + "nickname": "홍길동", + "role": "USER", + "status": "ACTIVE", + "createdAt": "2025-09-19T15:00:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "잘못된 아이디/비밀번호", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_006", + "message": "아이디 또는 비밀번호가 올바르지 않습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "이메일 미인증 / 정지 계정", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "이메일 미인증", value = """ + { + "success": false, + "code": "USER_007", + "message": "이메일 인증 후 로그인할 수 있습니다.", + "data": null + } + """), + @ExampleObject(name = "정지 계정", value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> login( + @Valid @RequestBody LoginRequest request, + HttpServletResponse response + ); + + @Operation( + summary = "로그아웃", + description = "사용자의 Refresh Token을 무효화하여 더 이상 토큰 재발급이 불가능하게 합니다. " + + "Access Token은 클라이언트(프론트엔드) 메모리에서 삭제해야 합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "로그아웃 되었습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "이미 만료되었거나 유효하지 않은 Refresh Token", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "AUTH_401", + "message": "이미 만료되었거나 유효하지 않은 토큰입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (토큰 없음 / 형식 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> logout( + HttpServletRequest request, + HttpServletResponse response + ); +} From 668bdf708e1cc3cbc28c73c4725c2d66b3a2978d 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 5/6] =?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 --- .../back/domain/user/service/UserService.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 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 4de60e53..9fc0f33a 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -84,13 +84,11 @@ public UserResponse register(UserRegisterRequest request) { /** * 로그인 서비스 - * 1. 사용자 조회 (username) - * 2. 비밀번호 검증 - * 3. 사용자 상태 체크 (PENDING, SUSPENDED, DELETED) - * 4. Access Token, Refresh Token 생성 - * 5. Refresh Token을 HttpOnly 쿠키로 설정 - * 6. Access Token을 응답 헤더에 설정 - * 7. UserResponse 반환 + * 1. 사용자 조회 및 비밀번호 검증 + * 2. 사용자 상태 검증 (PENDING, SUSPENDED, DELETED) + * 3. Access/Refresh Token 발급 + * 4. Refresh Token을 HttpOnly 쿠키로, Access Token은 헤더로 설정 + * 5. UserResponse 반환 */ public UserResponse login(LoginRequest request, HttpServletResponse response) { // 사용자 조회 @@ -137,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 1a926978f5d8dec2426df14549b5a4500674d289 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 6/6] =?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 --- .../back/domain/user/service/UserService.java | 9 ++- .../user/controller/AuthControllerTest.java | 74 +++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 60 +++++++++++++++ 3 files changed, 141 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 9fc0f33a..53913dc6 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); } 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 8d333390..2cd080f4 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,7 @@ import com.back.domain.user.entity.UserProfile; import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserRepository; +import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -367,4 +368,77 @@ void login_deletedUser() throws Exception { .andExpect(status().isGone()) .andExpect(jsonPath("$.code").value("USER_009")); } + + @Test + @DisplayName("정상 로그아웃 → 200 OK + RefreshToken 쿠키 만료") + void logout_success() throws Exception { + // given: 회원가입 + 로그인으로 refreshToken 쿠키 확보 + String rawPassword = "P@ssw0rd!"; + String registerBody = """ + { + "username": "logoutuser", + "email": "logout@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": "logoutuser", + "password": "%s" + } + """.formatted(rawPassword); + + ResultActions loginResult = mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginBody)) + .andExpect(status().isOk()); + + // 로그인 응답에서 refreshToken 쿠키 추출 + String refreshCookie = loginResult.andReturn() + .getResponse() + .getCookie("refreshToken") + .getValue(); + + // when: 로그아웃 요청 (쿠키 포함) + ResultActions logoutResult = mvc.perform(post("/api/auth/logout") + .cookie(new Cookie("refreshToken", refreshCookie))) + .andDo(print()); + + // then: 200 OK + 성공 메시지 + 쿠키 만료 + logoutResult + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) + .andExpect(cookie().maxAge("refreshToken", 0)); + } + + @Test + @DisplayName("RefreshToken 누락 → 400 Bad Request") + void logout_noToken() throws Exception { + // when & then + mvc.perform(post("/api/auth/logout")) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")); + } + + @Test + @DisplayName("유효하지 않은 RefreshToken → 401 Unauthorized") + void logout_invalidToken() throws Exception { + // given: 잘못된 refreshToken 쿠키 + Cookie invalid = new Cookie("refreshToken", "fake-token"); + + // when & then + mvc.perform(post("/api/auth/logout").cookie(invalid)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_401")); + } } \ 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 eaba533c..594001d3 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -7,6 +7,7 @@ import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserProfileRepository; import com.back.domain.user.repository.UserRepository; +import com.back.domain.user.repository.UserTokenRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import jakarta.servlet.http.Cookie; @@ -14,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; @@ -36,6 +38,9 @@ class UserServiceTest { @Autowired private UserProfileRepository userProfileRepository; + @Autowired + private UserTokenRepository userTokenRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -263,4 +268,59 @@ void login_deletedUser() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.USER_DELETED.getMessage()); } + @Test + @DisplayName("정상 로그아웃 성공 → RefreshToken DB 삭제 + 쿠키 만료") + void logout_success() { + // given: 정상 로그인된 사용자 + String rawPassword = "P@ssw0rd!"; + User user = setupUser("logoutuser", "logout@example.com", rawPassword, "닉네임", UserStatus.ACTIVE); + MockHttpServletResponse loginResponse = new MockHttpServletResponse(); + + userService.login(new LoginRequest("logoutuser", rawPassword), loginResponse); + Cookie refreshCookie = loginResponse.getCookie("refreshToken"); + assertThat(refreshCookie).isNotNull(); + + MockHttpServletResponse logoutResponse = new MockHttpServletResponse(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(refreshCookie); // 쿠키를 요청에 실어줌 + + // when: 로그아웃 실행 + userService.logout(request, logoutResponse); + + // then: DB에서 refreshToken 삭제됨 + assertThat(userTokenRepository.findByRefreshToken(refreshCookie.getValue())).isEmpty(); + + // 응답 쿠키는 만료 처리됨 + Cookie cleared = logoutResponse.getCookie("refreshToken"); + assertThat(cleared).isNotNull(); + assertThat(cleared.getMaxAge()).isZero(); + assertThat(cleared.getValue()).isNull(); + } + + @Test + @DisplayName("RefreshToken 없으면 INVALID_TOKEN 예외 발생") + void logout_noToken() { + // given: 쿠키 없이 로그아웃 요청 + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then + assertThatThrownBy(() -> userService.logout(request, response)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BAD_REQUEST.getMessage()); + } + + @Test + @DisplayName("유효하지 않은 RefreshToken이면 INVALID_TOKEN 예외 발생") + void logout_invalidToken() { + // given: 잘못된 토큰 쿠키 세팅 + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("refreshToken", "invalidToken")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then + assertThatThrownBy(() -> userService.logout(request, response)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } } \ No newline at end of file