개발자로 후회없는 삶 살기

[보안] RefreshToken, Redis 도입으로 성능 개선 및 탈취 당한 AccessToken 문제 방지 본문

[대외활동]/[캡스톤 디자인]

[보안] RefreshToken, Redis 도입으로 성능 개선 및 탈취 당한 AccessToken 문제 방지

몽이장쥰 2024. 1. 23. 22:43

서론

RDB에 저장하던 RefreshToken의 Network I/O를 개선하기 위해 In-Memory Redis를 도입합니다. 또한, RefreshToken을 도입하여 AccessToken이 탈취 되더라도 인증하는 기능을 추가합니다. 아래 글에서 이어지는 내용입니다.

 

-> 깃허브

https://github.com/SangBeom-Hahn/Capstone_Develop/commit/670912b9588debce0f81d223f0a6bf85e5955928

 

feat: 2단계 인증 구현 · SangBeom-Hahn/Capstone_Develop@670912b

*feat: AuthService 기능 구현 *feat: LoginInterceptor 기능 구현 *feat: RefreshTokenSaveResponseDto 기능 구현

github.com

 

본론

- 탈취 문제 상황

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
                         final Object handler) {
    if (request.getMethod().equals("OPTIONS")) {
        return true;
    }
    final String token = AuthorizationExtractor.extract(request);
    jwtTokenProvider.validateAbleToken(token);
    return true;
}

기존 로그인 인터셉터는 accesstoken 검증만으로 인증을 하고 로그아웃을 하면 서버와 클라이언트 토큰을 전부 삭제하는 방식으로 동작하기에 accesstoken이 탈취 당하면 공식 SecretKey로 제작된 토큰이라서 인터셉터 검증에 안 걸리게 되고 로그아웃을 하더라도 서비스를 이용할 수 있습니다.

 

이를 레디스로 바꾼 시점에 재현해보면, flushall로 로그아웃을 하고, 서버-클라이언트에서 관리하는 토큰을 전부 삭제했지만,

 

탈취된 토큰으로 인가가 필요한 서비스를 이용할 수 있습니다. 이를 위해 RefreshToken 활용 구조를 개선해야겠다는 고민을 하게 되었습니다.

 

- 기존 RefreshToken 구조

1) RDB에 refreshtoken 저장
2) 로그인 사용자 여부 인증을 할 때마다 쿼리 발생
3) API 성능 저하

이러한 구조로 인해서 redis를 도입하기로 결정했습니다.

 

- redis 도커 설치 및 config 구현

redis는 도커로 설치했습니다.

 

# Redis
spring.redis.host=127.0.0.1
spring.redis.port=6379

스프링에서 redis를 사용하기 위해선 RDB와 동일하게 connection을 획득해야 합니다. IP와 port를 approperties에 정의합니다.

 

@Configuration
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

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

Java의 Redis Client에는 Jedis, Lettuce가 있습니다만, 스프링부트는 default로 lettuce를 이용합니다. 성능이나 자원 사용률 측면에서도 jedis보다 lettuce가 현재 더 좋은 상황이므로 LettuceConnectionFactory를 빈으로 등록하도록 했습니다.

 

[Jedis 보다 Lettuce]

 

Jedis 보다 Lettuce 를 쓰자

Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하

jojoldu.tistory.com

 

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

redisTemplate에 기본 Java 직렬화를 적용합니다. redisTemplate를 사용하여 커넥션을 획득하고 키-벨류를 레디스에 set-get하는 역할을 하게 됩니다. 키는 유일한 값임을 보장해야 하며, 여기까지 만들었으면 연결을 하기 위한 준비는 끝이 납니다.

 

- 연동 테스트

@RestController
public class RedisController {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @PostMapping("/redisTest")
    public ResponseEntity<?> add() {
        ValueOperations<String, String> vop = redisTemplate.opsForValue();
        vop.set("A", "B");
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    @GetMapping("/redisTest/{key}")
    public ResponseEntity<?> get(@PathVariable String key) {
        ValueOperations<String, String> vop = redisTemplate.opsForValue();
        String v = vop.get(key);
        return new ResponseEntity<>(v, HttpStatus.OK);
    }
}

Controller를 만들고 RedisTemplate을 주입받고 Redis에 값을 넣고 꺼내는 것을 테스트합니다. 키로 A, 벨류로 B를 넣었고 조회해봅니다.

 

실행해보면 스프링부트가 Reids와 커넥션을 획득하고 셋팅하며

 

스프링의 redis-cli에서 도커 레디스 서버로 데이터를 잘 주고 받는 것을 확인할 수 있습니다.

 

도커 레디스 서버를 내리면 스프링에서 다시 커넥션을 획득하려는 시도가 보입니다.

 

- refreshtoken 구조에 redis 도입

=> key-value 데이터 정책

RedisTemplate<String, RefreshTokenSaveResponseDto>
key : memberId
value : RefreshTokenSaveResponseDto

redis 키는 유니크함을 보장해야 하기에 member pk를 키로 하고 인증 시 필요한 데이터를 DTO로 별도 관리합니다. 비즈니스와 데이터 영역을 분리하는 것이 좋기에

 

public class RefreshTokenSaveResponseDto {

    private String tokenValue;

    private Long memberId;

    private LocalDateTime expiredTime;

엔터리를 레디스에 직접 저장하지 않고 DTO로 관리하도록 했습니다.

 

=> 로그인 수정

로그인을 할 때 레디스에 접근하도록 수정합니다.

 

-> 직렬화 문제 발생

JPA DTO를 redis에 직렬화하는 과정에서 LocalDateTime 클래스를 직렬화할 수 없다는 오류가 발생했습니다.

 

'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

Jackson이 Java 8의 날짜/시간 타입인 java.time.LocalDateTime를 기본적으로 지원하지 않기 때문에 발생하는 문제로 위 의존성을 추가하고 ObjectMapper를 빈 등록한 후 시간 모듈을 등록하면 해결된다고 합니다.

 

하지만, 이미  'org.springframework.boot:spring-boot-starter-web' 의존성 내부에 포함하고 있었기에 궁극적으로 해결되는 방법이 아니었습니다.

 

-> 문제 해결 🚨

@Bean
public RedisTemplate<String, RefreshTokenSaveResponseDto> redisTemplate() {
    Jackson2JsonRedisSerializer<RefreshTokenSaveResponseDto> jsonRedisSerializer =
            new Jackson2JsonRedisSerializer<>(RefreshTokenSaveResponseDto.class);
            
    // 이 부분        
    ObjectMapper objectMapper = new ObjectMapper();
    jsonRedisSerializer.setObjectMapper(objectMapper);
}

기존 코드에서는 ObjectMapper를 새로 생성해서 사용합니다.

 

@Bean
public RedisTemplate<String, RefreshTokenSaveResponseDto> redisTemplate(ObjectMapper objectMapper) {
    Jackson2JsonRedisSerializer<RefreshTokenSaveResponseDto> jsonRedisSerializer =
            new Jackson2JsonRedisSerializer<>(RefreshTokenSaveResponseDto.class);
//        ObjectMapper objectMapper = new ObjectMapper();
    jsonRedisSerializer.setObjectMapper(objectMapper);

    RedisTemplate<String, RefreshTokenSaveResponseDto> redisTemplate = new RedisTemplate<>();

    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(jsonRedisSerializer);
    return redisTemplate;
}

ObjectMapper는 SpringBoot가 자동으로 빈 등록을 해주고, 주입받아 사용하면 다양한 설정이 이미 적용된 상태로 제공되어 날짜와 시간 API에 대한 직렬화와 역직렬화를 지원합니다. 스프링은 사용자 선언을 1순위로 동작하여 obj 맵퍼가 자동 빈 등록되더라도 사용자가 새로 정의한 맵퍼로 동작하니 이전엔 아무 설정이 안 된채로 OM으로 동작하여 발생한 문제였습니다.

 

-> 결과 확인 ✅

# redis-cli
127.0.0.1:6379> get 3
"{
    \"tokenValue\":\"d29ac46e-2c01-4daa-9faf-815644b007e1\",
    \"memberId\":3,
    \"expiredTime\":\"2024-02-02T19:19:13.2843173\"
}"

로그인을 시도하면 memberId와 DTO 데이터가 키-벨류로 저장됩니다.

 

-> 기존 인터셉터

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
                         final Object handler) {
   
    final String token = AuthorizationExtractor.extract(request);
    jwtTokenProvider.validateAbleToken(token);
    return true;
}

기존 인터셉터는 액세스 토큰이 유효한지만, 검증했다면

 

-> 2차 검증 인터셉터

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
                         final Object handler) {
    final String token = AuthorizationExtractor.extract(request);
    String key = jwtTokenProvider.getPayload(token);

    if (hasRefreshToken(key)) {
        jwtTokenProvider.validateAbleToken(token);
    } else {
        throw new InvalidAuthMemberException(key);
    }

    return true;
}

새로운 인터셉터는 레디스에서 refreshtoken이 있는지 확인하고, 없다면 로그인하지 않은 사용자 예외를 발생시키고, 있다면 2차로 AccessToken 검증을 하게 됩니다. AccessToken으로만 하던 1단계 검증을 RefreshToken을 도입하여 2단계로 강화하였습니다.

 

=> 로그아웃

이제 로그아웃 기능을 구현하고 테스트해보겠습니다.

 

-> 기존

public void logout(String refreshToken) {
    refreshTokenRepository.deleteByTokenValue(refreshToken);
}

RDB에서 조회하는 기존 로그아웃과 달리

 

-> 새로운 로그아웃

public void logout(String refreshToken) {
    redisTemplate.delete(refreshToken);
}

삭제 시 쿼리로 인한 NetWork I/O가 발생하지 않습니다.

 

- 테스트

동일하게 토큰을 탈취당한 상태를 테스트해보겠습니다.

flushall로 로그아웃을 하고

 

탈취한 AccessToken으로 접근하면 예외가 발생하여 서비스 이용이 불가합니다.

 

🚨 무작위로 로그아웃 할 수 있지 않을까?

private void saveRefreshToken(Long studentId, RefreshToken refreshToken) {
    redisTemplate.opsForValue()
            .set(
                    String.valueOf(studentId),
                    RefreshTokenSaveResponseDto.from(refreshToken)
            );
}

현재 key를 멤버 pk로 하고 있습니다. 하지만, logout은 인터셉터에 걸리지 않으니 /api/{아무 id}를 무작위로 반복하여 보내면 로그아웃이 될 수도 있는 현상이 예상됩니다.

 

private void saveRefreshToken(RefreshToken refreshToken) {
    redisTemplate.opsForValue()
            .set(
                    refreshToken.getTokenValue(),
                    RefreshTokenSaveResponseDto.from(refreshToken)
            );
}

 

키 : refreshtoken
벨류 : refreshtoken을 제외한 RefreshTokenSaveResponseDto

하지만, 위처럼 예상할 수 없는 refreshtoken을 키로 하면 인터셉터에서 refreshtoken을 찾을 수 있는 방법이 조금 애매합니다.

 

1. 레디스 완전 탐색으로 value로 key인 refreshtoken 찾기
2. id와 refreshtoken을 RDB에 저장하기

위 방법 모두 시간과 비용면에서 레디스를 사용한 장점을 상쇄시킵니다.

 

1. 2단계 조회를 하지 않고 로그아웃 용으로만 레디스를 사용
2. AccToken을 Redis에 저장하여 2단계 인증과 인가를 함
3. AccToken, Refresh를 모두 Redis에 저장하여 Acc로 2단계 인증하고 Redis로 renewal

따라서 이 문제를 해결하기 위해 위와 같은 고민을 해보았고, 트레이드 오프라는 것을 깨달았습니다.

 

결론

이번 변경 사항은 원래 요구사항 명세서에 있던 내용이 아닙니다. 개발을 하던 도중 "이런 문제도 있지 않을까?"하여 구현하게 된 것입니다. 저의 보안 의식과 서비스 개선 마인드를 발전시킬 수 있는 계기가 되었습니다.

Comments