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..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,12 +1,12 @@ 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.LoginSuccessResponse; +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; import com.beat.global.common.dto.ErrorResponse; @@ -44,10 +44,10 @@ public interface MemberApi { ) } ) - ResponseEntity> signUp( + 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 227f6034..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,18 +2,19 @@ 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; 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,21 +52,24 @@ 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 @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 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); + } + } + } 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); - } -} 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); + } +} 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 */