728x90
지난 글 참고:
https://un-lazy-midnight.tistory.com/159
https://un-lazy-midnight.tistory.com/160
지난 글에서 로그인 시 Access Token과 Refresh Token을 발급받았다. 하지만 유효 시간을 각각 30분, 7일로 설정해놓았기 때문에 시간이 지나면 만료가 된다. 만료되면 그 다음은 어떻게 해야할까? 이번에는 토큰이 만료되었는지 유효성을 검증하고, 만료되었다면 재발급하는 로직을 구현해본다.
Access Token의 재발급 방법
Access Token이 만료되어, 재발급이 진행되면 다음의 과정을 통해 재발급이 된다.
- Refresh Token 유효성 검증
- 저장소에 Refresh Token 존재 유무 체크
- 1, 2 모두 검증되면 재발급 진행
- Response header에 새로 발급한 Access Token 저장
이후 클라이언트는 재발급된 Access Token을 Request헤더에 포함하여 요청을 보내면 정상적으로 접근이 허용된다.
JwtTokenProvider 수정
@Slf4j
@Component
@Transactional(readOnly = true)
public class JwtProvider {
... (생략) // 지난 글 참고
// 토큰으로부터 정보 추출
public Claims getClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) { // Access Token
return e.getClaims();
}
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
String email = getClaims(token).get(EMAIL_KEY).toString();
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* Refresh 토큰으로부터 클레임을 만들고, 이를 통해 User 객체를 생성하여 Authentication 객체를 반환
* @param refreshToken
* @return
*/
public Authentication getAuthenticationByRefreshToken(String refreshToken) {
String userPrincipal = getClaims(refreshToken).get(EMAIL_KEY).toString();
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(userPrincipal);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public long getTokenExpirationTime(String token) {
return getClaims(token).getExpiration().getTime();
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateRefreshToken(String refreshToken) {
try {
Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(refreshToken);
return true;
} catch (SignatureException e) {
log.error("Invalid JWT signature.");
} catch (MalformedJwtException e) {
log.error("Invalid JWT token.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token.");
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.");
} catch (NullPointerException e) {
log.error("JWT Token is empty.");
}
return false;
}
// Filter에서 사용
public boolean validateAccessToken(String accessToken) {
String redisServiceValues = redisService.getValues(accessToken);
try {
if (redisServiceValues != null // NPE 방지
&& redisServiceValues.equals("logout")) { // 로그아웃 했을 경우
return false;
}
Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(accessToken);
return true;
} catch (ExpiredJwtException e) {
return true;
} catch (Exception e) {
return false;
}
}
// 재발급 검증 API에서 사용
public boolean validateAccessTokenOnlyExpired(String accessToken) {
try {
return getClaims(accessToken)
.getExpiration()
.before(new Date());
} catch (ExpiredJwtException e) {
return true;
} catch (Exception e) {
return false;
}
}
}
JwtAuthenticationFilter 수정
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Access Token 추출
String accessToken = resolveToken(request);
try { // 정상 토큰인지 검사
if (accessToken != null && jwtProvider.validateAccessToken(accessToken)) {
Authentication authentication = jwtProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Save authentication in SecurityContextHolder.");
}
} catch (InvalidTokenException e) { // 잘못된 토큰일 경우
SecurityContextHolder.clearContext();
} catch (InvalidMemberException e) { // 회원을 찾을 수 없을 경우
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
AuthController, AuthService에 유효성 검증 및 재발급 로직 추가
AuthController(다른 내용 생략, 이전 글 참고)
//토큰 유효성 확인
@Operation(summary = "access 토큰 유효성 확인")
@PostMapping("/validate")
public void validate(@RequestHeader("Authorization") String requestAccessToken) {
authService.validate(requestAccessToken);
}
// 토큰 재발급
@Operation(summary = "access&refresh 토큰 재발급")
@PostMapping("/reissue")
public ResponseEntity<?> reissue(@CookieValue(name = "refresh-token") String requestRefreshToken) {
log.info(requestRefreshToken);
TokenDto reissuedTokenDto = authService.reissue(requestRefreshToken);
if (reissuedTokenDto != null) { // 토큰 재발급 성공
// RT 저장
ResponseCookie responseCookie = ResponseCookie.from("refresh-token", reissuedTokenDto.getRefreshToken())
.maxAge(COOKIE_EXPIRATION)
.httpOnly(true)
.secure(true)
.build();
return ResponseEntity
.status(HttpStatus.OK)
.header(HttpHeaders.SET_COOKIE, responseCookie.toString())
// AT 저장
.header(HttpHeaders.AUTHORIZATION, "Bearer " + reissuedTokenDto.getAccessToken())
.build();
} else { // Refresh Token 탈취 가능성
// Cookie 삭제 후 재로그인 유도
ResponseCookie responseCookie = ResponseCookie.from("refresh-token", "")
.maxAge(0)
.path("/")
.build();
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.header(HttpHeaders.SET_COOKIE, responseCookie.toString())
.build();
}
}
AuthService(다른 내용 생략, 이전 글 참고)
// AT가 만료일자만 초과한 유효한 토큰인지 검사
public void validate(String requestAccessTokenInHeader) {
String requestAccessToken = resolveToken(requestAccessTokenInHeader);
if (!jwtProvider.validateAccessTokenOnlyExpired(requestAccessToken)) {
throw new InvalidTokenException();
}
}
// 토큰 재발급: AT, RT 재발급
@Transactional
public TokenDto reissue(String requestRefreshTokenInHeader) {
Authentication authentication = jwtProvider.getAuthenticationByRefreshToken(requestRefreshTokenInHeader);
String principal = getPrincipalByRefreshToken(requestRefreshTokenInHeader);
String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
if (refreshTokenInRedis == null) { // Redis에 저장되어 있는 RT가 없을 경우
return null; // -> 재로그인 요청
}
// RT의 유효성 검사
if (!jwtProvider.validateRefreshToken(refreshTokenInRedis)) {
redisService.deleteValues("RT(" + SERVER + "):" + principal); //삭제
return null; // -> 재로그인 요청
}
SecurityContextHolder.getContext().setAuthentication(authentication);
String authorities = getAuthorities(authentication);
// 토큰 재발급 및 Redis 업데이트
redisService.deleteValues("RT(" + SERVER + "):" + principal); // 기존 RT 삭제
TokenDto tokenDto = jwtProvider.createToken(principal, authorities);
saveRefreshToken(SERVER, principal, tokenDto.getRefreshToken());
return tokenDto;
}
// RT를 Redis에 저장
@Transactional
public void saveRefreshToken(String provider, String principal, String refreshToken) {
redisService.setValuesWithTimeout("RT(" + provider + "):" + principal, // key
refreshToken, // value
jwtProvider.getTokenExpirationTime(refreshToken)); // timeout(milliseconds)
}
// 권한 이름 가져오기
public String getAuthorities(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
}
// AT로부터 principal 추출
public String getPrincipal(String requestAccessToken) {
return jwtProvider.getAuthentication(requestAccessToken).getName();
}
// RT로부터 principal 추출
public String getPrincipalByRefreshToken(String requestRefreshToken) {
return jwtProvider.getAuthenticationByRefreshToken(requestRefreshToken).getName();
}
// "Bearer {AT}"에서 {AT} 추출
public String resolveToken(String requestAccessTokenInHeader) {
if (requestAccessTokenInHeader != null && requestAccessTokenInHeader.startsWith("Bearer ")) {
return requestAccessTokenInHeader.substring(7);
}
return null;
}
postman으로 재발급 API를 요청하면 다음과 같이 정상적으로 재발급 받는다!
728x90
'💻dev > 🕹️Project' 카테고리의 다른 글
대동덕지도 | Spring Boot + AWS EC2 도메인 연결 및 HTTPS 적용하기 (0) | 2023.08.13 |
---|---|
대동덕지도 | 로그인 기능 구현에서 Refresh Token을 선택한 이유 (0) | 2023.08.13 |
대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis) (0) | 2023.08.12 |
대동덕지도 | Spring Boot에서 비밀번호 재설정 링크 이메일 전송 기능 구현하기 (0) | 2023.08.11 |
대동덕지도 | Spring Boot에서 Spring Security + JWT 로그인을 구현하자! (Access Token, Refresh Token 발급) (0) | 2023.08.11 |