쌩로그

JWT를 관리하기 위한 Redis 로직 본문

Project/23년 6월의 프로젝트

JWT를 관리하기 위한 Redis 로직

.쌩수. 2023. 7. 24. 15:52
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 토큰 관리에 대한 고민
      2-2. Redis 관련 JWT 로직
      2-3. Redis 관련 설정 코드
      2-4. 토큰 관리 로직
  3. 요약

1. 포스팅 개요

이번 프로젝트에서 JWT관리에 대해 진짜 많이 생각했고, 감이 잡히지 않았었다.
고민한 끝에 Redis를 이용하며 경험하게 되었고,
동시에 Redis를 이용해서 JWT 토큰 관리도 할 수 있었다.

이와 관련된 포스팅이다.

2. 본론

2-1. 토큰 관리에 대한 고민

부트캠프 마지막 프로젝트에서는 Redis를 로그아웃 토큰인지 아닌지를 구분하기 위해서 Redis를 이용했다.

현 프로젝트에서는 JWT를 아예 Redis로만 관리했다.

주변에 말을 들어보거나, 혹은 많은 블로그들을 봤는데, JWT 중 리프레시 토큰을 DB에 넣어두기도 하고,
혹은 지금 내가 한 것처럼 Redis에 넣어두기도 하는 글을 종종 봤다.

정답은 없었다,
그래서 더욱 감이 잡히지 않았고, 어떻게 해야될지 막막했지만,
그래도 자료를 찾아보고 몰입하다보니 나름의 해결책을 가지게 되었다.

보통 DB에 넣어놓는 리프레시 토큰은 만료기간을 2주정도로 설정한 것들이 대부분이었다.

하지만, 이번 프로젝트는 규모상 액세스 토큰은 30분, 리프레시 토큰은 24시간으로 설정했다.
그래서 DB에 넣어두면 JWT조회가 빈번할 것 같았다.
근데 이게 다 비용이라고 생각했다.
왜 Redis를 사용하게 되었는지에 대한 글을 썼다.

거기서 다음과 같은 글을 썼었다.

이런 이유로 Redis를 사용하게 되었다고 했다.

그래서 나는 여러가지 내용들을 고려해서 JWT를 아예 Redis로만 관리하기로 선택했다.

2-2. Redis 관련 JWT 로직

Redis와 관련된 JWT 관리 로직은 다음과 같다.

// 사용자가 OAuth 로그인을 한다.
// 그럼 AccessToken(액세스 토큰. 이하 액토)과 RefreshToken(리프레시토큰, 이하 리토)가 발급된다.
    // 이때 리토는 Redis에 저장된다.
// 사용자가 요청을 보낼 때 Header에 액토를 담아서 보낸다.
// 서버에선 요청의 Header에서 액토를 추출하고, 액토가 있는지 없는지 Redis를이용해서 검증한다.
    // 있으면, 넘어가고 없으면 없거나, 혹은 올바르지 않다는 예외를 던진다.
// 로그아웃을 한다.
    // 로그아웃을 하면, 사용자가 가지고 있던 리토는 삭제하고, 액토는 남은 만료시간 이후에 삭제되도록 설정한다.
    // 그리고 해당 액토는 로그아웃 된 토큰으로 분류하고, 해당 토큰으로 들어올 경우 로그아웃된 토큰이라는 예외를 던진다.
// 새로운 액토를 발급하려면, Header에 사용자 이메일과 리토를 요청으로 같이 보내게 한다.
    // 그럼 이메일과 리토를 통해서 유효한 리토인지 확인한 후, 액토를 발급해준다.

로직은 이와 같다.

2-3. Redis 관련 설정 코드

RedisConfig
레디스를 설정한 코드

@Configuration  
@EnableRedisRepositories  
public class RedisConfig {  
    @Value("${spring.redis.host}")  
    private String host;  

    @Value("${spring.redis.port}")  
    private int port;  


    @Value("${spring.redis.password}")  
    private String redisPassword;  


    @Bean  
    public RedisConnectionFactory redisConnectionFactory() {  
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();  
        redisStandaloneConfiguration.setHostName(host);  
        redisStandaloneConfiguration.setPort(port);  
        redisStandaloneConfiguration.setPassword(redisPassword);  
        return new LettuceConnectionFactory(redisStandaloneConfiguration);  
    }  

    @Bean  
    public RedisTemplate<String, Object> redisTemplate() {  
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();  
        redisTemplate.setConnectionFactory(redisConnectionFactory());   // Redis Client의 구현체로 Lettuce를 선택  

        // 일반적인 key:value의 경우 시리얼라이저  
        redisTemplate.setKeySerializer(new StringRedisSerializer());  
        redisTemplate.setValueSerializer(new StringRedisSerializer());  

        return redisTemplate;  
    }  

}
  • redisConnectionFactory() 메서드로 RedisConnectionFactory를 반환한다.
  • redisStandaloneConfiguration.setXX를 통해서 각 값을 설정한다.
    • host는 yml에서 로컬이라면 localhost, AWS라면, 'IP'로 설정한다.
    • port는 yml에서 불러오도록 설정했다.
    • redisPassword 역시 yml에서 불러오도록 설정했다.
  • redisTemplate()메서드로 RedisTemplate<String, Object>을 반환하는데, Redis Client의 구현체로 Lettuce를 선택했다.
  • String 타입의 key:value로 데이터를 저장하게 했다.

RedisUtils

Redis를 통해서 JWT를 조회, 저장, 삭제 메서드가 있는 클래스이다.

@Service  
public class RedisUtils {  

    private final RedisTemplate<String, Object> redisTemplate;  

    public RedisUtils(RedisTemplate<String, Object> redisTemplate) {  
        this.redisTemplate = redisTemplate;  
    }  
    // Redis에 저장 // 동일한 Key 덮어 씀  
    public void setData(String key, String value, Long expiredTime){  
        redisTemplate.opsForValue().set(key, value, expiredTime, TimeUnit.MILLISECONDS);  
    }  

    // Redis에서 조회  
    public String getData(String key){  
        return (String) redisTemplate.opsForValue().get(key);  
    }  

    // Redis에서 삭제  
    public void deleteData(String key){  
        redisTemplate.delete(key);  
    }  
}

저장시엔 key:value형태로 저장하기 때문에 동일한 key가 있더라도 다른 value가 들어오면 덮어쓴다.

2-4. 토큰 관리 로직

다음은 토큰 관리 서비스 로직이 있는 클래스이다.

@Service  
public class TokenService {  
    private final CustomAuthorityUtils customAuthorityUtils;  
    private final RedisUtils redisUtils;  
    private final JwtTokenizer jwtTokenizer;  

    public TokenService(CustomAuthorityUtils customAuthorityUtils, RedisUtils redisUtils, JwtTokenizer jwtTokenizer) {  
        this.customAuthorityUtils = customAuthorityUtils;  
        this.redisUtils = redisUtils;  
        this.jwtTokenizer = jwtTokenizer;  
    }  

    // 로그인 혹은 가입시 호출되는 메서드.  
    public String saveRefreshToken(String email, String refreshToken) {  
        long refreshTokenTime = jwtTokenizer.getRefreshTokenExpirationMinutes() * 60 * 1000;// 1440 * 60 * 1000  
        redisUtils.setData(email + ":refreshToken", refreshToken, refreshTokenTime);  
        String savedRefreshToken = redisUtils.getData(email + ":refreshToken");  
        return savedRefreshToken;  
    }  

    // AccessToken 재발급 메서드  
    public String reissueAccessToken(String email, String refreshToken) {  
        // RefreshToken 조회  
        Optional<String> optionalMemberRefreshToken = Optional.ofNullable(redisUtils.getData(email + ":refreshToken"));  
        // refreshToken 없으면 예외 발생.  
        String memberRefreshToken = optionalMemberRefreshToken.orElseThrow(() -> new CustomLogicException(ExceptionCode.REFRESH_TOKNE_WITHOUT));  
        // 동일하면, 액세스 토큰 재발급  
        if(memberRefreshToken.equals(refreshToken)) {  
            List<String> authorities = customAuthorityUtils.createRoles(email);  
            Map<String, Object> claims = new HashMap<>();  
            claims.put("username", email);  
            claims.put("roles", authorities);  

            String subject = email;  
            Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());  
            String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());  
            String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);  

            return "Bearer " + accessToken;  

        } else {    // RefreshToken 값이 다르면, 예외 발생  
            throw new CustomLogicException(ExceptionCode.REFRESH_TOKEN_INVALID);  
        }  

    }  

    // 로그아웃  
    public void logoutService(String email, String accessToken) {  
        // 이메일과 연결된 리프레시 토큰 삭제  
        redisUtils.deleteData(email + ":refreshToken");  
        // 액세스 토큰은 아직 유효하므로, 유효한 시간까지 블랙리스트로 넣어두고, 시간이 될 때 삭제하도록 한다.  
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());  
        Jws<Claims> claims = jwtTokenizer.getClaims(accessToken, base64EncodedSecretKey);  
        Date expiration = claims.getBody().getExpiration();  
        long millis = expiration.getTime() - System.currentTimeMillis(); // 남은 시간 millisSecond(밀리초)   
redisUtils.setData(email+":logOut", accessToken, millis);   // 밀리초 이후에 삭제.  
    }  




    // AccessToken 블랙리스트 검증  
        // Logout한 유저라면, 해당토큰을 가졌을 때, 사용하지 못 하도록 예외 발생해야한다.  
    // 액세스 토큰에서 email 추출 => Principal로 하려해봤자, 인증되지 않은 토큰이라면 그 전에 예외처리 발생하므로,  
    // 아예 해당 메서드에서 모든 로직 처리해야 됨.  
    public void verificationLogOutToken(HttpServletRequest request) {  
        // 액세스 토큰  
        String accessToken = request.getHeader("Authorization").replace("Bearer ", "");  

        // 토큰에서 email 추출  
        String email = jwtTokenizer.getClaims(accessToken, jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey())).getBody().getSubject();  

        // RedisUtils에서 LogoutToken을 얻어온다.  
        String blackListAccessToken = redisUtils.getData(email + ":logOut");  

        // 블랙리스트 토큰과 Header에 있는 토큰을 비교한다. // 같으면 에러 발생  
        if(accessToken.equals(blackListAccessToken)) {  
            throw new CustomLogicException(ExceptionCode.LOGOUT_TOKEN);  
        } // else는 필요없음. null이면 통과.  
    }  

} 
}

하나하나 살펴보면 다음과 같다.

  1. 소셜로그인 성공시 리토를 저장한다.

위의 메서드 OAuth 로그인 성공시 리토를 생성할 때 호출하는 메서드이다.

return 값으로
token서비스를 통해서 생성된 리토를 Redis에 저장하고,
저장된 리토를 얻어와서 반환한다.

  1. 액를 재발급하는 메서드

컨트롤러로부터 매개변수로, 이메일과 리토를 받아오는데,
이메일과 리토를 이용해서 Redis에서 이메일과 리토가 알맞은 값인지 판별한다.
알맞다면 액토를 응답헤더로 재발급해주고,
없으면 리토가 올바르지 않은 리토라는 예외를 던진다.

  1. 로그아웃

먼저 리토를 삭제한다.
그리고, 액토는 유효기간이 남아있으므로, 남은 시간을 계산한 후,
Redis에서 제공하는 지정한 시간 이후에 삭제하는 기능을 이용해서
남은 액토의 만료시간 이후에 삭제하도록 한다.

그래서 로그아웃을 하면,
남은 만료시간동안은 로그아웃 된 토큰이라고 하지만,
삭제된 이후에는 만료된 토큰이라는 예외를 던진다.

로그아웃 토큰이라는 메세지가 다음과 같이 나온다.

로그아웃된 토큰이 만료기간이 지나 삭제되면, 다음과 같이
만료된 토큰이라는 예외가 발생한다.

  1. 액토 블랙리스트 검증

컨트롤러로부터 HttpServletRequest를 가져온다.
가져온 Request의 Header에서 JWT를 추출하고,
추출한 JWT가 logout된 토큰인지를 검증한다.
// logout된 토큰이라면, 로그아웃 된 토큰이라는 예외를 던진다.
// logout된 토큰이 아니라면, 통과한다.

이처럼 관련 코드와 로직에 대해서 알아보았다.

3. 요약

Redis를 이용하여 JWT 관리를 하기까지
어떤 과정이 있었고,
어떻게 어떤 로직을 생각했으며,
어떻게 구현했는지에 대한 포스팅이었다.

728x90
Comments