지난 글 참고:
https://un-lazy-midnight.tistory.com/159
대동덕지도 | Spring Boot에서 Spring Security + JWT 로그인을 구현하자! (Access Token, Refresh Token 발급)
Spring Boot에서 로그인을 구현하자! 처음 백엔드 팀원과 역할을 나눌 때, 도메인 별로 분배하는 게 좋다고 판단이 되었다. 두 명이기 때문에 사이즈가 큰 "회원"과 "이벤트"를 하나씩 맡고 나머지
un-lazy-midnight.tistory.com
https://un-lazy-midnight.tistory.com/160
대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis)
JWT 적용한 로그인은 구현 했는데 말입니다 ... 로그아웃은 어떻게 하지? JWT를 적용한 로그인 기능은 Access Token(AT), Refresh Token(RT)를 이용하여 구현해냈다. 그런데 로그아웃은.. 어떻게 하지? 클라이
un-lazy-midnight.tistory.com
지난 글에서 로그인 시 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를 요청하면 다음과 같이 정상적으로 재발급 받는다!
'💻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 |