이전 [Docker] 도커를 사용해서 로컬에 mysql, redis 구성 게시글에서 도커에 Redis 컨테이너를 띄우는 것을 알아봤다.

 

그래서 이 Redis에 accessToken과 refreshToken을 저장하여 accessToken 만료, refreshToken 유효한 상태인 경우 Redis에서 refreshToken을 조회해서 자동 갱신해 주는 것을 해보려고 한다.

 

Redis 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

build.gradle 파일에 Redis 의존성을 추가한다.

 

RedisConfig 작성

# application.yml

redis:
  url: localhost
  port: 6379

application.yml에 Redis 서버 url과 port번호를 설정한다. 로컬 도커에 실행시켰기 때문에 서버 url은 localhost로 설정했다.

 

@Configuration
public class RedisConfig {

    @Value("${redis.url}")
    private String url;
    @Value("${redis.port}")
    private String port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(url);
        redisStandaloneConfiguration.setPort(Integer.parseInt(port));

        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
        return lettuceConnectionFactory;
    }
    
}

RedisConnectionFactory를 Bean으로 등록한다.

 

TokenInfo RedisHash추가

@RedisHash(value = "tokenInfo", timeToLive = 60*60*3) // TTL : 3시간
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenInfo {
    @Id
    private String accessToken;
    private String refreshToken;
}

spring-data-jpa의 @Entity처럼 @RedisHash 어노테이션을 사용해서 Redis의 Hash를 만들 수 있다. TTL을 refreshToken의 만료시간과 동일한 3시간으로 설정해 만료되는 시점에 Redis의 key-value 데이터도 같이 사라진다.

 

accessToken을 Id로 갖고 accessToken을 통해 refreshToken을 조회해오기 위해 위와 같은 TokenInfo 클래스를 만들었다.

 

TokenRepository

public interface TokenRepository extends CrudRepository<TokenInfo, String> {
}

repository 또한 jpa와 동일하게 인터페이스를 정의하면 findById, findAll, ... 과 같은 메소드를 통해 Redis에 데이터를 조회하고 저장할 수 있다.

 

TokenService

@Service
@RequiredArgsConstructor
public class TokenInfoService {

    private final TokenRepository tokenRepository;

    public TokenDto getTokenInfoById(String accessToken) {
        TokenInfo tokenInfo = tokenRepository.findById(accessToken)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.OK, ErrorCode.EMPTY_DATA.name()));

        return TokenDto.builder()
                .accessToken(tokenInfo.getAccessToken())
                .refreshToken(tokenInfo.getRefreshToken())
                .build();
    }


    public void saveTokenInfo(TokenInfo tokenInfo) {
        tokenRepository.save(tokenInfo);
    }

    public void deleteTokenInfoById(String accessToken) {
        tokenRepository.deleteById(accessToken);
    }

}

TokenService에서 등록, 조회, 삭제 기능만 추가했다. 이제 해당 메소드들을 필요한 상황에서 호출하기만 하면 내 App에서 Redis를 사용할 수 있는 것이다.

 

로그인 성공

public TokenDto login(LoginDto loginDto, HttpServletResponse response) {
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUserId(), loginDto.getPassword());
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);

    TokenDto tokenDto = tokenProvider.generateToken(authentication);

    CookieUtil.generateCookie(response, "accessToken", tokenDto.getAccessToken());

    // Redis에 토큰 정보 저장
    tokenInfoService.saveTokenInfo(
            TokenInfo.builder()
                    .accessToken(tokenDto.getAccessToken())
                    .refreshToken(tokenDto.getRefreshToken())
                    .build()
    );

    return tokenDto;
}

로그인 메소드에 TokenProvider에서 생성한 accessToken과 refreshToken을 TokenInfo 객체로 만들어 Redis에 저장하는 로직을 추가했다.

 

로그인 성공 시 토큰이 잘 저장되고 TTL도 적용되어 있는 것을 확인할 수 있다.

 

토큰 자동 갱신

이제 포스트 제목대로 Jwt 필터로 들어온 요청이 만료된 액세스토큰을 가지고 온 경우 Redis의 refreshToken을 조회해 와서 재검증 후 만약 refreshToken이 유효하다면 토큰을 갱신해 줄 것이다.

 

JWT 필터의 doFilterInternal()메소드를 보자.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

    // 쿠키 or 헤더에서 토큰 추출
    String accessToken = CookieUtil.getAccessToken(request);

    if (StringUtils.hasText(accessToken)) {
        try {
            // 액세스 토큰이 유효한 경우
            if (tokenProvider.validateToken(accessToken)) {
                Authentication authentication = tokenProvider.getAuthentication(accessToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (ExpiredJwtException accessTokenEx) {
            // 액세스 토큰이 만료된 경우 ExpiredJwtException 발생하여 catch문에서 재시도
            try {
                // Redis에서 refreshToken 조회
                String refreshToken = tokenInfoService.getTokenInfoById(accessToken).getRefreshToken();

                // refreshToken이 유효한 경우
                if (tokenProvider.validateToken(refreshToken)) {
                    // Redis에 저장된 기존 토큰정보 삭제
                    tokenInfoService.deleteTokenInfoById(accessToken);

                    // 토큰 재발급
                    TokenDto tokenDto = tokenProvider.reissueToken(refreshToken);

                    // Redis에 재발급 된 토큰 정보 저장
                    tokenInfoService.saveTokenInfo(
                            TokenInfo.builder()
                                    .accessToken(tokenDto.getAccessToken())
                                    .refreshToken(tokenDto.getRefreshToken())
                                    .build());

                    Authentication authentication = tokenProvider.getAuthentication(tokenDto.getAccessToken());
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    CookieUtil.generateCookie((HttpServletResponse) response, "accessToken", tokenDto.getAccessToken());
                }
            } catch (ExpiredJwtException | ResponseStatusException ex) {
                log.info("Expired refreshToken");
            }
        }
    }

    filterChain.doFilter(request, response);
}

코드에 주석달아둔 것처럼 accessToken이 만료되면 Redis의 refreshToken을 조회해서 재검증, refreshToken도 만료되었다면 로그인 API를 다시 호출해야 한다.