SpringBoot + JWT를 이용한 로그아웃

HeavyJ·2022년 12월 26일
19

자바/스프링부트

목록 보기
8/17

JWT를 이용한 로그인 기능은 Access 토큰과 Refresh 토큰을 이용하여 구현에 성공했다.
그런데 이 JWT를 삭제할 수 있는 방식이 없는 것 같다...(로그아웃이 생각보다 어려워..) 그렇다면 JWT를 이용하여 로그아웃을 하려면 어떻게 해야 할까 고민을 하다가 토큰을 만료시키면 로그인이 안 되는거니까 토큰을 만료시켜야겠다는 생각을 했다.
그러면 가능한 시나리오들이 어떻게 될까? 구글링을 하면서 2가지 방법들을 찾아봤다
(내가 모르는 방법이 더 있을 수 있음)

  1. 클라이언트에 저장이 된 JWT 데이터를 제거하기
    JWT 데이터는 클라이언트의 storage에 저장이 된다. 따라서 프론트엔드 단에서 storage에 있는 JWT를 clear 하면 된다. 하지만, 이 방법의 단점은 만약 유저가 토큰을 미리 카피를 했다면 계속해서 서버에 요청을 보낼 수 있다.

  2. 블랙리스트 생성
    로그아웃하고 싶은 토큰들을 블랙리스트에 모은다. 그리고 블랙리스트에 토큰이 들어오면 해당 토큰을 무효화하는 작업을 진행하는 방법이다. JWT의 가장 큰 특징은 무상태성이고 클라이언트에 저장이 된다는 점이다. 그래서 데이터베이스를 사용할 필요가 전혀 없다.
    signature가 일치하고 토큰이 만료되지 않았다는 가정하에 유저가 언제든지 request에 접근이 가능하다. 하지만 이러한 점 때문에 이 토큰을 없애는 것은 매우매우 어렵게 된다.

그렇다면 블랙리스트를 써야하는 이유가 뭘까 ?

로그아웃을 할 때 토큰을 무효화해야 하는 이유는 토큰이 authentication에 사용되기 때문일 것이다. JWT는 클라이언트 사이드에서 삭제가 되더라도 만료시간이 지나지 않았다면 서버에서 여전히 사용이 가능하다(1번 경우). 따라서 Access Token을 블랙리스트로 저장하여 만료시키는 기능이 필요하다.

Redis를 JWT에서 사용해야 하는 이유?

JWT를 사용하여 유저 인증을 구현할 때 내가 사용하는 방식은 Access Token과 Refresh Token 이다. 이 두 토큰은 각각 인증과 재발급에 사용된다. Refresh Token은 Access Token이 만료되면 다시 Access Token을 발급해주는 토큰으로 계속적으로 Access Token을 업데이트하여 어플리케이션의 보안 성능을 향상시켜준다. 나의 경우 Access Token의 만료시간은 30분, Refresh Token의 만료시간은 14일로 설정했다. 그렇다면 Refresh Token은 어딘가에 저장을 해둬야 할텐데 데이터베이스에 저장을 하면 문제점이 발생한다. Refresh Token에도 유효기간이 존재하기 때문에 RDBMS에 저장을 하면 배치를 이용하여 주기적으로 삭제를 해줘야 하는 번거로움이 생긴다. 그래서 적절한 방법을 찾던 중에 Redis(인 메모리 데이터 저장소)를 찾았다. 추가적으로 Redis를 JWT 로그아웃에도 사용할 수 있으니까 일석이조다.

두 줄 요약

  • 주기적으로 삭제하는 번거로움 없어짐
  • 로그아웃 할 때도 사용이 덩달아 가능

그래서 어떻게 로그아웃 할건데?

클라이언트로부터 액세스 토큰과 리프레시 토큰을 둘 다 받아서 로그아웃 요청된 Access Token을 블랙리스트로 저장하는 기능이 추가된다. AccessToken을 Redis에 블랙리스트로 등록할 때 필요한 부분은 요청 들어온 AccessToken의 남은 유효시간이다. 로그아웃 성공된 회원의 AccessToken을 블랙리스트로 Redis에 등록할 때 토큰 값 자체를 키로 두고 value로 logout이라는 값을 준다.
이때 블랙리스트로 등록하는 액세스 토큰에 유효시간을 요청시 받은 액세스토큰의 남은 유효시간만큼 준다. 이렇게 되면 로그아웃된 액세스 토큰으로 요청이 들어왔을 때 해당 토큰의 유효성이 남아있는 동안 Redis에 해당 토큰 값이 key로 블랙리스트 등록이 되어 있기 때문에 로그인을 할 수 없다.
그리고 요청하는 AccessToken의 유효시간이 만료되었을 때 당연히 요청할 수 없고, 이 때 블랙리스트에 등록된 AccessToken 값도 자동으로 삭제된다.

그러면 내가 해야 할 일은?
1. redis 설정하기
2. 로그인 할 때 redis에 토큰 저장하기
3. 로그아웃 할 때 redis의 토큰 삭제하고 내가 로그아웃 하고 싶은 토큰을 만료시간 설정하여 블랙리스트에 추가
-> 여기서 블랙리스트에 등록한다는 것은 key를 Access Token value를 logout으로 등록한다는 것과 동일, 실질적인 블랙리스트는 존재하지 않는다^^. 그냥 편의상 그렇게 부르는 듯하다

Redis 설정하기

  • 레디스를 설치하기 (생략)

  • build.gradle에 해당 코드를 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  • yaml 혹은 properties에 레디스 port 설정하기
spring:
  redis:
    host: localhost
    port: 6379
  • redisTemplateConfig 클래스를 생성해서 redis를 사용하기 위한 설정들을 입력해준다.
package com.example.concalendar.user.config;

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {

    private final RedisProperties redisProperties;

    // lettuce
    // RedisConnectionFactory 인터페이스를 통해 LettuceConnectionFactory를 생성하여 반환한다.
    // RedisProperties로 yaml에 저장한 host, post를 가지고 와서 연결한다.
    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    // setKeySerializer, setValueSerializer 설정으로 redis-cli를 통해 직접 데이터를 보는게 가능하다.
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}
  • 기존의 jwtAuthenticationFilter 클래스에 유효한 토큰인지 확인하는 doFilter 메서드에 로그아웃 관련 코드를 추가해준다. String isLogout이 존재하면 Authentication을 받아오지 못하도록 설정해준다.
	@Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // log를 이용한 확인
        log.info("[This is Verifying token!!]"); // 그냥 스트링 출력해주는 로
        log.info(((HttpServletRequest) request).getRequestURL().toString());

        // 유효한 토큰인지 확인합니다. -> validation 진행
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // Redis에 해당 accessToken logout 여부를 확인
            String isLogout = (String) redisTemplate.opsForValue().get(token);

            // 로그아웃이 없는(되어 있지 않은) 경우 해당 토큰은 정상적으로 작동하기
            if (ObjectUtils.isEmpty(isLogout)) {

                // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                // SecurityContext 에 Authentication 객체를 저장합니다.
                SecurityContextHolder.getContext().setAuthentication(authentication);

            }
        }
        chain.doFilter(request, response);
    }


로그인 서비스 수정

그 후에 로그인 service를 수정해주자.
jwtTokenProvider의 createToken(유저이메일, 유저역할) 메서드를 통해 AccessToken과 RefreshToken을 발급받는다
그리고 redisTemplate 빈(bean) 객체를 가져와서 opsForValue().set()메서드로 해당 토큰을 redis에 저장한다

UserService

	@Transactional
    public TokenDto login(UserDto userDto) {
        // 로그인 시 Email이 일치하면 유저 정보 가져오기
        User user = userRepository.findByUserEmail(userDto.getUserEmail())
                .orElseThrow(()->new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
        // 로그인 시 패스워드가 불일치하면 에러 발생
        if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())){
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }
        // AccessToken, Refresh Token 발급하기
        TokenDto tokenDto = jwtTokenProvider.createToken(user.getUsername(), user.getRoles());

		//redis에 RT:13@gmail.com(key) / 23jijiofj2io3hi32hiongiodsninioda(value) 형태로 리프레시 토큰 저장하기 
        redisTemplate.opsForValue().set("RT:"+user.getUserEmail(),tokenDto.getRefreshToken(),tokenDto.getRefreshTokenExpiresTime(), TimeUnit.MILLISECONDS);

        return tokenDto;
    }

로그아웃 서비스 수정

이후에 UserService 클래스의 logout 메서드를 추가해준다. 매개변수는 accessToken과 refreshToken으로 구성된 TokenRequestDto 객체를 넣어준다.
먼저, 로그아웃하고 싶은 토큰이 유효한지 검증하고 유효하다면 AccessToken으로 Authentication -> User값을 가져온다
그리고 로그인 할 때 저장했던 redis refresh 토큰을 redisTemplate을 이용하여 삭제한다.
마지막으로 해당 Access 토큰을 다시 redis에 저장한다. 단, 저장할 때 value 값을 "logout"으로 설정하고 accessToken의 만료시간을 추가해서 넣어준다.

	@Transactional
    public void logout(TokenRequestDto tokenRequestDto){
        // 로그아웃 하고 싶은 토큰이 유효한 지 먼저 검증하기
        if (!jwtTokenProvider.validateToken(tokenRequestDto.getAccessToken())){
            throw new IllegalArgumentException("로그아웃 : 유효하지 않은 토큰입니다.");
        }

        // Access Token에서 User email을 가져온다
        Authentication authentication = jwtTokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

        // Redis에서 해당 User email로 저장된 Refresh Token 이 있는지 여부를 확인 후에 있을 경우 삭제를 한다.
        if (redisTemplate.opsForValue().get("RT:"+authentication.getName())!=null){
            // Refresh Token을 삭제
            redisTemplate.delete("RT:"+authentication.getName());
        }

        // 해당 Access Token 유효시간을 가지고 와서 BlackList에 저장하기
        Long expiration = jwtTokenProvider.getExpiration(tokenRequestDto.getAccessToken());
        redisTemplate.opsForValue().set(tokenRequestDto.getAccessToken(),"logout",expiration,TimeUnit.MILLISECONDS);

    }
profile
There are no two words in the English language more harmful than “good job”.

4개의 댓글

comment-user-thumbnail
2023년 2월 16일

안녕하세요:) 좋은 정보 감사합니다! 혹시 UserService에 사용된 userDto 작성된 걸 볼 수 있을까요..?

2개의 답글