저번 편에서는 RefreshToken은 사용하지 않았다. 물론 AccessToken만으로 로그인/로그아웃을 구현할 수는 있지만 AccessToken만 쓰면 토큰이 탈취당했을 때 위험이 크고, 유효시간을 짧게 가져가자니 사용자 경험이 나빠진다는 단점이 있다. RefreshToken을 함께 사용하면 이러한 문제를 어느정도 보완할 수 있다.
RefreshToken 저장RefreshToken을 사용하여 AccessToken 만료 시 AccessToken 재발급.RefreshToken 만료 시 로그아웃관련 Dependency를 아래처럼 추가하고, Windows용 Redis를 다운받아 설치한다.
//redis implementation 'org.springframework.boot:spring-boot-starter-data-redis'
또한 아래처럼 Redis 관련 기본 클래스들을 만들어준다. 필자는 Redis 비밀번호도 설정해 주었다.
RedisRepository의 메소드들은 아래에서 쓸 메소드들이다.
package com.example.securitytest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
@Slf4j
public class RedisRepositoryConfig {
private final RedisProperties redisProperties;
@Value("${spring.data.redis.password}")
private String redisPassword;
@Bean
public RedisConnectionFactory redisConnectionFactory(){
log.info("현재 레디스 접속 : {}, {}", redisProperties.getHost(), redisProperties.getPort());
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
lettuceConnectionFactory.setPassword(redisPassword);
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<Object, Object> redisTemplate() {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setEnableDefaultSerializer(false);
return redisTemplate;
}
}
package com.example.securitytest;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisRepository {
private final RedisTemplate<Object, Object> redisTemplate;
private ValueOperations<Object, Object> valueOperations;
@PostConstruct
public void init() {
valueOperations = redisTemplate.opsForValue();
}
public void saveRefreshToken(String email, String token, int timeLimit){
// valueOperations.set(email, token);
// log.info("Redis test : {}", valueOperations.get(email));
//refreshtoken을 기준으로 찾기 위해서 email이 아닌 token을 key로 설정
valueOperations.set(token, email);
saveKeyValue(token, email, timeLimit, TimeUnit.MILLISECONDS);
log.info("Redis test : {}", valueOperations.get(token));
}
public String findEmailByRefreshToken(String token){
ValueOperations<Object, Object> valueOperations = redisTemplate.opsForValue();
if(valueOperations.get(token) == null) return null;
return valueOperations.get(token).toString();
}
public void logout(User user) {
//Redis에서 토큰 삭제
//지금은 key가 token이라 찾기 어려움
}
private void saveKeyValue(String key, String value, int limitMinute, TimeUnit timeUnit){
try{ // 미봉책. 나중에 더 상세히 파 볼 것.
valueOperations.set(key, value, limitMinute, timeUnit);
log.info("key: {}, value: {} 로 {} 간 redis 저장", key, value, limitMinute);
}catch(NullPointerException ignored){}
}
}
RefreshToken 저장우선 로그인 할 때 토큰 두 개(
AccessToken과RefreshToken)이 만들어지는 때에 Redis에 저장도 하자. 일정 시간이 지나면 자동으로 사라질 수 있게끔TimeOut도 적용하자.
//JwtTokenProvider.java
public JwtTokenResponse makeJwtTokenResponse(User user) {
String accessToken = makeAccessToken(user.getEmail(), user.getRoles());
String refreshToken = makeRefreshToken(user.getEmail());
redisRepository.saveRefreshToken(user.getEmail(), refreshToken, refreshTokenValidTime);
return JwtTokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType(tokenType)
.build();
}
AccessToken 재발급우리가 설정한
AccessToken유효시간이 지나면 클라이언트에403을 리턴하게 된다. 그럼 이제 클라이언트에서403을 받았을 때,RefreshToken을 이용하여AccessToken재발급 요청을 하게끔 하면 된다. 이 때, 지금 갖고 있는RefreshToken이 유효한지 확인하기 위해서 Redis에 해당 토큰을key로 찾는다.value인User를 찾은 다음(여기서는 아직 DB가 없기에 이과정은 생략), 기존RefreshToken과 함께 새로운AccessToken을 만들어 클라이언트에 보내준다.
//UserController.java
@PostMapping("/refreshtoken")
public JwtTokenResponse updateAccessToken(@RequestBody UpdateAccessTokenRequest request){
String email = userService.findEmailByRefreshToken(request.refreshToken());
log.info("refreshToken : {}", email);
if(email==null){
return jwtTokenProvider.makeJwtTokenResponseWithNull();
}
//Redis의 Timeout을 사용하지 않았다면 DB에서 email을 가져온 후,
//이 token이 만료되었는지 여부도 따져야 함.
User user = userService.findUserByEmail(email);
String accessToken = jwtTokenProvider.makeAccessToken(user.getEmail(), user.getRoles());
return jwtTokenProvider.makeJwtTokenResponseWithToken(accessToken, request.refreshToken());
}
//UserService.java
public String findEmailByRefreshToken(String refreshToken){
//redis에 refreshToken있는지, refreshToken이 유효한지 검사
//(token, email)의 형식으로 redis에 저장하여 email을 찾음
String email = redisRepository.findEmailByRefreshToken(refreshToken);
if(email==null) return null;
return email;
}
public User findUserByEmail(String email) {
//원래는 실제로 DB에서 User를 찾아서 갖고와야함.
HashSet hs = new HashSet<Role>();
hs.add(Role.USER);
return User.builder()
.email(email)
.password("TESTPW")
.roles(hs)
.build();
}
로그아웃은 내가 알기로는 DB에서 토큰을 삭제하고, 클라이언트 내부 스토리지에도 토큰을 삭제하면 되는 것으로 알고 있다. 그런데 지금 나는 DB에서 시간지나면 토큰이 자동으로 사라지고, 위에서
key-value를RefreshToken-email로 저장했기 때문에, 로그아웃 시 알 수 있는건value로는key를 찾을 수가 없어서 그냥 생략했다..
url을
/logout이 아닌/userlogout으로 한 이유는 트러블슈팅 참고.
//UserController.java
@DeleteMapping("/userlogout")
public String logout(@AuthenticationPrincipal User user){
log.info("logout 진입");
userService.logout(user);
return "로그아웃 완료 "+user.getEmail();
}
//UserService.java
public void logout(User user) {
redisRepository.logout(user);
}

예를 들어 AccessToken 만료일이 3일, RefreshToken 만료일이 7일이라고 하자. 이때, 6일째 되는 날에 AccessToken을 재발급받는다면 하루가 지난 후에는 RefreshToken이 만료가 되어도 마지막 AccessToken이 살아있을 때 까지는 정상 접속이 가능한 셈이 되는데, 이게 괜찮은가?
보통 두 토큰의 만료일이 크게 차이가 나니까 괜찮을지도 모르겠다.
보통은 RefreshToken을 DB에 저장하는 듯 하다. 하지만 이번에는 일단 JPA 사용법을 잘 몰라서 Redis를 사용하기로 했기에(사실 Redis 사용법도 잘 모름;) 토큰을 저장하고 읽어오는데 많은 고민이 있었다. 결국 이쁘지는 않지만 컨트롤러에서 RefreshToken이 주어졌을 때 이를 저장하거나 찾아야했기 때문에 (key, value)의 형태로 (RefreshToken, email)의 형태로 저장하기로 했다. 그러나 이렇게 저장하니 반대로 로그아웃 할 때는 이메일로 해당 토큰을 찾아야 했기 때문에, 즉 value로 key를 찾아야 했기 때문에 해당 부분은 잘 구현하지 못했다.
또한 Redis의 TimeOut(시간지나면 자동으로 사라지는) 기능을 사용했기 때문에 RefreshToken 자체의 시간은 체크하지 않았다. 이렇게 하는게 맞나? 싶지만 위의 2번에서 말했듯이 Redis가 아닌 그냥 DB를 사용하면 이 점은 해결될 것 같다.