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

[FEAT] 토큰 재발행 API 로직 변경 (TICO-241) #49

Merged
merged 3 commits into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,29 +147,46 @@ public ResponseEntity<SuccessResponseDTO<TokenDTO>> googleJoin(
/**
* 토큰 재발급 API
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param deviceId 기기 고유 번호
* @param refreshToken 리프레시 토큰
* @return 재발급된 토큰을 포함하는 SuccessResponseDTO
*/
@Operation(
summary = "토큰 재발급",
description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 및 리프레시 토큰을 재발급합니다."
description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 및 리프레시 토큰을 재발급합니다.",
parameters = {
@Parameter(
name = "Device-Id",
description = "기기 고유 번호",
in = ParameterIn.HEADER,
required = true
),
@Parameter(
name = "Refresh-Token",
description = "리프레시 토큰",
in = ParameterIn.HEADER,
required = true
)
}
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class))),
@ApiResponse(responseCode = "401", description = "리프레시 토큰이 유효하지 않음",
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class))),
@ApiResponse(responseCode = "400", description = "리프레시 토큰 헤더가 없음",
@ApiResponse(responseCode = "404", description = "기기 ID 또는 리프레시 토큰이 DB에 존재하지 않음",
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class))),
@ApiResponse(responseCode = "500", description = "서버 내부 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
})
@PostMapping("/token/reissue")
public ResponseEntity<SuccessResponseDTO<TokenDTO>> reissueToken(
HttpServletRequest request,
HttpServletResponse response
@RequestHeader("Device-Id") String deviceId,
@RequestHeader("Refresh-Token") String refreshToken
) {
// 엑세스 토큰으로 현재 Redis 정보 삭제
// AuthService의 reissueToken 메서드 호출하여 결과 받기
TokenDTO tokenDTO = authService.reissueToken(request, response);
TokenDTO tokenDTO = authService.reissueToken(deviceId, refreshToken);

SuccessResponseDTO<TokenDTO> successResponse = SuccessResponseDTO.<TokenDTO>builder()
.status(SuccessCode.ACCESS_TOKEN_REISSUED.getHttpStatus().value())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

public interface RefreshRepository extends JpaRepository<Refresh, Long> {

// 해당 리프레쉬 토큰의 존재 여부를 판단하는 메소드
Boolean existsByRefreshToken(String refreshToken);
boolean existsByRefreshToken(String refreshToken);
Optional<Refresh> findByDeviceId(String deviceId);

Optional<Refresh> findByRefreshToken(String refreshToken);


// 해당 리프레쉬 토큰을 삭제하는 메소드
@Transactional
Expand All @@ -16,4 +22,8 @@ public interface RefreshRepository extends JpaRepository<Refresh, Long> {
// 사용자 이름을 기준으로 모든 리프레쉬 토큰을 삭제하는 메소드
@Transactional
void deleteByUsername(String username);

// 디바이스 id를 기준으로 모든 리프레쉬 토큰을 삭제하는 메소드
@Transactional
void deleteByDeviceId(String deviceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ public interface AuthService {
TokenDTO generateAndStoreTokens(String username, String role, HttpServletResponse response);

// Refresh 토큰으로 Access토큰 발급
TokenDTO reissueToken(HttpServletRequest request, HttpServletResponse response);
TokenDTO reissueToken(String deviceId, String refresh);

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.tico.pomoro_do.domain.user.dto.request.GoogleJoinDTO;
import com.tico.pomoro_do.domain.user.dto.response.JwtDTO;
import com.tico.pomoro_do.domain.user.dto.response.TokenDTO;
import com.tico.pomoro_do.domain.user.entity.Refresh;
import com.tico.pomoro_do.domain.user.entity.SocialLogin;
import com.tico.pomoro_do.domain.user.entity.User;
import com.tico.pomoro_do.domain.user.repository.RefreshRepository;
Expand Down Expand Up @@ -130,7 +131,7 @@ public TokenDTO generateAndStoreTokens(String username, String role, HttpServlet
// 리프레시 토큰 생성
String refreshToken = jwtUtil.createJwt("refresh", username, role, refreshExpiration); // 24시간
// 리프레시 토큰을 DB에 저장
tokenService.addRefreshEntity(username, refreshToken, refreshExpiration);
// tokenService.addRefreshEntity(username, refreshToken, refreshExpiration);
// 리프레시 토큰을 쿠키로 응답에 추가
response.addCookie(CookieUtil.createCookie("refresh", refreshToken));

Expand Down Expand Up @@ -237,23 +238,29 @@ public User createUser(String username, String nickname, String profileImageUrl,
/**
* Refresh 토큰을 사용하여 Access 토큰 재발급
*
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @param deviceId 기기 고유 번호
* @param refresh 리프레시 토큰
* @return 새 Access 토큰을 포함하는 TokenDTO
*/
@Transactional
@Override
public TokenDTO reissueToken(HttpServletRequest request, HttpServletResponse response) {
log.info("Refresh 토큰으로 Access 토큰 재발급");
public TokenDTO reissueToken(String deviceId, String refresh) {
log.info("Refresh 토큰으로 Access 토큰 재발급 시도: deviceId = {}", deviceId);

// 요청 쿠키에서 리프레시 토큰을 가져옵니다.
String refresh = CookieUtil.getRefreshToken(request);

log.info("Refresh 토큰 검증 시작");
// 리프레시 토큰을 검증합니다.
log.info("Refresh 토큰 검증 시작: refreshToken = {}", refresh);
tokenService.validateToken(refresh, "refresh");
log.info("Refresh 토큰 검증 완료");

// DB에서 리프레시 토큰에 해당하는 리프레시 토큰 정보를 가져옵니다.
Refresh refreshEntity = tokenService.getRefreshByRefreshToken(refresh);

// DB에 저장된 deviceId와 요청된 deviceId이 일치하는지 확인합니다.
if (!refreshEntity.getDeviceId().equals(deviceId)) {
log.error("Device ID가 DB에 존재하지 않음: deviceId = {}", deviceId);
throw new CustomException(ErrorCode.DEVICE_ID_MISMATCH);
}

// 리프레시 토큰에서 사용자 정보를 추출합니다.
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
Expand All @@ -264,20 +271,13 @@ public TokenDTO reissueToken(HttpServletRequest request, HttpServletResponse res
String newAccess = jwtUtil.createJwt("access", username, role, accessExpiration); // 60분
String newRefresh = jwtUtil.createJwt("refresh", username, role, refreshExpiration);

// DB에서 기존 리프레시 토큰을 삭제하고, 새로운 리프레시 토큰을 저장합니다.
refreshRepository.deleteByRefreshToken(refresh);
tokenService.addRefreshEntity(username, newRefresh, refreshExpiration);

//response
//응답 설정: header
//access 토큰 헤더에 넣어서 응답 (key: value 형태) -> 예시) access: 인증토큰(string)
// response.setHeader("access", newAccess);

// 새로운 리프레시 토큰을 쿠키로 응답에 추가합니다.
response.addCookie(CookieUtil.createCookie("refresh", newRefresh));
// DB에서 deviceId에 해당하는 기존 리프레시 토큰을 삭제하고,
// 새로운 리프레시 토큰을 저장합니다.
refreshRepository.deleteByDeviceId(deviceId);
tokenService.addRefreshEntity(username, newRefresh, refreshExpiration, deviceId);

// 새로운 액세스 토큰을 DTO로 반환합니다.
log.info("Access 토큰 재발급 완료");
log.info("Access 토큰 및 Refresh 토큰 재발급 완료: newAccessToken = {}, newRefreshToken = {}", newAccess, newRefresh);
return new TokenDTO(newAccess);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.tico.pomoro_do.domain.user.service;

import com.tico.pomoro_do.domain.user.entity.Refresh;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public interface TokenService {

// 리프레쉬 토큰 저장
void addRefreshEntity(String username, String refresh, Long expiredMs);
void addRefreshEntity(String username, String refresh, Long expiredMs, String deviceId);

// 토큰 가져오기
Refresh getRefreshByDeviceId(String deviceId);
Refresh getRefreshByRefreshToken(String refreshToken);

// 토큰 검증
void validateToken(String token, String expectedCategory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import com.tico.pomoro_do.global.exception.CustomException;
import com.tico.pomoro_do.global.util.CookieUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -34,21 +37,53 @@ public class TokenServiceImpl implements TokenService{
*/
@Transactional
@Override
public void addRefreshEntity(String username, String refresh, Long expiredMs) {
public void addRefreshEntity(String username, String refresh, Long expiredMs, String deviceId) {

Date date = new Date(System.currentTimeMillis() + expiredMs);

Refresh refreshEntity = Refresh.builder()
.username(username)
.refreshToken(refresh)
.expiration(date.toString())
.deviceId(deviceId)
.build();

refreshRepository.save(refreshEntity);
log.info("리프레시 토큰 저장 성공: 사용자 = {}, 토큰 = {}", username, refresh);

}

/**
* 주어진 deviceId로 리프레시 토큰 엔티티를 가져옵니다.
*
* @param deviceId 기기 고유 번호
* @return Refresh 엔티티
* @throws CustomException 기기 ID가 DB에 존재하지 않을 때 발생하는 예외
*/
@Override
public Refresh getRefreshByDeviceId(String deviceId) {
return refreshRepository.findByDeviceId(deviceId)
.orElseThrow(() -> {
log.error("Device ID가 DB에 존재하지 않음: deviceId = {}", deviceId);
return new CustomException(ErrorCode.DEVICE_ID_NOT_FOUND);
});
}

/**
* 주어진 리프레시 토큰으로 리프레시 토큰 엔티티를 가져옵니다.
*
* @param refreshToken 리프레시 토큰
* @return Refresh 엔티티
* @throws CustomException 리프레시 토큰이 DB에 존재하지 않을 때 발생하는 예외
*/
@Override
public Refresh getRefreshByRefreshToken(String refreshToken) {
return refreshRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> {
log.error("리프레시 토큰이 DB에 존재하지 않음: refreshToken = {}", refreshToken);
return new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND);
});
}

/**
* 주어진 토큰을 검증
Expand All @@ -69,7 +104,7 @@ public void validateToken(String token, String expectedCategory) {
);
}

// 토큰 만료 확인
// 토큰 만료 및 서명 오류 확인
try {
jwtUtil.isExpired(token);
} catch (ExpiredJwtException e) {
Expand All @@ -79,6 +114,18 @@ public void validateToken(String token, String expectedCategory) {
? ErrorCode.ACCESS_TOKEN_EXPIRED
: ErrorCode.REFRESH_TOKEN_EXPIRED
);
} catch (SignatureException e) {
log.error("유효하지 않은 JWT 서명: 카테고리 = {}", expectedCategory);
throw new CustomException(ErrorCode.INVALID_JWT_SIGNATURE);
} catch (MalformedJwtException e) {
log.error("유효하지 않은 JWT 형식: 카테고리 = {}", expectedCategory);
throw new CustomException(ErrorCode.INVALID_MALFORMED_JWT);
} catch (UnsupportedJwtException e) {
log.error("지원하지 않는 JWT: 카테고리 = {}", expectedCategory);
throw new CustomException(ErrorCode.UNSUPPORTED_JWT);
} catch (IllegalArgumentException e) {
log.error("잘못된 JWT 토큰: 카테고리 = {}", expectedCategory);
throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT);
}

// 토큰 카테고리 확인
Expand All @@ -91,15 +138,6 @@ public void validateToken(String token, String expectedCategory) {
: ErrorCode.INVALID_REFRESH_TOKEN
);
}

// 리프레시 토큰 검증
if ("refresh".equals(expectedCategory)) {
boolean exists = refreshRepository.existsByRefreshToken(token);
if (!exists) {
log.error("리프레시 토큰이 DB에 존재하지 않음: 토큰 = {}", token);
throw new CustomException(ErrorCode.MISSING_REFRESH_TOKEN_IN_DB);
}
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR
String refresh = jwtUtil.createJwt("refresh", username, role, refreshExpiration); //24시간

//Refresh 토큰 저장
tokenService.addRefreshEntity(username, refresh, refreshExpiration);
String deviceId = request.getHeader("Device-Id");
tokenService.addRefreshEntity(username, refresh, refreshExpiration, deviceId);

//응답 설정
//access 토큰 헤더에 넣어서 응답 (key: value 형태) -> 예시) access: 인증토큰(string)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,28 @@ public enum ErrorCode {
REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "T-202", "리프레시 토큰이 만료되었습니다."),
INVALID_ACCESS_TOKEN(HttpStatus.FORBIDDEN, "T-203", "액세스 토큰이 유효하지 않습니다."),
INVALID_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "T-204", "리프레시 토큰이 유효하지 않습니다."),
MISSING_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "T-205", "액세스 토큰이 없습니다."),
MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "T-206", "리프레시 토큰이 없습니다."),
MISSING_REFRESH_TOKEN_IN_DB(HttpStatus.BAD_REQUEST, "T-207", "DB에 해당 리프레시 토큰이 없습니다."),
REFRESH_TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "T-208", "서버의 리프레시 토큰과 일치하지 않습니다."),
INVALID_AUTHORIZATION_HEADER(HttpStatus.BAD_REQUEST, "T-209", "AUTHORIZATION 헤더의 토큰이 유효하지 않습니다."),
// JWT 서명 오류
INVALID_JWT_SIGNATURE(HttpStatus.UNAUTHORIZED, "T-211", "JWT 서명 검증에 실패했습니다."),
// 잘못된 JWT 형식
INVALID_MALFORMED_JWT(HttpStatus.UNAUTHORIZED, "T-212", "잘못된 JWT 토큰입니다."),

//구글 토큰 관련 에러: -300번대
GOOGLE_TOKEN_VERIFICATION_FAILED(HttpStatus.UNAUTHORIZED, "GT-300", "구글 ID 토큰 검증에 실패했습니다."),
INVALID_GOOGLE_TOKEN_HEADER(HttpStatus.BAD_REQUEST, "GT-301", "GOOGLE_ID_TOKEN 헤더의 토큰이 유효하지 않습니다."),

//관리자 관련 에러: -400번대
MISSING_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "T-205", "액세스 토큰이 제공되지 않았습니다."),
MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "T-206", "리프레시 토큰이 제공되지 않았습니다."),
INVALID_AUTHORIZATION_HEADER(HttpStatus.BAD_REQUEST, "T-207", "Authorization 헤더의 토큰이 유효하지 않습니다."),
REFRESH_TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "T-208", "제공된 리프레시 토큰과 서버의 토큰이 일치하지 않습니다."),
REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "T-209", "제공된 리프레시 토큰이 서버에 존재하지 않습니다."),

// JWT 검증 관련 에러 - 230번대
INVALID_JWT_SIGNATURE(HttpStatus.UNAUTHORIZED, "T-231", "JWT 서명 검증에 실패했습니다."),
INVALID_MALFORMED_JWT(HttpStatus.UNAUTHORIZED, "T-232", "잘못된 형식의 JWT입니다."),
UNSUPPORTED_JWT(HttpStatus.UNAUTHORIZED, "T-233", "지원하지 않는 JWT입니다."),
ILLEGAL_ARGUMENT(HttpStatus.UNAUTHORIZED, "T-234", "잘못된 JWT 토큰입니다."),

// 구글 토큰 관련 에러 - 290번대
GOOGLE_TOKEN_VERIFICATION_FAILED(HttpStatus.UNAUTHORIZED, "T-290", "구글 ID 토큰 검증에 실패했습니다."),
INVALID_GOOGLE_TOKEN_HEADER(HttpStatus.BAD_REQUEST, "T-291", "Google ID Token 헤더가 유효하지 않습니다."),

// 기기 관련 에러 - 300번대
DEVICE_ID_NOT_FOUND(HttpStatus.BAD_REQUEST, "D-300", "제공된 Device ID가 서버에 존재하지 않습니다."),
INVALID_DEVICE_ID_HEADER(HttpStatus.BAD_REQUEST, "D-301", "Device ID 헤더의 값이 유효하지 않습니다."),
DEVICE_ID_MISMATCH(HttpStatus.BAD_REQUEST, "D-302", "제공된 Device ID와 서버의 Device ID가 일치하지 않습니다."),

// 관리자 관련 에러: -400번대
NOT_AN_ADMIN(HttpStatus.FORBIDDEN, "A-400", "관리자 권한이 없습니다."),
INVALID_ADMIN_EMAIL(HttpStatus.BAD_REQUEST, "A-401", "허용되지 않은 관리자 이메일입니다."),
ADMIN_EMAIL_ONLY(HttpStatus.FORBIDDEN, "A-402", "관리자 이메일만 접근할 수 있습니다."),
Expand Down
Loading