Skip to content

Commit ceb45b8

Browse files
authored
Feat: refresh token 재발급 (#186)
## #️⃣연관된 이슈 > #174 ## 📝작업 내용 > refresh token 재발급 ### 스크린샷 <img width="236" height="522" alt="image" src="https://github.com/user-attachments/assets/617ce37e-7dd6-4ee8-a411-62c21d816693" />
2 parents fc4afda + d7a57e9 commit ceb45b8

File tree

7 files changed

+150
-20
lines changed

7 files changed

+150
-20
lines changed

src/main/java/EatPic/spring/domain/user/controller/UserController.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,11 @@ public ApiResponse<CheckNicknameResponseDTO> checkNickname(@RequestParam String
9393
}
9494
return ApiResponse.onSuccess(UserConverter.toCheckNicknameResponseDto(nickname, true));
9595
}
96+
97+
// refresh token 재발급이요 진짜 제발 되길 바라요 제발요
98+
@GetMapping("/user/refresh")
99+
@Operation(summary = "refreshToken 재발급", security = {@SecurityRequirement(name = "JWT TOKEN")})
100+
public ApiResponse<RefreshTokenResponseDTO> reissueRefreshToken(HttpServletRequest request) {
101+
return ApiResponse.onSuccess(userService.reissueRefreshToken(request));
102+
}
96103
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package EatPic.spring.domain.user.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
7+
@Data
8+
@Builder
9+
@AllArgsConstructor
10+
public class RefreshTokenResponseDTO {
11+
private String accessToken;
12+
private String refreshToken;
13+
private Long accessTokenExpiresIn;
14+
}

src/main/java/EatPic/spring/domain/user/repository/UserRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package EatPic.spring.domain.user.repository;
22

33
import EatPic.spring.domain.user.entity.User;
4+
import jakarta.persistence.LockModeType;
45
import org.springframework.data.domain.Pageable;
56
import org.springframework.data.domain.Slice;
67
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Lock;
79
import org.springframework.data.jpa.repository.Query;
810
import org.springframework.data.repository.query.Param;
911

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

1820
boolean existsByNameId(String nameId);
1921

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

2227
User findUserById(Long id);

src/main/java/EatPic/spring/domain/user/service/UserService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import EatPic.spring.domain.user.dto.request.LoginRequestDTO;
66
import EatPic.spring.domain.user.dto.request.UserRequest;
77
import EatPic.spring.domain.user.dto.response.LoginResponseDTO;
8+
import EatPic.spring.domain.user.dto.response.RefreshTokenResponseDTO;
89
import EatPic.spring.domain.user.dto.response.UserResponseDTO;
910
import EatPic.spring.domain.user.dto.request.SignupRequestDTO;
1011
import EatPic.spring.domain.user.dto.response.SignupResponseDTO;
@@ -23,6 +24,7 @@ public interface UserService {
2324
boolean isEmailDuplicate(String email);
2425
boolean isnameIdDuplicate(String nameId);
2526
boolean isNicknameDuplicate(String nickname);
27+
RefreshTokenResponseDTO reissueRefreshToken(HttpServletRequest request);
2628

2729
// UserQueryService
2830
UserInfoDTO getUserInfo(HttpServletRequest request);

src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import EatPic.spring.domain.user.dto.request.SignupRequestDTO;
99
import EatPic.spring.domain.user.dto.request.UserRequest;
1010
import EatPic.spring.domain.user.dto.response.LoginResponseDTO;
11+
import EatPic.spring.domain.user.dto.response.RefreshTokenResponseDTO;
1112
import EatPic.spring.domain.user.dto.response.SignupResponseDTO;
1213
import EatPic.spring.domain.user.dto.response.UserResponseDTO;
1314
import EatPic.spring.domain.user.entity.FollowStatus;
@@ -21,6 +22,7 @@
2122
import EatPic.spring.global.common.code.status.ErrorStatus;
2223
import EatPic.spring.global.common.exception.GeneralException;
2324
import EatPic.spring.global.common.exception.handler.ExceptionHandler;
25+
import EatPic.spring.global.config.Properties.JwtProperties;
2426
import EatPic.spring.global.config.jwt.JwtTokenProvider;
2527
import jakarta.servlet.http.HttpServletRequest;
2628
import lombok.RequiredArgsConstructor;
@@ -48,6 +50,7 @@ public class UserServiceImpl implements UserService{
4850
private final CardRepository cardRepository;
4951
private final PasswordEncoder passwordEncoder;
5052
private final JwtTokenProvider jwtTokenProvider;
53+
private final JwtProperties jwtProperties;
5154

5255
// s3 설정
5356
private final AmazonS3Manager s3Manager;
@@ -128,6 +131,66 @@ public UserInfoDTO getUserInfo(HttpServletRequest request) {
128131
return UserConverter.toUserInfoDTO(user);
129132
}
130133

134+
// refreshToken 재발급
135+
@Override
136+
public RefreshTokenResponseDTO reissueRefreshToken(HttpServletRequest request){
137+
// refresh token 추출
138+
String requestRefreshToken = jwtTokenProvider.resolveToken(request);
139+
140+
// 토큰이 없으면 예외 발생
141+
if (!jwtTokenProvider.validateRefreshToken(requestRefreshToken)){
142+
throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN);
143+
}
144+
145+
// 토큰에서 이메일 추출, 사용자 정보 조회
146+
final String email = jwtTokenProvider.getSubject(requestRefreshToken);
147+
148+
User user = userRepository.findByEmail(email)
149+
.orElseThrow(() -> new ExceptionHandler(ErrorStatus.MEMBER_NOT_FOUND));
150+
151+
// 저장된 refresh token과 요청으로 들어온 token의 일치 여부 확인
152+
final String storedRefreshToken = user.getRefreshToken();
153+
154+
if (!requestRefreshToken.equals(user.getRefreshToken())) {
155+
throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN);
156+
}
157+
158+
// access token 재발급
159+
Authentication authentication = new UsernamePasswordAuthenticationToken(
160+
user.getEmail(), null,
161+
//Collections.emptyList()
162+
Collections.singleton(() -> user.getRole().name())
163+
);
164+
165+
String newAccessToken = jwtTokenProvider.generateToken(authentication);
166+
167+
// refresh token 재발급 필요 여부 확인
168+
// access -> 30시간, refresh -> 5일
169+
// 재발급 임계일 설정 -> 3일
170+
boolean needReissueRefreshToken = expireWithinDays(requestRefreshToken, jwtProperties.getRefreshTokenReissueThresholdDays());
171+
String oldRefreshToken = user.getRefreshToken();
172+
173+
if (needReissueRefreshToken) {
174+
oldRefreshToken = jwtTokenProvider.generateRefreshToken(user.getEmail());
175+
user.updateRefreshToken(oldRefreshToken);
176+
userRepository.save(user);
177+
}
178+
179+
return RefreshTokenResponseDTO.builder()
180+
.accessToken(newAccessToken)
181+
.refreshToken(oldRefreshToken)
182+
.accessTokenExpiresIn(jwtProperties.getAccessTokenValidity())
183+
.build();
184+
}
185+
186+
// refreshToken이 유효 기간 이내에 만료되는지 체크
187+
private boolean expireWithinDays(String jwt, int days) {
188+
long isRemained = jwtTokenProvider.getExpiredTime(jwt) - System.currentTimeMillis();
189+
long threshold = (long) days * 24L * 60L * 60L * 1000L;
190+
191+
return isRemained <= threshold;
192+
}
193+
131194
// 팔로잉한 유저의 프로필 아이콘 목록 조회
132195
@Override
133196
public UserResponseDTO.UserIconListResponseDto followingUserIconList(HttpServletRequest request,int page, int size) {

src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
@ConfigurationProperties(prefix = "spring.jwt")
1212
public class JwtProperties {
1313
private String secret;
14-
1514
private long accessTokenValidity;
1615
private long refreshTokenValidity;
16+
private int refreshTokenReissueThresholdDays;
1717
}

src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public String generateToken(Authentication authentication) {
4545
.map(GrantedAuthority::getAuthority)
4646
.collect(Collectors.toList());
4747

48-
// 🔹 권한이 비어 있으면 DB에서 사용자 role을 읽어 보정 (임시 해결)
48+
// 권한이 비어 있으면 DB에서 사용자 role을 읽어 보정 (임시 해결)
4949
if (roles.isEmpty()) {
5050
var user = userRepository.findByEmail(email).orElse(null);
5151
if (user != null && user.getRole() != null) {
@@ -55,6 +55,7 @@ public String generateToken(Authentication authentication) {
5555

5656
return Jwts.builder()
5757
.setSubject(email)
58+
.claim("tokenType", "accessToken")
5859
.claim(ROLES, roles) // 🔹 roles 클레임 추가
5960
.setIssuedAt(new Date())
6061
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getAccessTokenValidity()))
@@ -66,52 +67,54 @@ public String generateToken(Authentication authentication) {
6667
public String generateRefreshToken(String email) {
6768
return Jwts.builder()
6869
.setSubject(email)
70+
.claim("tokenType", "refreshToken")
6971
.setIssuedAt(new Date())
7072
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshTokenValidity()))
7173
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
7274
.compact();
7375
}
7476

75-
76-
// WT 토큰이 유효한지 검증
77-
public boolean validateToken(String token) {
77+
// JWT 토큰에서 Claims 객체를 추출하는 핵심 메소드
78+
public Claims getClaims(String token) {
7879
try {
79-
Jwts.parser()
80+
return Jwts.parser()
8081
.setSigningKey(getSigningKey())
8182
.build()
82-
.parseClaimsJws(token);
83-
return true;
83+
.parseClaimsJws(token)
84+
.getBody();
8485
} catch (JwtException | IllegalArgumentException e) {
85-
return false;
86+
return null; // 유효하지 않은 토큰일 경우 null 반환
8687
}
8788
}
8889

90+
// JWT 토큰이 유효한지 검증
91+
public boolean validateToken(String token) {
92+
return getClaims(token) != null;
93+
}
94+
8995
// JWT 토큰에서 인증 정보를 추출해서 Spring Security의 Authentication 객체로 변환
9096
public Authentication getAuthentication(String token) {
91-
Claims claims = Jwts.parser() // parserBuilder() 사용
92-
.setSigningKey(getSigningKey())
93-
.build()
94-
.parseClaimsJws(token)
95-
.getBody();
97+
Claims claims = getClaims(token);
98+
if (claims == null) {
99+
return null;
100+
}
96101

97102
String email = claims.getSubject();
98103

99104
@SuppressWarnings("unchecked")
100105
List<String> roleStrings = claims.get(ROLES) instanceof List
101106
? (List<String>) claims.get(ROLES)
102-
: java.util.Collections.emptyList();
107+
: Collections.emptyList();
103108

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

110114
org.springframework.security.core.userdetails.User principal =
111115
new org.springframework.security.core.userdetails.User(email, "", authorities);
112116

113-
return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
114-
principal, null, authorities);
117+
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
115118
}
116119

117120
public static String resolveToken(HttpServletRequest request) {
@@ -123,12 +126,48 @@ public static String resolveToken(HttpServletRequest request) {
123126
}
124127

125128
// HttpServletRequest 에서 토큰 값을 추출
126-
// getAuthentication 메소드를 이용해서 Spring Security의 Authentication 객체로 변환
127129
public Authentication extractAuthentication(HttpServletRequest request){
128130
String accessToken = resolveToken(request);
129-
if(accessToken == null || !validateToken(accessToken)) {
131+
if(accessToken == null || !validateAccessToken(accessToken)) {
130132
throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN);
131133
}
132134
return getAuthentication(accessToken);
133135
}
136+
137+
// token 유효성 검증
138+
private boolean validateTokenType(String token, String expectedType) {
139+
try {
140+
Claims claims = Jwts.parser()
141+
.setSigningKey(getSigningKey())
142+
.build()
143+
.parseClaimsJws(token)
144+
.getBody();
145+
146+
String type = claims.get("tokenType", String.class);
147+
return expectedType.equals(type);
148+
} catch (JwtException | IllegalArgumentException e) {
149+
return false;
150+
}
151+
}
152+
153+
// accessToken 유효성 검증
154+
public boolean validateAccessToken(String accessToken){
155+
return validateTokenType(accessToken, "accessToken");
156+
}
157+
158+
// refreshToken 유효성 검증
159+
public boolean validateRefreshToken(String refreshToken) {
160+
return validateTokenType(refreshToken, "refreshToken");
161+
}
162+
// token의 email 꺼내기
163+
public String getSubject(String token) {
164+
Claims claims = getClaims(token);
165+
return (claims != null) ? claims.getSubject() : null;
166+
}
167+
168+
// token 만료 시간
169+
public long getExpiredTime(String token) {
170+
Claims claims = getClaims(token);
171+
return (claims != null) ? claims.getExpiration().getTime() : 0;
172+
}
134173
}

0 commit comments

Comments
 (0)