Spring Security + jwt 로그인 기능 (2) - Redis 활용

허진혁·2023년 5월 26일
0

왜 Redis?

The open source, in-memory data store used by millions of developers as a database, cache, streaming engine, and message broker.

레디스는 key-value 쌍으로 데이터를 관리할 수 있는 데이터 스토리지로 표현할 거에요. 데이터베이스라고 표현하지 않은 이유는 기본적으로 레디스는 in-memory로 데이터를 관리해요. 이는 저장된 데이터가 영속적이지 않다는 것을 의미해요.

데이터가 Disk가 아니라 RAM에 저장하므로 데이터가 영구적으로 저장되지는 않지만, 굉장히 빠른 속도를 활용할 수 있어요. 빠른 액세스 속도와 휘발성이라는 특징으로 보통 캐시의 용도로 레디스를 많이 사용하는 것 같아요.

Refresh Token의 저장소로 레디스를 선택한 이유는 위와 같은 내용들 때문이에요. 사용자 로그인시 (리프레시 토큰 발급시) 빠른 접근이 목적이지요.

또한, 리프레시 토큰은 발급된 후 일정 시간 이후 만료되야 해요. 레디스는 시간 만료를 지원해 주어요. 그래서 만료 기간 설정을 매우 간단히 할 수 있어요. 이것 또한 레디스를 Refresh Token 저장용으로 사용하는 이유에요.

그리고 리프레시 토큰은 매우 중요한 데이터는 아니에요. 모든 데이터는 중요하지만, 우선순위를 상대적으로 나누어 볼 때 리프테리 토큰 데이터가 삭제된다고 하면 최악의 경우가 로그아웃 정도 되는 수준이에요.

물론 JWT와 같은 클레임 기반 토큰을 사용하면 리프레시 토큰을 서버에 저장할 필요가 없어요. 하지만, 사용자 강제 로그아웃 기능, 유저 차단, 토큰 탈취시 대응을 해야한다는 가정으로 서버에서 리프레시 토큰을 저장하도록 구현할 거에요.

토큰의 생명 주기

Access Token과 Refresh Token의 생명 흐름을 알아보아요.

Access Token 먼저 볼게요. Access Token은 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검증을 하며 사용자 권한을 인증해요.

이런 역할을 하는 Access Token이 탈취되면 토큰이 만료되기 전 까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해게 되요. 따라서 Access Token의 유효 주기는 짧게 가져갈 거에요(보통 30분).

그렇다면, 자동 로그인 혹은 로그인 유지는 어떻게 할까요?
이제 Refresh Token의 일이 시작되는 거에요. Refresh Token은 한 번 발급되면 Access Token보다 훨씬 길게 발급할 거에요. 대신에 접근에 대한 권한을 주는 것이 아니라 Access Token 재발급 용도로 사용하는 것이지요.

구현

Redis 설정

Lettuce vs Jedis

Spring Data Redis에서 사용할 수 있는 Redis Client 구현체는 크게 Lettuce와 Jedis가 있어요. 이번 프로젝트에서는 Lettuce를 사용할 거에요.

spring-boot-starter-data-redis 을 사용하면 별도의 의존성 설정 없이 Lettuce를 사용할 수 있어요. (Jedis는 별도의 설정이 필요)

Jedis를 사용하지 않는 이유는 몇가지 더 있지만 넘어갈 거에요. (이동욱님의 Jedis 보다 Lettuce 를 쓰자 포스팅을 읽어보고 다음에 Redis의 대한 포스트를 쓸 예정이에요.)

application.yml 설정

spring:
  redis:
    host: localhost
    port: 6379

Redis Repository vs Redis Template

스프링부트에서 Redis를 사용하는 방법에는 두가지가 있어요. Repository 인터페이스를 정의하는 방법과 Redis Template을 사용하는 방법이에요.

  • Repository
    Repository 인터페이스를 정의하는 방법은 Spring Data JPA를 사용하는 것과 비슷한 것 같아요. Redis는 많은 자료구조를 지원하는데, Repository를 정의하는 방법은 Hash 자료구조로 한정하여 사용할 수 있어요. Repository를 사용하면 객체를 Redis의 Hash 자료구조로 직렬화하여 스토리지에 저장할 수 있어요.

  • Redis Template
    Redis Template은 Redis 서버에 커맨드를 수행하기 위한 고수준의 추상화(high-level abstraction)를 제공해요.

저는 Refresh Token이 목적으로 Redis를 사용하려 해요. 그렇지만 차후에 캐쉬도 활용하는 법을 공부해야 하기에 Redis Template을 적용해볼 거에요.

RedisConfig

RedisTemplate을 사용하기 위해서는 아래와 같이 @Configuration을 통해서 redisTemplate을 빈 등록 해야해요.

LettuceConnectionFactory을 통해 Lettuce 방식을 사용하는 거에요.

@Configuration
public class RedisConfig {

    private final String host;

    private final int port;

    public RedisConfig(@Value("${spring.redis.host}") final String host,
                       @Value("${spring.redis.port}")final int port) {
        this.host = host;
        this.port = port;
    }

    // lettuce
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @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;
    }
}

RedisDao (토큰 확인 및 저장 역할)

@Component
public class RedisDao {

    private final RedisTemplate<String, Object> redisTemplate;

    public RedisDao(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void setValues(String key, String data) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public Object getValues(String key) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        return values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }

}

JwtTokenProvider

이 부분은 이전과 내용이 똑같지만 다음 두 가지가 달라요.

  • RedisDao 의존성 추가하기
  • RedisDao를 활용해서 Redis에 Refresh Token 넣기
@Slf4j
@Component
public class JwtTokenProvider {
	
    ...
    // 추가된 내용
    private final RedisDao redisDao;
    
    public JwtTokenProvider(@Value("${JWT.SECRET}") String secretKey,
                            RedisDao redisDao)
    {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.redisDao = redisDao;
    }
    
    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public MemberLoginDto.TokenResDto generateToken(Authentication authentication) {
        // ...
	    // 추가된 내용
        // Reids에 refresh Token 넣기
        redisDao.setValues(authentication.getName(), refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME));

        return MemberLoginDto.TokenResDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                .build();
    }
}

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

	// ...
    // 추가된 내용
    private final RedisTemplate redisTemplate;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        // 추가된 내용
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // (추가) Redis 에 해당 accessToken logout 여부 확인
            String isLogout = (String)redisTemplate.opsForValue().get(token);
            if (ObjectUtils.isEmpty(isLogout)) {
                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }

}

응답 확인

요청

응답

Redis 데이터 확인

전체적인 흐름 정리

로그인 인증

  1. id, pw 기반의 로그인 폼 요청이 들어옵니다.

  2. 요청된 데이터를 가지고 UsernamdPasswordAuthenticationToken을 생성하여 인증을 진행합니다.

  3. 실제 인증을 위한 authenticate() 메서드가 실행되고, 해당 메서드는 AuthenticationManager interface를 구현한 ProviderManager class의 authenticate() 메서드가 인증하는 것 입니다.

  4. ProviderManager의 authenticate() 메서드는 모든 provider(List로 되어 있음) 중에서 해당 인증을 처리할 수 있는 provider를 찾아 실제 인증 절차인 authenticate() 메서드를 실행시킵니다.

  5. 이때 provider가 AbstractUserDetailsAuthenticationProvider이고 authenticate() 메서드 로직 중 일부는 DaoAuthenticationProvider에 실제로 구현되어서 사용되고 있습니다.

  6. AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 통해 로그인 요청된 id, pw에 대한 인증이 처리됩니다.

토큰 갱신 과정

  1. Refresh Token이 유효한지 검증합니다.

  2. Access Token에서 Authentication 객체를 가지고 와서 저장된 name(email을 name으로 저장했음)을 가지고 옵니다.

  3. email을 가지고 Redis에 저장된 Refresh Token와 입력받은 Refresh Token 값과 비교합니다.

  4. Authentication 객체를 가지고 새로운 토큰을 생성합니다.

  5. Redis에 새로 생성된 Refresh Token을 저장합니다.

  6. 클라이언트에게 새로 발급된 토큰 정보를 내려줍니다.

참고 자료

Spring Security JWT 로그인 구현
레디스 공식 홈페이지
lettuce.io

profile
Don't ever say it's over if I'm breathing

0개의 댓글