Skip to content

Commit

Permalink
Merge pull request #49 from Tico-Corp/feature-be/TICO-241-update-toke…
Browse files Browse the repository at this point in the history
…n-reissue-api

[FEAT] 토큰 재발행 API 로직 변경 (TICO-241)
  • Loading branch information
bu119 authored Jul 27, 2024
2 parents 88e9048 + 81d76b9 commit 1b6b775
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 59 deletions.
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

0 comments on commit 1b6b775

Please sign in to comment.