diff --git a/src/main/java/EatPic/spring/domain/user/controller/UserController.java b/src/main/java/EatPic/spring/domain/user/controller/UserController.java index 4c24dd5..ad971ee 100644 --- a/src/main/java/EatPic/spring/domain/user/controller/UserController.java +++ b/src/main/java/EatPic/spring/domain/user/controller/UserController.java @@ -93,4 +93,11 @@ public ApiResponse checkNickname(@RequestParam String } return ApiResponse.onSuccess(UserConverter.toCheckNicknameResponseDto(nickname, true)); } + + // refresh token 재발급이요 진짜 제발 되길 바라요 제발요 + @GetMapping("/user/refresh") + @Operation(summary = "refreshToken 재발급", security = {@SecurityRequirement(name = "JWT TOKEN")}) + public ApiResponse reissueRefreshToken(HttpServletRequest request) { + return ApiResponse.onSuccess(userService.reissueRefreshToken(request)); + } } diff --git a/src/main/java/EatPic/spring/domain/user/dto/response/RefreshTokenResponseDTO.java b/src/main/java/EatPic/spring/domain/user/dto/response/RefreshTokenResponseDTO.java new file mode 100644 index 0000000..1a485af --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/dto/response/RefreshTokenResponseDTO.java @@ -0,0 +1,14 @@ +package EatPic.spring.domain.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class RefreshTokenResponseDTO { + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java b/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java index 107c104..9117120 100644 --- a/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java +++ b/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java @@ -1,9 +1,11 @@ package EatPic.spring.domain.user.repository; import EatPic.spring.domain.user.entity.User; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -17,6 +19,9 @@ public interface UserRepository extends JpaRepository { boolean existsByNameId(String nameId); + // 비관적 락이 적용된 사용자 조회 메서드 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.email = :email") Optional findByEmail(String email); // 로그인 시, 이메일로 유저 찾기 User findUserById(Long id); diff --git a/src/main/java/EatPic/spring/domain/user/service/UserService.java b/src/main/java/EatPic/spring/domain/user/service/UserService.java index 0d26ad3..8449595 100644 --- a/src/main/java/EatPic/spring/domain/user/service/UserService.java +++ b/src/main/java/EatPic/spring/domain/user/service/UserService.java @@ -4,6 +4,7 @@ import EatPic.spring.domain.user.dto.request.LoginRequestDTO; import EatPic.spring.domain.user.dto.request.UserRequest; import EatPic.spring.domain.user.dto.response.LoginResponseDTO; +import EatPic.spring.domain.user.dto.response.RefreshTokenResponseDTO; import EatPic.spring.domain.user.dto.response.UserResponseDTO; import EatPic.spring.domain.user.dto.request.SignupRequestDTO; import EatPic.spring.domain.user.dto.response.SignupResponseDTO; @@ -21,6 +22,7 @@ public interface UserService { boolean isEmailDuplicate(String email); boolean isnameIdDuplicate(String nameId); boolean isNicknameDuplicate(String nickname); + RefreshTokenResponseDTO reissueRefreshToken(HttpServletRequest request); // UserQueryService UserInfoDTO getUserInfo(HttpServletRequest request); diff --git a/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java b/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java index aedd598..765cfeb 100644 --- a/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java +++ b/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java @@ -6,6 +6,7 @@ import EatPic.spring.domain.user.dto.request.SignupRequestDTO; import EatPic.spring.domain.user.dto.request.UserRequest; import EatPic.spring.domain.user.dto.response.LoginResponseDTO; +import EatPic.spring.domain.user.dto.response.RefreshTokenResponseDTO; import EatPic.spring.domain.user.dto.response.SignupResponseDTO; import EatPic.spring.domain.user.dto.response.UserResponseDTO; import EatPic.spring.domain.user.entity.User; @@ -18,6 +19,7 @@ import EatPic.spring.global.common.code.status.ErrorStatus; import EatPic.spring.global.common.exception.GeneralException; import EatPic.spring.global.common.exception.handler.ExceptionHandler; +import EatPic.spring.global.config.Properties.JwtProperties; import EatPic.spring.global.config.jwt.JwtTokenProvider; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -46,6 +48,7 @@ public class UserServiceImpl implements UserService{ private final UserBadgeService userBadgeService; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final JwtProperties jwtProperties; // s3 설정 private final AmazonS3Manager s3Manager; @@ -126,6 +129,66 @@ public UserInfoDTO getUserInfo(HttpServletRequest request) { return UserConverter.toUserInfoDTO(user); } + // refreshToken 재발급 + @Override + public RefreshTokenResponseDTO reissueRefreshToken(HttpServletRequest request){ + // refresh token 추출 + String requestRefreshToken = jwtTokenProvider.resolveToken(request); + + // 토큰이 없으면 예외 발생 + if (!jwtTokenProvider.validateRefreshToken(requestRefreshToken)){ + throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN); + } + + // 토큰에서 이메일 추출, 사용자 정보 조회 + final String email = jwtTokenProvider.getSubject(requestRefreshToken); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ExceptionHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // 저장된 refresh token과 요청으로 들어온 token의 일치 여부 확인 + final String storedRefreshToken = user.getRefreshToken(); + + if (!requestRefreshToken.equals(user.getRefreshToken())) { + throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN); + } + + // access token 재발급 + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getEmail(), null, + //Collections.emptyList() + Collections.singleton(() -> user.getRole().name()) + ); + + String newAccessToken = jwtTokenProvider.generateToken(authentication); + + // refresh token 재발급 필요 여부 확인 + // access -> 30시간, refresh -> 5일 + // 재발급 임계일 설정 -> 3일 + boolean needReissueRefreshToken = expireWithinDays(requestRefreshToken, jwtProperties.getRefreshTokenReissueThresholdDays()); + String oldRefreshToken = user.getRefreshToken(); + + if (needReissueRefreshToken) { + oldRefreshToken = jwtTokenProvider.generateRefreshToken(user.getEmail()); + user.updateRefreshToken(oldRefreshToken); + userRepository.save(user); + } + + return RefreshTokenResponseDTO.builder() + .accessToken(newAccessToken) + .refreshToken(oldRefreshToken) + .accessTokenExpiresIn(jwtProperties.getAccessTokenValidity()) + .build(); + } + + // refreshToken이 유효 기간 이내에 만료되는지 체크 + private boolean expireWithinDays(String jwt, int days) { + long isRemained = jwtTokenProvider.getExpiredTime(jwt) - System.currentTimeMillis(); + long threshold = (long) days * 24L * 60L * 60L * 1000L; + + return isRemained <= threshold; + } + // 팔로잉한 유저의 프로필 아이콘 목록 조회 @Override public UserResponseDTO.UserIconListResponseDto followingUserIconList(HttpServletRequest request,int page, int size) { diff --git a/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java b/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java index 25905e0..05d4edc 100644 --- a/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java +++ b/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java @@ -11,7 +11,7 @@ @ConfigurationProperties(prefix = "spring.jwt") public class JwtProperties { private String secret; - private long accessTokenValidity; private long refreshTokenValidity; + private int refreshTokenReissueThresholdDays; } diff --git a/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java b/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java index 9021ffe..6b8a0ff 100644 --- a/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java +++ b/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java @@ -45,7 +45,7 @@ public String generateToken(Authentication authentication) { .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); - // 🔹 권한이 비어 있으면 DB에서 사용자 role을 읽어 보정 (임시 해결) + // 권한이 비어 있으면 DB에서 사용자 role을 읽어 보정 (임시 해결) if (roles.isEmpty()) { var user = userRepository.findByEmail(email).orElse(null); if (user != null && user.getRole() != null) { @@ -55,6 +55,7 @@ public String generateToken(Authentication authentication) { return Jwts.builder() .setSubject(email) + .claim("tokenType", "accessToken") .claim(ROLES, roles) // 🔹 roles 클레임 추가 .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getAccessTokenValidity())) @@ -66,43 +67,46 @@ public String generateToken(Authentication authentication) { public String generateRefreshToken(String email) { return Jwts.builder() .setSubject(email) + .claim("tokenType", "refreshToken") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshTokenValidity())) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } - - // WT 토큰이 유효한지 검증 - public boolean validateToken(String token) { + // JWT 토큰에서 Claims 객체를 추출하는 핵심 메소드 + public Claims getClaims(String token) { try { - Jwts.parser() + return Jwts.parser() .setSigningKey(getSigningKey()) .build() - .parseClaimsJws(token); - return true; + .parseClaimsJws(token) + .getBody(); } catch (JwtException | IllegalArgumentException e) { - return false; + return null; // 유효하지 않은 토큰일 경우 null 반환 } } + // JWT 토큰이 유효한지 검증 + public boolean validateToken(String token) { + return getClaims(token) != null; + } + // JWT 토큰에서 인증 정보를 추출해서 Spring Security의 Authentication 객체로 변환 public Authentication getAuthentication(String token) { - Claims claims = Jwts.parser() // parserBuilder() 사용 - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); + Claims claims = getClaims(token); + if (claims == null) { + return null; + } String email = claims.getSubject(); @SuppressWarnings("unchecked") List roleStrings = claims.get(ROLES) instanceof List ? (List) claims.get(ROLES) - : java.util.Collections.emptyList(); + : Collections.emptyList(); List authorities = roleStrings.stream() - // hasRole("ADMIN")를 쓰면 내부적으로 "ROLE_ADMIN"을 찾음 → 접두 보장 .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) .map(SimpleGrantedAuthority::new) .toList(); @@ -110,8 +114,7 @@ public Authentication getAuthentication(String token) { org.springframework.security.core.userdetails.User principal = new org.springframework.security.core.userdetails.User(email, "", authorities); - return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( - principal, null, authorities); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); } public static String resolveToken(HttpServletRequest request) { @@ -123,12 +126,48 @@ public static String resolveToken(HttpServletRequest request) { } // HttpServletRequest 에서 토큰 값을 추출 - // getAuthentication 메소드를 이용해서 Spring Security의 Authentication 객체로 변환 public Authentication extractAuthentication(HttpServletRequest request){ String accessToken = resolveToken(request); - if(accessToken == null || !validateToken(accessToken)) { + if(accessToken == null || !validateAccessToken(accessToken)) { throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN); } return getAuthentication(accessToken); } + + // token 유효성 검증 + private boolean validateTokenType(String token, String expectedType) { + try { + Claims claims = Jwts.parser() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + String type = claims.get("tokenType", String.class); + return expectedType.equals(type); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + // accessToken 유효성 검증 + public boolean validateAccessToken(String accessToken){ + return validateTokenType(accessToken, "accessToken"); + } + + // refreshToken 유효성 검증 + public boolean validateRefreshToken(String refreshToken) { + return validateTokenType(refreshToken, "refreshToken"); + } + // token의 email 꺼내기 + public String getSubject(String token) { + Claims claims = getClaims(token); + return (claims != null) ? claims.getSubject() : null; + } + + // token 만료 시간 + public long getExpiredTime(String token) { + Claims claims = getClaims(token); + return (claims != null) ? claims.getExpiration().getTime() : 0; + } }