Skip to content
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 @@ -93,4 +93,11 @@ public ApiResponse<CheckNicknameResponseDTO> 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<RefreshTokenResponseDTO> reissueRefreshToken(HttpServletRequest request) {
return ApiResponse.onSuccess(userService.reissueRefreshToken(request));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,6 +19,9 @@ public interface UserRepository extends JpaRepository<User,Long> {

boolean existsByNameId(String nameId);

// 비관적 락이 적용된 사용자 조회 메서드
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(String email); // 로그인 시, 이메일로 유저 찾기

User findUserById(Long id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@ConfigurationProperties(prefix = "spring.jwt")
public class JwtProperties {
private String secret;

private long accessTokenValidity;
private long refreshTokenValidity;
private int refreshTokenReissueThresholdDays;
}
77 changes: 58 additions & 19 deletions src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()))
Expand All @@ -66,52 +67,54 @@ 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<String> roleStrings = claims.get(ROLES) instanceof List
? (List<String>) claims.get(ROLES)
: java.util.Collections.emptyList();
: Collections.emptyList();

List<SimpleGrantedAuthority> authorities = roleStrings.stream()
// hasRole("ADMIN")를 쓰면 내부적으로 "ROLE_ADMIN"을 찾음 → 접두 보장
.map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r)
.map(SimpleGrantedAuthority::new)
.toList();

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) {
Expand All @@ -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;
}
}
Loading