지난 시간을 끝으로 JWT의 주요 기능은 구현을 마무리하였습니다. 이번에는 Refresh Token의 보안을 높일 수 있는 로직을 구현하고 테스트를 진행해보겠습니다.
Refresh Token의 경우에는 유효기간이 Access Token과 비교하면 상당히 길게 설정되어있습니다.
그래서 Refresh Token이 탈취되면 보안적으로 위험합니다. 이를 보완하기 위해 Access Token을 재발급할 때 Refresh Token도 다시 재발급을 해주는 방식을 도입할 수 있습니다. 이러한 방식을 Refresh Rotate라고 합니다.
Refresh Rotate
이번 구현에는 Redis를 활용하고 있습니다. Redis 설정에 관련해서는 별도로 설명하지 않고 바로 진행해 보겠습니다.
login 함수에서는 발급받은 Refresh Token을 Redis에 저장하는 로직이 필요합니다.
refresh 함수에서는 Redis에 Refresh Token이 저장되어있는지 확인하는 로직, Redis에 저장되어 있는 Refresh Token을 삭제하고 새로운 Refresh Token을 만들고 Redis에 저장하는 로직이 필요합니다.
각 로직에 대한 설명은 주석으로 작성하였습니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtUtil jwtUtil;
private final MemberService memberService;
private final RefreshTokenRepository refreshTokenRepository;
@Value("${spring.jwt.accessToken_expiration_time}")
private Long accessTokenExpiredMs;
@Value("${spring.jwt.refreshToken_expiration_time}")
private Long refreshTokenExpiredMs;
public AuthResponseDto login(AuthRequestDto authRequestDto) {
//authRequestDto가 가지고 있는 uuid를 조건으로 MemberResponseDto를 가져옵니다.
MemberResponseDto memberResponseDto = memberService.findByUuid(authRequestDto.getUuid());
//MemberResponseDto 값이 null이면 회원가입을 실행합니다.
if(memberResponseDto == null){
memberResponseDto = memberService.save(authRequestDto.getUuid(), authRequestDto.getNickname());
}
//MemberResponseDto를 기반으로 Access Token을 만듭니다.
String accessToken = jwtUtil.createJwt("access", memberResponseDto, accessTokenExpiredMs);
//MemberResponseDto를 기반으로 Refresh Token을 만듭니다.
String refreshToken = jwtUtil.createJwt("refresh", memberResponseDto, refreshTokenExpiredMs);
//createCookie() 함수를 호출하여 쿠키를 생성하고 저장합니다.
response.addCookie(createCookie("refresh", refreshToken));
//RefreshToken을 생성합니다.
RefreshToken token = RefreshToken.builder()
.uuid(memberResponseDto.getUuid())
.refreshToken(refreshToken)
.build();
//생성한 RefreshToken을 Redis에 저장합니다.
refreshTokenRepository.save(token);
//AuthResponseDto에 Access Token 값을 넣어서 생성합니다.
AuthResponseDto authResponseDto = AuthResponseDto.builder()
.accessToken(accessToken)
.build();
//AuthResponseDto을 반환합니다.
return authResponseDto;
}
public AuthResponseDto refresh(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = null;
//요청에 있는 쿠키를 배열 형태로 가져옵니다.
Cookie[] cookies = request.getCookies();
//쿠키가 null이면 IllegalArgumentException을 발생시킵니다.
if(cookies == null){
throw new IllegalArgumentException("잘못된 요청입니다.");
}
//쿠키에 저장된 Refresh Token 값을 가져옵니다.
for(Cookie cookie : cookies){
if(cookie.getName().equals("refresh")){
refreshToken = cookie.getValue();
break;
}
}
//Refresh Token의 값이 null이면 IllegalArgumentException을 발생시킵니다.
if(refreshToken == null){
throw new IllegalArgumentException("잘못된 요청입니다.");
}
//Refresh Token가 만료되었는지 확인하고 만료되었다면 IllegalArgumentException을 발생시킵니다.
try {
jwtUtil.isExpired(refreshToken);
}
catch (ExpiredJwtException e){
throw new IllegalArgumentException("잘못된 요청입니다.");
}
//Refresh Token의 category 값을 가져옵니다.
String category = jwtUtil.getCategory(refreshToken);
//category가 refresh가 아닌 경우에 IllegalArgumentException을 발생시킵니다.
if(!category.equals("refresh")){
throw new IllegalArgumentException("잘못된 요청입니다.");
}
//Redis에 Refresh Token이 저장되어 있지 않으면 IllegalArgumentException을 발생시킵니다.
if(!refreshTokenRepository.exist(refreshToken)){
throw new IllegalArgumentException("잘못된 요청입니다.");
}
//Refresh Token의 MemberResponseDto 값을 가져옵니다.
MemberResponseDto memberResponseDto = jwtUtil.getMemberResponseDto(refreshToken);
//MemberResponseDto를 기반으로 새로운 Access Token을 만듭니다.
String accessToken = jwtUtil.createJwt("access", memberResponseDto, accessTokenExpiredMs);
//MemberResponseDto를 기반으로 새로운 Refresh Token을 만듭니다.
refreshToken = jwtUtil.createJwt("refresh", memberResponseDto, refreshTokenExpiredMs);
//createCookie() 함수를 호출하여 쿠키를 생성하고 저장합니다.
response.addCookie(createCookie("refresh", refreshToken));
//기존에 Redis에 저장된 RefreshToken을 삭제합니다.
refreshTokenRepository.delete(refreshToken);
//RefreshToken을 생성합니다.
RefreshToken token = RefreshToken.builder()
.uuid(memberResponseDto.getUuid())
.refreshToken(refreshToken)
.build();
//생성한 RefreshToken을 Redis에 저장합니다.
refreshTokenRepository.save(token);
//AuthResponseDto에 Access Token 값을 넣어서 생성합니다.
AuthResponseDto authResponseDto = AuthResponseDto.builder()
.accessToken(accessToken)
.build();
//AuthResponseDto을 반환합니다.
return authResponseDto;
}
private Cookie createCookie(String key, String value){
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24 * 60 * 60);
//cookie.setHttpOnly(true);
//cookie.setAttribute("SameSite", "None");
//cookie.setPath("/");
//cookie.setSecure(true);
return cookie;
}
}
지금까지 구현한 Refresh Rotate가 실행이 되는지 테스트를 해보겠습니다. Access Token을 발급받을 때 마다 Refresh Token 값이 바뀌는지, Redis에 저장이 되고 삭제가 되는지 확인해보겠습니다.
처음 로그인 요청을 했을 때 발급받은 Refresh Token이 Redis에도 잘 저장이 되어있는 것을 확인했습니다.
이제 Access Token 재발급 요청을 했을 때 새로운 Refresh Token이 오는 것을 확인할 수 있었고 Redis에도 이전 Refresh Token가 삭제되고 새로운 Refresh Token가 저장이 되어있는 것을 확인했습니다.
이렇게 Refresh Rotate을 성공적으로 마무리할 수 있었습니다. 다음 포스트에서는 로그아웃을 마지막으로 JWT 도입 시리즈를 마무리하겠습니다!