Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[fix] #295 - HttpOnly 쿠키에서 자동으로 refreshToken을 추출해 처리하도록 수정 완료 #296

Merged
merged 7 commits into from
Dec 28, 2024
14 changes: 7 additions & 7 deletions src/main/java/com/beat/domain/member/api/MemberApi.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,10 +44,10 @@ public interface MemberApi {
)
}
)
ResponseEntity<SuccessResponse<LoginSuccessResponse>> signUp(
ResponseEntity<SuccessResponse<MemberLoginResponse>> 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입니다.")
Expand All @@ -64,8 +64,8 @@ ResponseEntity<SuccessResponse<LoginSuccessResponse>> signUp(
)
}
)
ResponseEntity<SuccessResponse<AccessTokenGetSuccess>> issueAccessTokenUsingRefreshToken(
@RequestHeader("Authorization_Refresh") final String refreshToken
ResponseEntity<SuccessResponse<AccessTokenGenerateResponse>> issueAccessTokenUsingRefreshToken(
@CookieValue(value = "refreshToken") final String refreshToken
);

@Operation(summary = "로그아웃 API", description = "로그아웃하는 POST API입니다.")
Expand Down
29 changes: 17 additions & 12 deletions src/main/java/com/beat/domain/member/api/MemberController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,35 +37,39 @@ public class MemberController implements MemberApi {

@Override
@PostMapping("/sign-up")
public ResponseEntity<SuccessResponse<LoginSuccessResponse>> signUp(
public ResponseEntity<SuccessResponse<MemberLoginResponse>> 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("/")
.secure(true)
.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<SuccessResponse<AccessTokenGetSuccess>> issueAccessTokenUsingRefreshToken(
@RequestHeader("Authorization_Refresh") final String refreshToken
public ResponseEntity<SuccessResponse<AccessTokenGenerateResponse>> issueAccessTokenUsingRefreshToken(
@CookieValue(value = REFRESH_TOKEN) final String refreshToken
) {
AccessTokenGetSuccess accessTokenGetSuccess = authenticationService.generateAccessTokenFromRefreshToken(refreshToken);
AccessTokenGenerateResponse response = authenticationService.generateAccessTokenFromRefreshToken(refreshToken);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소한 부분이긴 한데 상단에 response이름을 살렸던 것처럼
response 대신 accessTokenGenerateResponse 로 적으면 더 통일성 있을 것 같습니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 저희가 Controller에서 API 작업 시 응닶 DTO return 시 response 로 네이밍을 써왔어서 통일성있게 response로 적어보았습니다!!

(BookingController 참고하시면 좋을 것 같아요!)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 MemberApi에는
HttpServletResponse httpServletResponse
이런식으로 네이밍을 적으셔서 controller에서도 통일하는 게 좋지 않을까 생각했는데 api와 controller도 네이밍 통일하면 좋을 것 같습니다!
저는 개인적으로는 코드 길이가 조금 길어지더라도 이해도를 위해 dto 네이밍을 그대로 사용하는 방식을 더 선호하는데 어떻게 생각하시나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 그대로 사용하는 방식이 좋을 것 같습니다

이 부분 나중에 멀티모듈 도입 시, 한번에 바꾸는거 어떠신가요? (바꿔야 할 네이밍들이 너무 많아서요...)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다~~

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<GrantedAuthority> 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));
}

/**
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}

This file was deleted.

15 changes: 15 additions & 0 deletions src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,6 +68,14 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
.body(ErrorResponse.of(HttpStatus.BAD_REQUEST.value(), errorMessage + " (Expected: " + requiredType + ")"));
}

@ExceptionHandler(MissingRequestCookieException.class)
public ResponseEntity<ErrorResponse> 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
*/
Expand Down