[Spring Boot] Spring Security+JWT 맛보기 (2) - Redis와 RefreshToken

CNH·2024년 3월 4일

개발

목록 보기
15/17

개요

저번 편에서는 RefreshToken은 사용하지 않았다. 물론 AccessToken만으로 로그인/로그아웃을 구현할 수는 있지만 AccessToken만 쓰면 토큰이 탈취당했을 때 위험이 크고, 유효시간을 짧게 가져가자니 사용자 경험이 나빠진다는 단점이 있다. RefreshToken을 함께 사용하면 이러한 문제를 어느정도 보완할 수 있다.

목표

  1. 로그인 시 Redis에 RefreshToken 저장
  2. RefreshToken을 사용하여 AccessToken 만료 시 AccessToken 재발급.
  3. RefreshToken 만료 시 로그아웃
  4. 로그아웃 구현

구현

1. Redis 기본세팅

관련 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){}

    }
}

2. 로그인 시 Redis에 RefreshToken 저장

우선 로그인 할 때 토큰 두 개(AccessTokenRefreshToken)이 만들어지는 때에 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();
    }

3. AccessToken 재발급

우리가 설정한 AccessToken 유효시간이 지나면 클라이언트에 403을 리턴하게 된다. 그럼 이제 클라이언트에서 403을 받았을 때, RefreshToken을 이용하여 AccessToken 재발급 요청을 하게끔 하면 된다. 이 때, 지금 갖고 있는 RefreshToken이 유효한지 확인하기 위해서 Redis에 해당 토큰을 key로 찾는다. valueemail이 제대로 찾아진다는 것은 해당 토큰이 아직 만료되지 않은 것이고, 결과가 null이라면 제대로된 토큰이 아니거나 이미 만료된 토큰이라는 뜻이다. 이후, return된 email로 DB에서 해당 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();
    }

4. 로그아웃

로그아웃은 내가 알기로는 DB에서 토큰을 삭제하고, 클라이언트 내부 스토리지에도 토큰을 삭제하면 되는 것으로 알고 있다. 그런데 지금 나는 DB에서 시간지나면 토큰이 자동으로 사라지고, 위에서 key-valueRefreshToken-email로 저장했기 때문에, 로그아웃 시 알 수 있는건 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);
    }

정리

의문점, 아쉬운점

  1. 예를 들어 AccessToken 만료일이 3일, RefreshToken 만료일이 7일이라고 하자. 이때, 6일째 되는 날에 AccessToken을 재발급받는다면 하루가 지난 후에는 RefreshToken이 만료가 되어도 마지막 AccessToken이 살아있을 때 까지는 정상 접속이 가능한 셈이 되는데, 이게 괜찮은가?

    보통 두 토큰의 만료일이 크게 차이가 나니까 괜찮을지도 모르겠다.

  2. 보통은 RefreshToken을 DB에 저장하는 듯 하다. 하지만 이번에는 일단 JPA 사용법을 잘 몰라서 Redis를 사용하기로 했기에(사실 Redis 사용법도 잘 모름;) 토큰을 저장하고 읽어오는데 많은 고민이 있었다. 결국 이쁘지는 않지만 컨트롤러에서 RefreshToken이 주어졌을 때 이를 저장하거나 찾아야했기 때문에 (key, value)의 형태로 (RefreshToken, email)의 형태로 저장하기로 했다. 그러나 이렇게 저장하니 반대로 로그아웃 할 때는 이메일로 해당 토큰을 찾아야 했기 때문에, 즉 valuekey를 찾아야 했기 때문에 해당 부분은 잘 구현하지 못했다.

  3. 또한 Redis의 TimeOut(시간지나면 자동으로 사라지는) 기능을 사용했기 때문에 RefreshToken 자체의 시간은 체크하지 않았다. 이렇게 하는게 맞나? 싶지만 위의 2번에서 말했듯이 Redis가 아닌 그냥 DB를 사용하면 이 점은 해결될 것 같다.

profile
끄적끄적....

0개의 댓글