본문 바로가기
부트캠프 개발일기/Pre-Project

88일차: Pre-Project Day 8-2 (JWT 로그아웃, Redis)

by shyun00 2023. 6. 20.

이번 프로젝트를 진행하면서 가장 복잡했던 부분이다. 부트캠프 과정에서 배우지 않았던 내용이라 검색을 통해 여러 자료를 참고했다.

아직 학습 중인 단계라서 정확하지 않은 부분이 있을 수 있다. (혹시 잘못된 부분이나 더 나은 방법이 있다면 편하게 말씀 부탁드립니다.^^)

 

우선 JWT는 세션방식과 달리 서버 측에 해당 정보가 저장되지 않는다. 따라서 서버에서 임의로 JWT를 삭제할 수는 없다.

그렇다면 어떻게 로그아웃을 구현할 수 있을까? -> 해당 토큰이 유효하지 않은 토큰이 되면 된다.

 

현재 리프레시 토큰을 사용해 액세스 토큰을 다시 발급받는 과정은 구현하지 않은 상태이다.

따라서 액세스 토큰을 무효화 하는것만 우선적으로 구현했다. (리프레시 토큰을 사용할 경우 해당 토큰도 무효화해야 한다.)

 

그렇다면 서버 측에는 토큰 정보가 저장되지 않는데 어떻게 토큰을 무효화할 수 있을까?

여기에서 Redis라는 개념이 나온다.

Redis는 Remote Dictionary Server의 약자로 오픈소스인 인메모리 데이터구조 저장소이다.

키 - 값 형태로 데이터를 저장한다. Redis에 데이터를 저장할 때 유효시간(TTL, Time To Live)을 지정해 주면 해당 시간이 지나면 자동으로 데이터가 삭제된다. 따라서 유효 시간이 정해져 있는 토큰을 관리하기에 적절했다. 

 

Redis를 사용해 로그아웃을 구현하기 위해 다음과 같은 순서로 작업을 진행했다.

  • Redis 설치
  • Redis 설정
  • 로그아웃 요청 오면 Redis에 해당 액세스토큰 블랙리스트(logout) 상태로 저장하는 로직 추가
  • JWT검증 조건에 logout 상태인지 확인하는 로직 추가 

[JWT 로그아웃]

1. Redis 설치: Homebrew를 통해 간단하게 설치할 수 있었다.

// redis 설치
brew install redis 

// redis 버전 확인(설치 여부 확인 가능)
redis-server --version

2. Redis 실행: 터미널에서 명령어를 통해 실행할 수 있다.

// foreground로 실행
redis-server

// background로 실행
brew services start redis

// background 실행 중지
brew services stop redis

// redis 실행 상태 확인
brew services info redis

redis 실행 화면, 기본 포트가 6379로 설정되어있다.

3. Spring 애플리케이션에 Redis 설정: 먼저 build.gradle 파일에 redis를 추가한다.

    application.yml 파일에 redis 관련 설정을 추가한다. 기본 포트인 6379를 적용해 주었다.

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
Spring:
  data:
    redis:
      host: localhost
      port: 6379

4. RedisRepositoryConfig: Redis 저장소를 사용하기 위한 기본 설정을 하였다.

    @EnableRedisRepositories를 통해 redis 레포지토리를 활성화하였다.

    RedisTemplate<String, Object>를 Bean으로 등록해서 사용한다.

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
    private final RedisProperties redisProperties;

    // LettuceConnectionFactory를 생성한다.
    // RedisProperties로 yml파일의 host, port를 가져와서 Redis 서버와 연결한다.
    @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;
    }
}

4. JwtTokenizer: 사용자 요청이 오면 해당 요청에서 AccessToken을 추출하는 메서드를 추가했다.

@Component
public class JwtTokenizer {

    ...

    // Request Header에서 토큰 정보 추출하는 메서드
    public String getAccessToken(HttpServletRequest request){
        String bearerToken = request.getHeader("Authorization");

        if (bearerToken.startsWith("Bearer ")) return bearerToken;   
        else return null;
}

5. Controller: 로그아웃 요청이 오면 해당 요청을 처리하는 로직을 추가했다. 아직 응답 형태는 정해지지 않아서 HTTP 상태만 리턴했다.

@RestController
@RequestMapping("/")
public class MemberController {

    ...

    @PostMapping("/logout")
    public ResponseEntity logout(@RequestHeader(value="Authorization") String accessToken,
                                 @RequestHeader(value="Refresh") String refreshToken) {
        memberService.logout(accessToken, refreshToken);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

6. Service: 로그아웃 요청이 오면 해당 AccessToken을 키로, 값은 logout으로 갖는 데이터를 redis에 저장하도록 했다.

    redisTemplate를 통해 redis 데이터베이스와 상호작용한다.

    opsForValue() 는 값에 대한 작업을 수행하는 데 사용되는 메서드이다.

    set(key, value, timeout, Time unit)은 키, 값과 유효시간을 데이터로 저장한다.

    키값으로 해당 액세스 토큰 값을, 값으로 로그아웃 상태를 갖게 된다.

@Service
public class MemberService {

    // redis에 데이터를 저장하기 위해 정보를 담고있는 RedisTemplate를 DI 해주었다.
    private final RedisTemplate redisTemplate;
    ...
        
        public void logout(String accessToken, String refreshToken){
        
        // Redis에 accessToken 사용 못하도록 블랙리스트 등록
        redisTemplate.opsForValue().set(accessToken, "logout", 30, TimeUnit.MINUTES);
    }
}

7. Filter에 로그아웃 상태 검증 추가: JwtVerificationFilter에 토큰이 로그아웃상태는 아닌지 확인하는 로직을 추가한다.

public class JwtVerificationFilter extends OncePerRequestFilter {
    ...
    private final RedisTemplate<String, Object> redisTemplate;


    // Logout 상태가 아닌지 확인하는 checkLogout() 메서드를 추가하였다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try{
            checkLogout(request);
            Map<String, Object> claims = verifyJws(request);
            setAuthenticationContext(claims);
        } catch (SignatureException se) {
            request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        }

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

    private void checkLogout(HttpServletRequest request){
        String accessToken = jwtTokenizer.getAccessToken(request);

        String isLogout = (String) redisTemplate.opsForValue().get(accessToken);
        // accessToken의 로그아웃 여부 확인. 이상 없으면 다음 단계로 넘어가고 아니면 오류발생함
        if(!ObjectUtils.isEmpty(isLogout)) throw new RuntimeException("로그아웃 상태");
    }

8. SecurityConfiguration: Spring Security가 적용되면 디폴트 로그아웃 기능이 있다.

실제로 포스트맨으로 요청을 보냈을 때 /logout으로 로그아웃 요청을 보냈는데 경로는 /login으로 나타나는 것을 확인했다.

이는 로그아웃 이후 /login 페이지로 자동으로 연결되도록 설정되어 있기 때문이다.

우리는 JWT를 통한 로그아웃을 구현할 것이므로 해당 기능을 비활성화했다.

@Configuration
public class SecurityConfiguration {
    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer())
                .and()
                .logout().disable() // Spring security에서 제공하는 기본 로그아웃기능 사용 안함, JWT 방식 사용예정
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers( "/members/**").authenticated()
//                        .antMatchers( "/questions/**").authenticated()
//                        .antMatchers("/answers/**").authenticated()
                        .anyRequest().permitAll());

        return http.build();
    }

[결과 확인]

redis를 실행하고 Spring Boot 애플리케이션을 실행하였다.

redis-cli , keys * 명령어를 통해 모든 키를 조회했을 때 아직은 아무것도 등록되지 않은 것을 확인할 수 있었다.

로그인 요청 후 받은 AccessToken을 헤더에 넣어 정보 Get 요청을 보냈을 때 정상적으로 작동했다.

이후 POST 메서드를 통해 로그아웃을 실행했다. 정상적으로 수행된 것으로 나온다.

다시 GET 요청을 보내보면 인증받았던 AccessToken을 넣었음에도 권한이 없는 것으로 나온다. 로그아웃이 적용됐다.

redis에서 확인해 보면 아까는 비어있던 것과 달리 AccessToken이 logout이라는 값과 함께 저장된 것을 확인할 수 있다.

아무래도 새롭게 시도해 보는 내용이다 보니 원하는 대로 작동하지 않을 때가 많았다.

그럴 때마다 log기록을 남기면서 파라미터들이 내가 원하는 값들을 가지고 있는지 확인하는 것이 많은 도움이 됐다.

앞으로도 코드 작성할 때 로그 기록을 잘 활용하면 문제를 비교적 빨리 찾을 수 있을 것 같다는 생각이 든다. :)

 

참고자료

Spring Security 로그아웃

security+jwt_redis 로그인 기능 구현

Spring Boot, H2 and Redis

Mac에서 Redis 설치하기

SpringBoot + JWT를 이용한 로그아웃

redis를 이용한 jwt 로그아웃 만들기

Redis로 JWT 만료 Logout