저는 JWT를 통해서 로그인/로그아웃 하는 과정을 구현했습니다.
JWT를 사용하는 이유는 무상태성을 가지므로 서버 확장 혹은 축소를 할 때 유용하다는 장점이 있습니다.
토큰 재발급 시에 수정 사항이 발생해서 기록용으로 블로그를 작성했습니다.
Refresh Token을 현재 HTTP Only 쿠키에 담아서 전송을 하고 있습니다. 왜냐하면 자바스크립트 기반 공격을 방어하고 싶기 때문입니다. 대표적인 자바스크립트 기반 공격은 XSS 공격으로 해커가 클라이언트 브라우저에 Javascript를 삽입해서 실행하는 공격입니다. HTTP Only 옵션을 추가하면 자바스크립트로 쿠키를 조회하지 못합니다.
Access Token은 Response Body에 넘겨주는 방식을 사용했습니다.
현재 이렇게 토큰을 프론트로 넘기니 발생하는 문제가 있었습니다.
Refresh Token을 통해 재발급을 받는 과정을 Access Token과 Refresh Token을 같이 매개변수로 받는 방식으로 진행을 하니까 프론트엔드에서 문제가 발생했습니다.
발생하게 된 문제는 페이지를 새로 고침할 경우 Access Token에 대한 정보가 초기화 되기 때문에 클라이언트에서 서버로 Access Token을 넘겨주지 못합니다. 따라서 재발급을 받아야 하는 상황에서는 오직 Refresh Token 만을 매개변수로 사용하여 재발급하는 코드를 구현해야 했습니다.
Redis에 Refresh Token을 dictionary 구조로 key(userEmail) : value(refreshToken) 저장하고 있었습니다.
기존에는 Access Token을 통해서 userEmail을 찾아서 해당 key 값을 가진 userEmail의 value값이 쿠키에 있는 Refresh Token과 일치하는 지 확인하는 과정을 거쳤습니다. Refresh Token이 일치하면 재발급을 해주고 Refresh Token이 일치하지 않으면 재발급을 해주지 않는 코드를 구현했습니다.
TokenService
public TokenDto reIssue(String refreshToken, String accessToken){
String userEmail = jwtTokenProvider.getUserPk(accessToken);
if ( !redisTemplate.opsForValue().get(userEmail).equals(refreshToken) ) {
Object o = redisTemplate.opsForValue().get("refresh:"+refreshToken);
if (o == null) {
throw new CustomException(StatusEnum.BAD_REQUEST, "일치하는 Refresh Token이 존재하지 않습니다");
}
user = userService.findUserByUserEmail(userEmail);
TokenDto newCreatedToken = jwtTokenProvider.createToken(user.getUsername(), user.getRoles(), user.getUserId());
redisTemplate.opsForValue().set(
user.getUserEmail(),
newCreatedToken.getRefreshToken(),
newCreatedToken.getRefreshTokenExpiresTime(),
TimeUnit.MILLISECONDS
);
return newCreatedToken;
}
이렇게 하면 redis에 저장되는 형태는 아래와 같습니다.
Redis
"15@gmail.com"(userEmail = key) : "sdgijhioehgqheiwejfoi;wejfiowehiowehioh"(refresh token = value)
다만, Refresh Token만을 매개변수로 가져오기 때문에 레디스에 접근할 때 모든 키 값을 찾아서 키 값에 해당하는 모든 value값까지 찾아야 하는 번거로움이 발생했습니다.
Refresh Token의 claim에 사용자 정보(ex. user id, user email)을 추가하는 방법도 생각해봤지만 Refresh Token의 긴 유효기간을 가지는 특성 때문에 탈취될 경우 보안에 큰 문제가 발생할 수 있어서 사용자 정보를 넣지 않았습니다.
이 문제를 해결해주기 위해서 redis의 key 값을 refresh token으로 바꾸고 value 값을 userEmail로 수정했습니다.
그래서 redis의 key 값을 쭉 읽어서 redis의 key 값과 일치하는 Refresh Token이 있을 경우 재발급을 해주는 코드로 변경했습니다. 이 방식이면 Refresh Token이 일치하면 value 값(사용자 정보)를 통해 재발급 토큰을 만들 수 있습니다.
TokenService
public TokenDto reIssue(String refreshToken){
// TokenDto 객체 선언
TokenDto newCreatedToken;
// 재발급 토큰에 사용할 User 객체 선언
User user;
// 만료된 refresh token 에러
if (!jwtTokenProvider.validateToken(refreshToken)){
throw new CustomException(StatusEnum.BAD_REQUEST, "RefreshToken이 만료되었습니다.");
}
Object o = redisTemplate.opsForValue().get("refresh:"+refreshToken);
if (o == null) {
throw new CustomException(StatusEnum.BAD_REQUEST, "일치하는 Refresh Token이 존재하지 않습니다");
}
user = userService.findUserByUserEmail(o.toString());
// User객체의 이름과 역할을 매개변수로 token을 다시 생성한다.
newCreatedToken = jwtTokenProvider.createToken(user.getUsername(), user.getRoles(), user.getUserId());
log.info("Access 토큰을 재발급했습니다."+newCreatedToken.getAccessToken());
// Refresh Token을 Redis에 업데이트 하기
redisTemplate.opsForValue().set(
"refresh:"+newCreatedToken.getRefreshToken(),
user.getUserEmail(),
newCreatedToken.getRefreshTokenExpiresTime(),
TimeUnit.MILLISECONDS
);
return newCreatedToken;
Redis
"sdgijhioehgqheiwejfoi;wejfiowehiowehioh"(refresh token = key) :
"15@gmail.com"(userEmail = value)
이렇게 레디스 저장구조와 자바 코드가 바뀌게 되었습니다.
제가 코드를 이런식으로 구성한 이유는 Refresh Token에 사용자 정보를 넣지 않고 순수 재발급을 위한 토큰으로 만들고 싶었기 때문입니다. 이 방식으로 클라이언트에서 따로 처리를 하지 않아도 손쉽게 재발급 토큰을 받을 수 있게 되었습니다!!
흥미롭게 읽었습니다.
일치하는 토큰을 조회할 때 Object 형으로 받아오는 특별한 이유가 있는지 궁금합니다.🤔