m1ndy5's coding blog

[SpringBoot 3.0] JWT로 로그아웃을???(feat. Redis) 본문

Toy Projects/Ditto - Discuss Today's Topic

[SpringBoot 3.0] JWT로 로그아웃을???(feat. Redis)

정민됴 2024. 1. 25. 00:35

JWT가 무엇일까? 가 궁금한 사람은
https://m1ndy5.tistory.com/89
위 글을 먼저 읽고 오시길!!ㅎㅎ
JWT가 무엇인지 알았다면 JWT의 장점 중에 하나는 바로 Stateless하다는 것을 알았을 것이다!
즉, 서버가 이 토큰을 저장하고 있지 않다는 것인데... 여기서 궁금증이 생겼다.
JWT 토큰은 클라이언트 쪽에서 보관한다. 서버는 보관하지 않는다.
그럼 로그아웃을 할 때 클라이언트에 있는 토큰을 어떻게 없애지...??
JWT와 연관되서 로그아웃이 가능한거야?? 라는 궁금증을 품고 답을 찾아 해맸다!!!
그 결과 다음과 같은 로직을 찾을 수 있었다.

  1. 로그아웃을 요청한다.
  2. 해당 accesstoken을 blacklist에 저장한다.
  3. 요청의 accesstoken이 blacklist에 있는지 확인하고 있다면 토큰을 사용하지 못하게 막는다.

blacklist??? 그러면 얘는 어떻게 만들어야할까.. 새로운 테이블을 또 파야하나?? 하고 생각하고 있을 때 많은 사람들이 redis를 사용해서 구현한 것을 찾아볼 수 있었다.

Redis

Redis는 key-value 타입의 인메모리 저장소로서 데이터베이스, 캐시, 메세지 브로커 등으로 사용되는 고성능 NoSQL Database이다.

인메모리 저장소란?

컴퓨터의 메인 메모리 RAM에 데이터를 올려서 사용하는 방법을 말한다.
이 방법은 다른 저장공간에서 데이터를 가져오는 것보다 수백배 빠르므로 빠른 속도가 가장 큰 장점이다.
하지만 컴퓨터의 RAM 용량을 따라 가기 때문에 메인 데이터베이스로 사용하기엔 무리가 있다.
어?? 그런데 자바 ConcurrentHashMap을 쓰면 되지 않나 어짜피 로컬에 저장되는 건 똑같은데?? 라고 생각하겠지만
https://stackoverflow.com/questions/20376901/using-redis-to-cache-java-objects-why-it-should-be-better-than-a-concurrenthash
이 글을 읽어보면 ConcurrentHashMap은 외부 access가 필요하지 않고 토탈 크기가 많이 크지 않고 등등 Redis가 가진 능력(?)보다 더 가볍게 원할 때 사용하면 좋다는 것을 알 수 있다.

Redis 사용법

먼저 Redis를 사용하려면 해줘야하는 세팅들이 있다.

// redis 의존성
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

의존성을 추가해주고

  redis:
    pool:
      min-idle: 0
      max-idle: 8
      max-active: 8
    port: 6379
    host: localhost

yml에도 redis 관련된 설정들을 몇가지 해준다.

RedisProperties

package hanghae99.ditto.auth.support;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "spring.redis")
@Getter
@Setter
public class RedisProperties {

    private int port;
    private String host;
}

yml파일에 적어놨던 값들을 넣어주고

RedisConfig

package hanghae99.ditto.global.config;

import hanghae99.ditto.auth.support.RedisProperties;
import lombok.RequiredArgsConstructor;
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
public class RedisConfig {
    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(redisProperties.getHost(),
                redisProperties.getPort());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(){
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

redis연결을 도와줄 빈과 redis틀을 등록시켜준다.

RedisUtil

package hanghae99.ditto.auth.support;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
@RequiredArgsConstructor
public class RedisUtil {
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisTemplate<String, Object> redisBlackListTemplate;

    public void set(String key, Object o, int minutes){
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object get(String key){
        return redisTemplate.opsForValue().get(key);
    }

    public boolean delete(String key){
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }

    public boolean hasKey(String key){
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    public void setBlackList(String key, Object o, int minutes){
        redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object getBlackList(String key){
        return redisBlackListTemplate.opsForValue().get(key);
    }

    public boolean deleteBlackList(String key){
        return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
    }

    public boolean hasKeyBlackList(String key){
        return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
    }
}

Redis를 사용할 수 있는 메서드를 정의했다.
나는 여기서 setBlackList와 hasKeyBlackList를 사용했는데, 로그아웃을 했을 때 setBlackList에 등록하고
accesstoken을 validate할 때 hasKeyBlackList로 사용할 수 없는 토큰인지 확인했다.

AuthService

package hanghae99.ditto.auth.service;

import hanghae99.ditto.auth.domain.MemberAuthenticationCodeEntity;
import hanghae99.ditto.auth.domain.MemberAuthenticationCodeRepository;
import hanghae99.ditto.auth.dto.request.AuthenticateCodeRequest;
import hanghae99.ditto.auth.dto.request.LoginRequest;
import hanghae99.ditto.auth.dto.request.LogoutRequest;
import hanghae99.ditto.auth.dto.request.SendEmailAuthenticationRequest;
import hanghae99.ditto.auth.dto.response.EmailAuthenticationResponse;
import hanghae99.ditto.auth.dto.response.LoginResponse;
import hanghae99.ditto.auth.support.JwtTokenProvider;
import hanghae99.ditto.auth.support.RedisUtil;
import hanghae99.ditto.global.entity.UsageStatus;
import hanghae99.ditto.member.domain.Member;
import hanghae99.ditto.member.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
    private final MemberAuthenticationCodeRepository memberAuthenticationCodeRepository;
    private final MemberRepository memberRepository;
    private final EmailService emailService;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisUtil redisUtil;


    public HttpEntity<?> logout(LogoutRequest logoutRequest){
        if(!jwtTokenProvider.validateToken(logoutRequest.getToken())){
            return new ResponseEntity<>(
                new LoginResponse(0L, "이미 로그아웃한 멤버입니다."), HttpStatus.BAD_REQUEST);
        }
        redisUtil.setBlackList(logoutRequest.getToken(), "accessToken", 5);
        return new ResponseEntity<>(
                new LoginResponse(1L, "로그아웃 완료"), HttpStatus.OK
        );
    }
}

jwtTokenProvider.validateToken에 토큰이 블랙리스트에 있는 토큰이면 false를 리턴하게 했다.
Redis라는 새로운 개념을 알게되서 재미있었던 구현이었던 것 같다!
이로써 JWT로 로그아웃 구현하기 끝~~!!