개발자로 후회없는 삶 살기
[보안] 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
본론
- 탈취 문제 상황
@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를 빈으로 등록하도록 했습니다.
@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
따라서 이 문제를 해결하기 위해 위와 같은 고민을 해보았고, 트레이드 오프라는 것을 깨달았습니다.
결론
이번 변경 사항은 원래 요구사항 명세서에 있던 내용이 아닙니다. 개발을 하던 도중 "이런 문제도 있지 않을까?"하여 구현하게 된 것입니다. 저의 보안 의식과 서비스 개선 마인드를 발전시킬 수 있는 계기가 되었습니다.
'[대외활동] > [캡스톤 디자인]' 카테고리의 다른 글
[최적화] 졸업자 전체 조회 JPA 쿼리 튜닝 (0) | 2024.03.17 |
---|---|
캡스톤 디자인 PART.JWT 관리자 정책 변경 (0) | 2023.08.18 |
[보안] Jwt를 이용한 클라이언트 권한 인가 구현 (0) | 2023.08.02 |
캡스톤 디자인 PART.스프링 & 리액트 프로젝트 연동 (0) | 2023.07.22 |
캡스톤 디자인 PART.DB 모델링 변경사항 (0) | 2023.07.11 |