본문 바로가기
개발/Spring

Redis를 통한 JWT Refresh Token 관리

by solchan98 2022. 2. 11.

Spring Security와 JWT 그리고 Redis를 통한 Access Token 재발급

 

2024.05.18 글이 새로 업데이트 되었습니다.

 

 

저장소

 

Playground/spring-security-jwt at main · solchan98/Playground

🛝 개발 공부 놀이터 🛝. Contribute to solchan98/Playground development by creating an account on GitHub.

github.com

 

 

본 포스팅은 이전 Spring Security와 JWT를 통한 인증 및 인가 과정에 이어서 진행된다.

JWT는 한 번 발급되면 만료되기 전 까지 삭제할 수 없다.
이 문제를 어떻게 해결할까?

일반적으로 RefreshToken이라는 추가적인 토큰을 통해 이를 해결한다.
그러면 어떻게 해결하는지 자세히 알아보자.

Access Token과 Refresh Token

Refresh Token을 발급하기 전, Token이 어떻게 사용되는지 알아보자.

각 토큰의 이름이 뜻하는 대로 Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰이다.

 

JWT는 발급한 후, 삭제가 불가능하기 때문에 접근에 관여하는 토큰은 유효시간을 길게 부여할 수 없다.

하지만 자동 로그인 혹은 로그인 유지를 위해서는 유효시간이 긴 토큰이 필요하다.

이때 사용되는 것이 Refresh Token이다.

생명 주기

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

우선, Access Token 먼저 보자.

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

이런 역할을 하는 Access Token이 탈취되면 토큰이 만료되기 전 까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해진다.

따라서 Access Token의 생명 주기는 짧게 가져가야 한다.

 

그러면 자동 로그인 혹은 로그인 유지는?
이제 Refresh Token의 일이 시작된다.

Refresh Token은 한 번 발급되면 Access Token보다 훨씬 길게 발급된다.

 

여기서 주의점은 Access Token은 특정 API 인증 및 인가 역할만 해야 하며, Refresh Token은 Access Token 재발급 역할만 하여야 한다.

Access Token 재발급 방법

그럼 어떻게 재발급에 관여하는지 알아보자.

보통 Refresh Token은 로그인 성공시 발급되며 저장소에 저장하여 관리된다.

 

Access Token이 만료되어 재발급이 진행되면 아래 과정을 통해 재발급이 된다.

  1. Refresh Token을 Bearer 타입 토큰으로 헤더에 설정한 뒤 재발급 요청
  2. 토큰의 Subject로 Refresh Token인지 확인한다.
  3. Refresh Token이 저장소에 존재하는지 체크
  4. 유효한 토큰이면 유저 정보를 조회한 뒤 Access Token 재발급 하여 응답

위 과정을 수행하는 TokenRefreshFilter를 Spring Security Filter Chain에 추가한다.

코드 설명

https://github.com/solchan98/Playground/tree/main/spring-security-jwt

 

Spring Security와 JWT를 통한 인증 및 인가와 동일하게 Spring Security Config를 중점으로 다루며, 모든 객체를 다루지 않는다.

위 글과 설정정보가 동일하며 일부 추가된 부분만 다룬다.

 

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    ...

    private final RefreshTokenProvider refreshTokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

        ...
        
        setTokenRefreshFilter(httpSecurity);
        
        ...

        return httpSecurity.exceptionHandling(eh -> eh.accessDeniedHandler(new AccessTokenAccessDeniedHandler()))
                .build();
    }
    
    ...

    private void setTokenRefreshFilter(HttpSecurity httpSecurity) {
        SimpleAuthenticationProcessingFilter tokenRefreshAuthenticationFilter = new SimpleAuthenticationProcessingFilter(
                RequestMatchers.REFRESH_TOKEN,
                new BearerAuthenticationConverter()
        );
        tokenRefreshAuthenticationFilter.setAuthenticationManager(new ProviderManager(refreshTokenProvider));
        tokenRefreshAuthenticationFilter.setAuthenticationSuccessHandler(
                new TokenRefreshSuccessHandler(objectMapper, accessTokenProvider));
        tokenRefreshAuthenticationFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandlerImpl());

        httpSecurity.addFilterBefore(tokenRefreshAuthenticationFilter, SimpleAuthenticationProcessingFilter.class);
    }
    
    ...
}

 

이전 글의 로그인 인증 설정 부분과 동일하게 SimpleAuthenticationProcessFilter를 생성한다.

왜? 위에서 Access Token 재발급 과정을 보면 인증이 성공된 후, 토큰을 발급한 뒤 클라이언트에게 다시 응답을 한다.

 

즉, 다음 Filter를 진행할 필요가 없다.

AuthenticationSuccessHandler에서 새로운 Access Token을 발급한 뒤, 클라이언트에게 넘겨주면 끝이다.

따라서 AuthenticationFilter 객체가 아닌, SimpleAuthenticationProcessFilter를 생성하여 설정한다.

 

RefreshTokenProvider

Access Token과 다르게 인증 로직이 다르기 때문에 TokenProvider 추상 클래스를 상속 구현한 RefreshTokenProvider를 사용한다.

 

간단하게 RefreshTokenProvider의 인증 역할 수행 부분을 보면 다음과 같다.

 

BearerAuthenticationConverter가 요청 헤더에서 Refresh Token을 꺼내어 Authentication 인터페이스 구현체인 BearerAuthenticationToken를 반환하면서 인증 프로세스가 시작된다.

 

AuthenticationManage는 AuthenticationProvider List를 순회하면서 supports가 true인 AuthenticationProvider의 authenticate를 호출한다.

 

따라서 Authentication BearerAuthenticationToken 타입이라면 RefreshTokenProvider의 authenticate가 호출되면서 인증이 진행된다.

 

  1. 토큰이 유효한지 검증한다. (super.verify)
    1.  TokenProvider의 verify를 통해 Refresh Token이 유효한 JWT인지 검증한다.
  2.  Refresh Token인지 검증한다. (checkTokenType)
    1. JWT가 Refresh Token인지 확인한다.

위 두 검증이 성공한다면 유저 정보를 저장소에서 가져온 뒤 인증된 Authentication(AccessUser) 객체를 만들어 응답한다.

TokenRefreshSuccessHandle를 통해 Authentication(AccessUser)로 새로운 AccessToken 생성한 뒤 클라이언트에게 응답한다.

@Component(value = "refreshTokenProvider")
@RequiredArgsConstructor
public class RefreshTokenProvider extends TokenProvider {

    private static final String TOKEN_SUBJECT = "refresh token";

    private final RefreshTokenRepository refreshTokenRepository;

    private final UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Claims claims = super.verify(authentication.getName());
        checkTokenType(claims);

        String email = claims.get("email", String.class);
        AuthUserDetails authUserDetails = (AuthUserDetails) userDetailsService.loadUserByUsername(email);

        // Refresh Token의 email(payload)로 저장소 존재 여부 && 요청 Refresh Token과 match
        boolean empty = refreshTokenRepository.findByEmail(email)
                .filter(token -> Objects.equals(token, authentication.getName()))
                .stream()
                .findAny()
                .isEmpty();

        if (empty) {
            throw new BadCredentialsException("인증 정보를 확인하세요.");
        }

        return AccessUser.authenticated(authUserDetails);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return (BearerAuthenticationToken.class.isAssignableFrom(authentication));
    }

    public BearerAuthenticationToken createToken(String email) {
        long tokenLive = 1000L * 60L * 60L; // 1h
        BearerAuthenticationToken token = super.createToken(TOKEN_SUBJECT, Map.of("email", email), tokenLive);
        refreshTokenRepository.save(email, token.getName(), tokenLive);

        return token;
    }

    @Override
    public void checkTokenType(Claims claims) {
        if (!TOKEN_SUBJECT.equals(claims.getSubject())) {
            throw new BadCredentialsException("인증 정보를 확인하세요.");
        }
    }
}

정리

사실, 필요한 구현체 부분을 구현하여 위 Security Config처럼 적용을 하면 끝이다.

따라서 Spring Security를 사용한다면, Spring Security Filter Chaining이 어떻게 동작하는지 이해하는 것이 중요하다고 생각한다.

 

그 외, 부가적인 구현부를 다루어 보면 infrastructure 레이어 부분을 볼 수 있을 것 같다.

 

현재 refresh 패키지에 RefreshTokenRepository 인터페이스가 정의 되어있다.

그리고 infrastructure에 위 인터페이스를 구현한 InMemoryRefreshTokenRepository, RedisRefreshTokenRepository 2개의 구현체가 있다.

 

처음 진행 시, Redis 설정을 하지 않아 단순 메모리를 저장소로 활용하기 위해 InMemoryRefreshTokenRepository를 구현하여 사용하였다.

그리고 추후 Redis 적용 시, 단순 구현체만 변경하여 적용하기 위해 RefreshTokenRepository를 인터페이스로 분리하여 개발 진행하였다.

 

즉, 구현부에 대한 의존을 제거하여 간편하게 구현체를 변경할 수 있었다.

 

추가로 Redis를 로컬에 직접 설치하지 않기 위해 H2와 비슷하게 제공되는 embedded redis를 활용하였다.

따라서 실제 Redis 서버 구동 없이 RedisRefreshTokenRepository 테스트가 가능했다.

 

Spring Security도 동일한 방식이다.

사용자 입맛에 맞게 인터페이스 혹은 추상 클래스를 상속 구현하여 구현체 갈아끼우기만 하면 된다.