From b83520b8df716cc237dfe27df8b943ee7498ac86 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sat, 28 Dec 2024 20:20:12 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#295]=20feat(MemberLoginResponse):=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/dto/MemberLoginResponse.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java diff --git a/src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java b/src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java new file mode 100644 index 00000000..38b67af2 --- /dev/null +++ b/src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java @@ -0,0 +1,15 @@ +package com.beat.domain.member.dto; + +public record MemberLoginResponse( + String accessToken, + String nickname, + String role +) { + public static MemberLoginResponse of( + final String accessToken, + final String nickname, + final String role + ) { + return new MemberLoginResponse(accessToken, nickname, role); + } +} From 2c556132a975e5a91b2d047b730b4017e31ca2e2 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sat, 28 Dec 2024 20:20:47 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#295]=20refactor:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20DTO=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/beat/domain/member/api/MemberApi.java | 4 ++-- .../domain/member/api/MemberController.java | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/beat/domain/member/api/MemberApi.java b/src/main/java/com/beat/domain/member/api/MemberApi.java index e6309ca1..435f64f7 100644 --- a/src/main/java/com/beat/domain/member/api/MemberApi.java +++ b/src/main/java/com/beat/domain/member/api/MemberApi.java @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.RequestParam; import com.beat.domain.member.dto.AccessTokenGetSuccess; -import com.beat.domain.member.dto.LoginSuccessResponse; +import com.beat.domain.member.dto.MemberLoginResponse; import com.beat.global.auth.annotation.CurrentMember; import com.beat.global.auth.client.dto.MemberLoginRequest; import com.beat.global.common.dto.ErrorResponse; @@ -44,7 +44,7 @@ public interface MemberApi { ) } ) - ResponseEntity> signUp( + ResponseEntity> signUp( @RequestParam final String authorizationCode, @RequestBody final MemberLoginRequest loginRequest, HttpServletResponse response diff --git a/src/main/java/com/beat/domain/member/api/MemberController.java b/src/main/java/com/beat/domain/member/api/MemberController.java index 227f6034..58bd1635 100644 --- a/src/main/java/com/beat/domain/member/api/MemberController.java +++ b/src/main/java/com/beat/domain/member/api/MemberController.java @@ -14,6 +14,7 @@ import com.beat.domain.member.application.SocialLoginService; import com.beat.domain.member.dto.AccessTokenGetSuccess; import com.beat.domain.member.dto.LoginSuccessResponse; +import com.beat.domain.member.dto.MemberLoginResponse; import com.beat.domain.member.exception.MemberSuccessCode; import com.beat.global.auth.annotation.CurrentMember; import com.beat.global.auth.client.dto.MemberLoginRequest; @@ -36,13 +37,14 @@ public class MemberController implements MemberApi { @Override @PostMapping("/sign-up") - public ResponseEntity> signUp( + public ResponseEntity> signUp( @RequestParam final String authorizationCode, @RequestBody final MemberLoginRequest loginRequest, - HttpServletResponse response + HttpServletResponse httpServletResponse ) { LoginSuccessResponse loginSuccessResponse = socialLoginService.handleSocialLogin(authorizationCode, loginRequest); + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, loginSuccessResponse.refreshToken()) .maxAge(COOKIE_MAX_AGE) .path("/") @@ -50,11 +52,13 @@ public ResponseEntity> signUp( .sameSite("None") .httpOnly(true) .build(); - response.setHeader("Set-Cookie", cookie.toString()); + httpServletResponse.setHeader("Set-Cookie", cookie.toString()); + + MemberLoginResponse response = MemberLoginResponse.of(loginSuccessResponse.accessToken(), loginSuccessResponse.nickname(), + loginSuccessResponse.role()); + return ResponseEntity.ok() - .body(SuccessResponse.of(MemberSuccessCode.SIGN_UP_SUCCESS, - LoginSuccessResponse.of(loginSuccessResponse.accessToken(), null, loginSuccessResponse.nickname(), - loginSuccessResponse.role()))); + .body(SuccessResponse.of(MemberSuccessCode.SIGN_UP_SUCCESS, response)); } @Override @@ -62,7 +66,8 @@ public ResponseEntity> signUp( public ResponseEntity> issueAccessTokenUsingRefreshToken( @RequestHeader("Authorization_Refresh") final String refreshToken ) { - AccessTokenGetSuccess accessTokenGetSuccess = authenticationService.generateAccessTokenFromRefreshToken(refreshToken); + AccessTokenGetSuccess accessTokenGetSuccess = authenticationService.generateAccessTokenFromRefreshToken( + refreshToken); return ResponseEntity.ok() .body(SuccessResponse.of(MemberSuccessCode.ISSUE_ACCESS_TOKEN_USING_REFRESH_TOKEN, accessTokenGetSuccess)); } From 8e0505495dd5927b7075e12f2198bb6245a24d89 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sun, 29 Dec 2024 00:06:39 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#295]=20rename(AccessTokenGenerateResponse?= =?UTF-8?q?):=20DTO=20=EC=9E=90=EC=B2=B4=20=EB=AA=85=EB=AA=85=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/dto/AccessTokenGenerateResponse.java | 11 +++++++++++ .../beat/domain/member/dto/AccessTokenGetSuccess.java | 11 ----------- 2 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java delete mode 100644 src/main/java/com/beat/domain/member/dto/AccessTokenGetSuccess.java diff --git a/src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java b/src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java new file mode 100644 index 00000000..db4be892 --- /dev/null +++ b/src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java @@ -0,0 +1,11 @@ +package com.beat.domain.member.dto; + +public record AccessTokenGenerateResponse( + String accessToken +) { + public static AccessTokenGenerateResponse from( + final String accessToken + ) { + return new AccessTokenGenerateResponse(accessToken); + } +} diff --git a/src/main/java/com/beat/domain/member/dto/AccessTokenGetSuccess.java b/src/main/java/com/beat/domain/member/dto/AccessTokenGetSuccess.java deleted file mode 100644 index d297d881..00000000 --- a/src/main/java/com/beat/domain/member/dto/AccessTokenGetSuccess.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.beat.domain.member.dto; - -public record AccessTokenGetSuccess( - String accessToken -) { - public static AccessTokenGetSuccess of( - final String accessToken - ) { - return new AccessTokenGetSuccess(accessToken); - } -} From e78a96ad2fa87a4ae7613e07b245ccf23a963d55 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sun, 29 Dec 2024 00:09:12 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#295]=20fix(AuthenticationService):=20Bear?= =?UTF-8?q?er=20=EC=A0=91=EB=91=90=EC=82=AC=20substring=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿠키에서 바로 리프레쉬 토큰을 추출하기 때문에 Bearer 접두사가 붙지 않음. - 추가적으로 복잡한 로직은 private 메서드로 분리함. --- .../application/AuthenticationService.java | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/beat/domain/member/application/AuthenticationService.java b/src/main/java/com/beat/domain/member/application/AuthenticationService.java index b712574c..5984d7fb 100644 --- a/src/main/java/com/beat/domain/member/application/AuthenticationService.java +++ b/src/main/java/com/beat/domain/member/application/AuthenticationService.java @@ -1,6 +1,6 @@ package com.beat.domain.member.application; -import com.beat.domain.member.dto.AccessTokenGetSuccess; +import com.beat.domain.member.dto.AccessTokenGenerateResponse; import com.beat.domain.member.dto.LoginSuccessResponse; import com.beat.domain.user.domain.Role; import com.beat.domain.user.domain.Users; @@ -64,51 +64,30 @@ public LoginSuccessResponse generateLoginSuccessResponse(final Long memberId, fi } /** - * Refresh Token을 사용하여 새로운 Access Token을 생성하는 메서드. + * 쿠키에서 "refreshToken" 값을 가져와 유효성을 검증하고, + * 유효한 Refresh Token일 경우 새로운 Access Token을 생성합니다. * * Refresh Token에서 사용자 ID와 Role 정보를 추출한 후, * Role에 따라 Admin 또는 Member 권한으로 새로운 Access Token을 발급합니다. * - * @param refreshTokenWithBearer "Bearer + 사용자의 Refresh Token" - * @return 새로운 Access Token 정보가 포함된 AccessTokenGetSuccess 객체 + * @param refreshToken "사용자의 Refresh Token" + * @return 새로운 Access Token 정보가 포함된 AccessTokenGenerateResponse 객체 */ @Transactional - public AccessTokenGetSuccess generateAccessTokenFromRefreshToken(final String refreshTokenWithBearer) { - String refreshToken = refreshTokenWithBearer; - if (refreshToken.startsWith(BEARER_PREFIX)) { - refreshToken = refreshToken.substring(BEARER_PREFIX.length()); - } - - log.info("Validation result for refresh token: {}", jwtTokenProvider.validateToken(refreshToken)); - - JwtValidationType validationType = jwtTokenProvider.validateToken(refreshToken); - if (!validationType.equals(JwtValidationType.VALID_JWT)) { - log.warn("Invalid refresh token: {}", validationType); - throw switch (validationType) { - case EXPIRED_JWT_TOKEN -> new UnauthorizedException(TokenErrorCode.REFRESH_TOKEN_EXPIRED_ERROR); - case INVALID_JWT_TOKEN -> new BadRequestException(TokenErrorCode.INVALID_REFRESH_TOKEN_ERROR); - case INVALID_JWT_SIGNATURE -> new BadRequestException(TokenErrorCode.REFRESH_TOKEN_SIGNATURE_ERROR); - case UNSUPPORTED_JWT_TOKEN -> new BadRequestException(TokenErrorCode.UNSUPPORTED_REFRESH_TOKEN_ERROR); - case EMPTY_JWT -> new BadRequestException(TokenErrorCode.REFRESH_TOKEN_EMPTY_ERROR); - default -> new BeatException(TokenErrorCode.UNKNOWN_REFRESH_TOKEN_ERROR); - }; - } + public AccessTokenGenerateResponse generateAccessTokenFromRefreshToken(final String refreshToken) { + validateRefreshToken(refreshToken); Long memberId = jwtTokenProvider.getMemberIdFromJwt(refreshToken); - - if (!memberId.equals(tokenService.findIdByRefreshToken(refreshToken))) { - log.error("MemberId mismatch: token does not match the stored refresh token"); - throw new BadRequestException(TokenErrorCode.REFRESH_TOKEN_MEMBER_ID_MISMATCH_ERROR); - } + verifyMemberIdWithStoredToken(refreshToken, memberId); Role role = jwtTokenProvider.getRoleFromJwt(refreshToken); Collection authorities = List.of(role.toGrantedAuthority()); - UsernamePasswordAuthenticationToken authenticationToken = createAuthenticationToken(memberId, role, - authorities); + UsernamePasswordAuthenticationToken authenticationToken = createAuthenticationToken(memberId, role, authorities); log.info("Generated new access token for memberId: {}, role: {}, authorities: {}", memberId, role.getRoleName(), authorities); - return AccessTokenGetSuccess.of(jwtTokenProvider.issueAccessToken(authenticationToken)); + + return AccessTokenGenerateResponse.from(jwtTokenProvider.issueAccessToken(authenticationToken)); } /** @@ -144,4 +123,29 @@ private UsernamePasswordAuthenticationToken createAuthenticationToken(Long membe return new MemberAuthentication(memberId, null, authorities); } } + + private void validateRefreshToken(String refreshToken) { + JwtValidationType validationType = jwtTokenProvider.validateToken(refreshToken); + + if (!validationType.equals(JwtValidationType.VALID_JWT)) { + throw switch (validationType) { + case EXPIRED_JWT_TOKEN -> new UnauthorizedException(TokenErrorCode.REFRESH_TOKEN_EXPIRED_ERROR); + case INVALID_JWT_TOKEN -> new BadRequestException(TokenErrorCode.INVALID_REFRESH_TOKEN_ERROR); + case INVALID_JWT_SIGNATURE -> new BadRequestException(TokenErrorCode.REFRESH_TOKEN_SIGNATURE_ERROR); + case UNSUPPORTED_JWT_TOKEN -> new BadRequestException(TokenErrorCode.UNSUPPORTED_REFRESH_TOKEN_ERROR); + case EMPTY_JWT -> new BadRequestException(TokenErrorCode.REFRESH_TOKEN_EMPTY_ERROR); + default -> new BeatException(TokenErrorCode.UNKNOWN_REFRESH_TOKEN_ERROR); + }; + } + } + + private void verifyMemberIdWithStoredToken(String refreshToken, Long memberId) { + Long storedMemberId = tokenService.findIdByRefreshToken(refreshToken); + + if (!memberId.equals(storedMemberId)) { + log.error("MemberId mismatch: token does not match the stored refresh token"); + throw new BadRequestException(TokenErrorCode.REFRESH_TOKEN_MEMBER_ID_MISMATCH_ERROR); + } + } + } From 2069db1b8f90efdf99539ddef399991205fef938 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sun, 29 Dec 2024 00:11:41 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#295]=20feat(GlobalExceptionHandler):=20Mi?= =?UTF-8?q?ssingRequestCookieException=EC=9D=84=20handler=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @CookieValue 어노테이션의 required가 true인데 쿠키 값이 없는 상태에서 요청할 경우 예외 발생 --- .../global/common/handler/GlobalExceptionHandler.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/beat/global/common/handler/GlobalExceptionHandler.java b/src/main/java/com/beat/global/common/handler/GlobalExceptionHandler.java index fc31f7cc..60a1ca64 100644 --- a/src/main/java/com/beat/global/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/beat/global/common/handler/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestCookieException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -67,6 +68,14 @@ public ResponseEntity handleMethodArgumentTypeMismatchException( .body(ErrorResponse.of(HttpStatus.BAD_REQUEST.value(), errorMessage + " (Expected: " + requiredType + ")")); } + @ExceptionHandler(MissingRequestCookieException.class) + public ResponseEntity handleMissingRequestCookieException(MissingRequestCookieException e) { + log.warn("MissingRequestCookieException: {}", e.getMessage()); + String message = String.format("Missing required cookie: %s", e.getCookieName()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST.value(), message)); + } + /** * 401 UNAUTHORIZED */ From 2a638c7e1b4c6184fca64c4235bf4962e63fc8a1 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Sun, 29 Dec 2024 00:12:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#295]=20fix:=20HttpOnly=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=EC=97=90=EC=84=9C=20=EC=9E=90=EB=8F=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20refreshToken=EC=9D=84=20=EC=B6=94=EC=B6=9C=ED=95=B4?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/beat/domain/member/api/MemberApi.java | 10 +++++----- .../beat/domain/member/api/MemberController.java | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/beat/domain/member/api/MemberApi.java b/src/main/java/com/beat/domain/member/api/MemberApi.java index 435f64f7..1f2335b9 100644 --- a/src/main/java/com/beat/domain/member/api/MemberApi.java +++ b/src/main/java/com/beat/domain/member/api/MemberApi.java @@ -1,11 +1,11 @@ package com.beat.domain.member.api; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; -import com.beat.domain.member.dto.AccessTokenGetSuccess; +import com.beat.domain.member.dto.AccessTokenGenerateResponse; import com.beat.domain.member.dto.MemberLoginResponse; import com.beat.global.auth.annotation.CurrentMember; import com.beat.global.auth.client.dto.MemberLoginRequest; @@ -47,7 +47,7 @@ public interface MemberApi { ResponseEntity> signUp( @RequestParam final String authorizationCode, @RequestBody final MemberLoginRequest loginRequest, - HttpServletResponse response + HttpServletResponse httpServletResponse ); @Operation(summary = "access token 재발급 API", description = "refresh token으로 access token을 재발급하는 GET API입니다.") @@ -64,8 +64,8 @@ ResponseEntity> signUp( ) } ) - ResponseEntity> issueAccessTokenUsingRefreshToken( - @RequestHeader("Authorization_Refresh") final String refreshToken + ResponseEntity> issueAccessTokenUsingRefreshToken( + @CookieValue(value = "refreshToken") final String refreshToken ); @Operation(summary = "로그아웃 API", description = "로그아웃하는 POST API입니다.") diff --git a/src/main/java/com/beat/domain/member/api/MemberController.java b/src/main/java/com/beat/domain/member/api/MemberController.java index 58bd1635..ba4206b0 100644 --- a/src/main/java/com/beat/domain/member/api/MemberController.java +++ b/src/main/java/com/beat/domain/member/api/MemberController.java @@ -2,17 +2,17 @@ import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.beat.domain.member.application.AuthenticationService; import com.beat.domain.member.application.SocialLoginService; -import com.beat.domain.member.dto.AccessTokenGetSuccess; +import com.beat.domain.member.dto.AccessTokenGenerateResponse; import com.beat.domain.member.dto.LoginSuccessResponse; import com.beat.domain.member.dto.MemberLoginResponse; import com.beat.domain.member.exception.MemberSuccessCode; @@ -54,7 +54,8 @@ public ResponseEntity> signUp( .build(); httpServletResponse.setHeader("Set-Cookie", cookie.toString()); - MemberLoginResponse response = MemberLoginResponse.of(loginSuccessResponse.accessToken(), loginSuccessResponse.nickname(), + MemberLoginResponse response = MemberLoginResponse.of(loginSuccessResponse.accessToken(), + loginSuccessResponse.nickname(), loginSuccessResponse.role()); return ResponseEntity.ok() @@ -63,13 +64,12 @@ public ResponseEntity> signUp( @Override @GetMapping("/refresh-token") - public ResponseEntity> issueAccessTokenUsingRefreshToken( - @RequestHeader("Authorization_Refresh") final String refreshToken + public ResponseEntity> issueAccessTokenUsingRefreshToken( + @CookieValue(value = REFRESH_TOKEN) final String refreshToken ) { - AccessTokenGetSuccess accessTokenGetSuccess = authenticationService.generateAccessTokenFromRefreshToken( - refreshToken); + AccessTokenGenerateResponse response = authenticationService.generateAccessTokenFromRefreshToken(refreshToken); return ResponseEntity.ok() - .body(SuccessResponse.of(MemberSuccessCode.ISSUE_ACCESS_TOKEN_USING_REFRESH_TOKEN, accessTokenGetSuccess)); + .body(SuccessResponse.of(MemberSuccessCode.ISSUE_ACCESS_TOKEN_USING_REFRESH_TOKEN, response)); } @Override