Skip to content

Commit 77b97c1

Browse files
authored
Merge pull request #59 from fullstack-dev-hub/feature/#56-token-reissue
[Feat] Refresh Token을 이용한 Access Token 재발급 구현
2 parents 5c62057 + 17fcc5f commit 77b97c1

10 files changed

Lines changed: 251 additions & 28 deletions

File tree

backend/src/main/java/com/postdm/backend/domain/auth/api/AuthController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,14 @@ public ResponseTemplate<?> singOut(@RequestHeader("Authorization") String authHe
8989

9090
return new ResponseTemplate<>(HttpStatus.OK, "로그아웃 성공", token);
9191
}
92+
93+
@Operation(summary = "토큰 재발급", description = "Refresh Token 기반의 Access Token 재발급 API 입니다.")
94+
@ApiResponses(value = {
95+
@ApiResponse(responseCode = "200", description = "성공"),
96+
})
97+
@PostMapping("/reissue")
98+
public ResponseTemplate<TokenInfo> reissue(@CookieValue("Refresh") String refreshToken, HttpServletResponse response) {
99+
TokenInfo token = authService.reissue(refreshToken, response);
100+
return new ResponseTemplate<>(HttpStatus.OK, "토큰 재발급 성공", token);
101+
}
92102
}

backend/src/main/java/com/postdm/backend/domain/auth/application/AuthService.java

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.postdm.backend.domain.auth.application;
22

3+
import com.postdm.backend.domain.auth.domain.RefreshToken;
34
import com.postdm.backend.domain.auth.dto.SignInRequestDto;
45
import com.postdm.backend.domain.auth.dto.SignUpRequestDto;
6+
import com.postdm.backend.domain.auth.repository.RefreshTokenRepository;
57
import com.postdm.backend.domain.email.domain.entity.CertificationEntity;
68
import com.postdm.backend.domain.email.domain.repository.CertificationRepository;
79
import com.postdm.backend.domain.member.domain.entity.Member;
@@ -14,9 +16,13 @@
1416
import jakarta.servlet.http.Cookie;
1517
import jakarta.servlet.http.HttpServletResponse;
1618
import org.springframework.beans.factory.annotation.Value;
19+
import org.springframework.security.core.Authentication;
20+
import org.springframework.security.core.GrantedAuthority;
1721
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1822
import org.springframework.stereotype.Service;
1923

24+
import java.time.LocalDateTime;
25+
2026
@Service
2127
public class AuthService { // 로그인 및 회원가입 서비스
2228

@@ -26,6 +32,7 @@ public class AuthService { // 로그인 및 회원가입 서비스
2632
private final JwtProvider jwtProvider;
2733
private final int refreshedMS;
2834
private final TokenBlacklistService tokenBlacklistService;
35+
private final RefreshTokenRepository refreshTokenRepository;
2936

3037
// 생성자 주입 방식
3138
public AuthService(
@@ -34,13 +41,15 @@ public AuthService(
3441
BCryptPasswordEncoder bCryptPasswordEncoder,
3542
JwtProvider jwtProvider,
3643
@Value("${jwt.refreshedMs}") int refreshedMS,
37-
TokenBlacklistService tokenBlacklistService) {
44+
TokenBlacklistService tokenBlacklistService,
45+
RefreshTokenRepository refreshTokenRepository) {
3846
this.memberRepository = memberRepository;
3947
this.certificationRepository = certificationRepository;
4048
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
4149
this.jwtProvider = jwtProvider;
4250
this.refreshedMS = refreshedMS;
4351
this.tokenBlacklistService = tokenBlacklistService;
52+
this.refreshTokenRepository = refreshTokenRepository;
4453
}
4554

4655

@@ -126,8 +135,16 @@ public TokenInfo signIn(SignInRequestDto signInRequestDto, HttpServletResponse r
126135
TokenInfo token = jwtProvider.generateToken(username, role); // 로그인이 완료되면 토큰 생성
127136
String refreshToken = jwtProvider.generateRefreshToken(username, role);
128137

129-
response.addCookie(createCookie(refreshToken)); // 쿠키에 refresh 토큰 담음
138+
LocalDateTime expiration = jwtProvider.getExpiration(refreshToken);
130139

140+
// 로그인 성공 후, Refresh Token을 DB에 저장
141+
refreshTokenRepository.save(new RefreshToken(
142+
username,
143+
refreshToken,
144+
expiration
145+
));
146+
147+
response.addCookie(createCookie(refreshToken)); // 쿠키에 refresh 토큰 담음
131148

132149
return token; // 응답 body에는 access 토큰 반환
133150
}
@@ -142,11 +159,62 @@ private Cookie createCookie(String value) { // 쿠키 생성 메소드
142159
}
143160

144161
public void signOut(String accessToken) {
145-
long expireMillis = jwtProvider.getExpiration(accessToken);
146-
boolean isNewlySaved = tokenBlacklistService.saveIfNotExists(accessToken, expireMillis);
162+
LocalDateTime expiration = jwtProvider.getExpiration(accessToken);
163+
boolean isNewlySaved = tokenBlacklistService.saveIfNotExists(accessToken, expiration);
147164

148165
if (!isNewlySaved) {
149166
throw new CustomException(ErrorCode.ALREADY_SIGN_OUT);
150167
}
168+
169+
Authentication authentication = jwtProvider.getAuthentication(accessToken);
170+
Member member = (Member) authentication.getPrincipal();
171+
String username = member.getUsername();
172+
173+
// Refresh Token 삭제
174+
refreshTokenRepository.findById(username)
175+
.ifPresent(refreshTokenRepository::delete);
176+
}
177+
178+
public TokenInfo reissue(String oldRefreshToken, HttpServletResponse response) {
179+
if (!jwtProvider.validateToken(oldRefreshToken)) {
180+
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
181+
}
182+
183+
Authentication authentication = jwtProvider.getAuthentication(oldRefreshToken);
184+
Member member = (Member) authentication.getPrincipal();
185+
String username = member.getUsername();
186+
187+
RefreshToken saved = refreshTokenRepository.findById(username)
188+
.orElseThrow(() -> new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND));
189+
190+
if (!saved.getRefreshToken().equals(oldRefreshToken)) {
191+
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
192+
}
193+
194+
// Access Token 재발급
195+
String role = extractRole(authentication);
196+
String newAccessToken = jwtProvider.generateAccessToken(username, role);
197+
198+
// Refresh Token 회전(Rotation)
199+
String newRefreshToken = jwtProvider.generateRefreshToken(username, role);
200+
LocalDateTime expiration = jwtProvider.getExpiration(newRefreshToken);
201+
saved.update(newRefreshToken, expiration);
202+
refreshTokenRepository.save(saved);
203+
204+
response.addCookie(createCookie(newRefreshToken));
205+
206+
return TokenInfo.builder()
207+
.grantType("Bearer")
208+
.accessToken(newAccessToken)
209+
.role(role)
210+
.build();
211+
}
212+
213+
private String extractRole(Authentication authentication) {
214+
return authentication.getAuthorities().stream()
215+
.findFirst()
216+
.map(GrantedAuthority::getAuthority)
217+
.orElseThrow(() -> new CustomException(ErrorCode.ROLE_NOT_FOUND))
218+
.replace("ROLE_", "");
151219
}
152-
}
220+
}

backend/src/main/java/com/postdm/backend/domain/auth/application/TokenBlacklistService.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
import org.springframework.scheduling.annotation.Scheduled;
88
import org.springframework.stereotype.Service;
99

10-
import java.time.Duration;
10+
import java.time.Instant;
1111
import java.time.LocalDateTime;
12+
import java.time.ZoneId;
1213

1314
@Service
1415
@RequiredArgsConstructor
@@ -17,7 +18,9 @@ public class TokenBlacklistService {
1718
private final TokenBlacklistRepository repository;
1819

1920
public void save(String token, long expireMillis) {
20-
LocalDateTime expiration = LocalDateTime.now().plus(Duration.ofMillis(expireMillis));
21+
LocalDateTime expiration = Instant.ofEpochMilli(expireMillis)
22+
.atZone(ZoneId.of("Asia/Seoul"))
23+
.toLocalDateTime();
2124
repository.save(new TokenBlacklist(token, expiration));
2225
}
2326

@@ -26,13 +29,12 @@ public boolean isBlacklisted(String token) {
2629
}
2730

2831
// 중복 저장 방지
29-
public boolean saveIfNotExists(String token, long expireMillis) {
32+
public boolean saveIfNotExists(String token, LocalDateTime expireMillis) {
3033
if (repository.existsByToken(token)) {
31-
return false; // 이미 블랙리스트에 있음
34+
return false;
3235
}
3336

34-
LocalDateTime expiration = LocalDateTime.now().plus(Duration.ofMillis(expireMillis));
35-
repository.save(new TokenBlacklist(token, expiration));
37+
repository.save(new TokenBlacklist(token, expireMillis));
3638
return true;
3739
}
3840

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.postdm.backend.domain.auth.domain;
2+
3+
import jakarta.persistence.Entity;
4+
import jakarta.persistence.Id;
5+
import jakarta.persistence.Lob;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.time.LocalDateTime;
11+
12+
@Entity
13+
@Getter
14+
@NoArgsConstructor
15+
@AllArgsConstructor
16+
public class RefreshToken {
17+
18+
@Id
19+
private String username;
20+
21+
@Lob
22+
private String refreshToken;
23+
24+
private LocalDateTime expiration;
25+
26+
public void update(String newToken, LocalDateTime newExpiration) {
27+
this.refreshToken = newToken;
28+
this.expiration = newExpiration;
29+
}
30+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.postdm.backend.domain.auth.repository;
2+
3+
import com.postdm.backend.domain.auth.domain.RefreshToken;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
7+
}

backend/src/main/java/com/postdm/backend/global/common/exception/GlobalExceptionHandler.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import com.postdm.backend.global.common.response.ErrorCode;
44
import com.postdm.backend.global.common.response.ErrorResponse;
5+
import io.jsonwebtoken.JwtException;
56
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.http.HttpStatus;
68
import org.springframework.http.ResponseEntity;
79
import org.springframework.http.converter.HttpMessageNotReadableException;
810
import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -46,4 +48,10 @@ protected ResponseEntity<ErrorResponse> validationExceptionHandler(Exception e)
4648
.body(new ErrorResponse(ErrorCode.VALIDATION_FAIL));
4749
}
4850

49-
}
51+
@ExceptionHandler(JwtException.class)
52+
public ResponseEntity<ErrorResponse> handleJwtException(JwtException e) {
53+
return ResponseEntity
54+
.status(HttpStatus.UNAUTHORIZED)
55+
.body(new ErrorResponse(ErrorCode.INVALID_REFRESH_TOKEN));
56+
}
57+
}

backend/src/main/java/com/postdm/backend/global/common/response/ErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ public enum ErrorCode {
4141

4242

4343
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "잘못된 HTTP 메서드를 호출했습니다."),
44-
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러 입니다.")
44+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러 입니다."),
45+
46+
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."),
47+
REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "저장된 Refresh Token이 없습니다."),
48+
ROLE_NOT_FOUND(HttpStatus.UNAUTHORIZED, "권한 정보를 찾을 수 없습니다."),
4549
;
4650

4751
private final HttpStatus httpStatus;

backend/src/main/java/com/postdm/backend/global/jwt/util/JwtProvider.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import javax.crypto.SecretKey;
1717
import javax.crypto.spec.SecretKeySpec;
1818
import java.nio.charset.StandardCharsets;
19+
import java.time.LocalDateTime;
20+
import java.time.ZoneId;
1921
import java.util.Collections;
2022
import java.util.Date;
2123
import java.util.List;
@@ -120,14 +122,16 @@ public Authentication getAuthentication(String token) { // 토큰에서 사용
120122
}
121123

122124
// JWT 토큰 만료 시간 반환
123-
public long getExpiration(String token) {
125+
public LocalDateTime getExpiration(String token) {
124126
Claims claims = Jwts.parser()
125127
.verifyWith(secretKey)
126128
.build()
127129
.parseSignedClaims(token)
128130
.getPayload();
129131

130132
Date expiration = claims.getExpiration();
131-
return expiration.getTime() - System.currentTimeMillis();
133+
return expiration.toInstant()
134+
.atZone(ZoneId.of("Asia/Seoul"))
135+
.toLocalDateTime();
132136
}
133137
}

backend/src/main/resources/application-test.yml

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)