[1년 후 마실가실] JWT와 로그아웃(2) RefreshToken
1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.
이것저것 좋아보이는 코드를 붙여넣다보니, 이도저도 아닌 누구세요 코드가 되어 정리를 좀 했습니다,,
이름하여 리리팩토링,,!
로그아웃하기 전 Refresh Token을 통한 Access Token 재발급을 정리하고, 다음은 정말.. 찐.. 최종.. 로그아웃..!
지난번에 설정한 Redis와 함께 토큰 재발행.. 도전..!
- UserController.java
@RestController
@RequestMapping("api/v2/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/re-issue")
@ResponseStatus(HttpStatus.OK)
public TokenInfo reissue(@RequestBody TokenInfo reIssueDto) {
return userService.reissue(reIssueDto);
}
}
- UserService.java
public TokenInfo reissue(TokenInfo reIssueDto) {
try {
jwtTokenProvider.getExpiration(reIssueDto.getAccessToken());
throw new BusinessException(VALID_ACCESS_TOKEN);
} catch (ExpiredJwtException e) {
// Refresh Token 유효성 검사
if (!jwtTokenProvider.isValidToken(reIssueDto.getRefreshToken())) {
throw new BusinessException(INVALID_REFRESH_TOKEN);
}
// Redis에 Refresh Token 존재 확인
boolean hasStoredRefreshToken = redisTemplate.hasKey("RT:" + reIssueDto.getRefreshToken());
if(!hasStoredRefreshToken) {
throw new BusinessException(LOGOUT_MEMBER);
}
String email = redisTemplate.opsForValue().get("RT:" + reIssueDto.getRefreshToken());
User user = userRepository.findByEmail(email).orElseThrow(
() -> new BusinessException(NOT_FOUND_MEMBER));
// AccessToken 재발급
// User의 role -> 스프링시큐리티의 GrantedAuthority로 변경
// 여러개의 role을 가질수 있으므로 Set
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
Set.of(SecurityUtils.convertToAuthority(user.getRole()))
);
return jwtTokenProvider.generateAccessToken(userDetails);
}
}
재생성.. 이제 자신있습니다..!
⭐ Refresh Token의 목적이 Access Token 만료 시, 토큰을 재발행 해주는 것이기 때문에, Access Token 만료 전에는 재발행할 수 없도록 하였습니다.
⭐ Access Token이 만료될 경우, Refresh Token의 유효성 검사를 수행합니다.
⭐ Redis 서버를 조회하여, 로그아웃한 User인지 확인합니다.
⭐ 토큰을 재발급받을 때,
Access Token만 재발급할 수도, 기존의 Refresh Token을 삭제하고 Access Token과 Refresh Token을 재발급할`수도 있습니다.
저는 로그인된 상태가 계속 유지되는 것이 한 번 Access Token이 탈취되면 보안 상 위험할 수 있다고 판단하여, Refresh Token까지 만료된 경우에는 다시 로그인을 하여 Access Token과 Refresh Token을 발급받도록 하였습니다.
- JwtTokenProvider.java
@Component // Spring이 이 클래스를 자동으로 스캔하고 빈으로 등록
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "roles";
private static final String BEARER_TYPE = "Bearer";
@Value("${jwt.secret}")
private String jwtSecretKey;
private final RedisUtil redisUtil;
private final UserRepository userRepository;
// 유저 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
public TokenInfo generateToken(UserDetails auth) {
// Access Token 생성
String accessToken = generateAccessToken(auth).getAccessToken();
// Refresh Token 생성
// 아무런 정보도 토큰에 넣지 않고, 단순히 IssuedAt과 Expiration만을 입력한 후 서명
String refreshToken = generateRefreshToken();
return TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public String generateRefreshToken() {
Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
Date now = new Date();
Date expirationRefreshToken = new Date(now.getTime() + Duration.ofMinutes(2).toMillis());
return Jwts.builder()
.setIssuedAt(now)
.setExpiration(expirationRefreshToken)
.signWith(secretKey)
.compact();
}
public TokenInfo generateAccessToken(UserDetails auth) {
Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
Date now = new Date();
Date expirationAccessToken = new Date(now.getTime() + Duration.ofMinutes(1).toMillis());
String authorites = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String accessToken = Jwts.builder()
.setSubject(auth.getUsername()) // 토큰 소유자 설정
.claim(AUTHORITIES_KEY, authorites) // JWT 내부에 추가적인 정보 설정
.setIssuedAt(now)
.setExpiration(expirationAccessToken)
.signWith(secretKey)
.compact();
return TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
//유저 정보 부분만 가져옴
Claims claims = parseClaims(accessToken);
if(claims == null)
throw new BusinessException(NOT_FOUND_MEMBER);
if (claims.get(AUTHORITIES_KEY) == null)
throw new BusinessException(NOT_FOUND_AUTHORITY);
// 클레임에서 권한 정보 가져오기
Set<GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY)
.toString()
.split(","))
.map(SecurityUtils::convertToAuthority) // 스트림의 각 요소를 다른 형태로 변환
.collect(Collectors.toSet());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails userDetails = UserPrinciple.builder()
.email(claims.getSubject())
.authorities(authorities)
.build();
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
// credentials: null, 인증이 완료된 후에는 실제 자격 증명(예: 비밀번호)을 사용할 필요가 없으므로 null로 설정
}
/**
* UsernamePasswordAuthenticationToken
* userDetails: 사용자의 정보(예: 이메일, 권한)를 담고 있는 객체
* null: 이 위치에 보통은 비밀번호나 인증 토큰이 들어가지만, 여기서는 이미 인증이 된 상태이므로 필요하지 않아 null로 설정
* authorities: 사용자가 가지고 있는 권한 목록
* */
// 토큰 정보를 검증하는 메서드
public boolean isValidToken(String token) {
if(token == null || token.isEmpty())
return false;
try {
return (!parseClaims(token).isEmpty());
} catch (MalformedJwtException e) {
throw new BusinessException(MALFORMED_JWT);
} catch (ExpiredJwtException e) {
throw new BusinessException(EXPIRED_JWT);
} catch (UnsupportedJwtException e) {
throw new BusinessException(UNSUPPORTED_JWT);
} catch (IllegalStateException e) {
throw new BusinessException(ILLEGAL_STATE_JWT);
}
}
// 리퀘스트의 토큰에서 암호풀어 Claims 가져오기
private Claims parseClaims(String token) {
if(redisUtil.hasKeyBlackList(token)){
throw new BusinessException(LOGOUT_MEMBER);
}
Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public Long getExpiration(String token) {
Key secretKey = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
// Token 남은 유효시간
Date expiration = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
// 현재 시간
Long now = new Date().getTime();
return (expiration.getTime() - now); // TimeUnit.MILLISECONDS
}
}
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
📑
참고 자료
https://jjhwang.tistory.com/15