OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - Refresh Token 재발급

DevSeoRex·2023년 5월 28일
8
post-thumbnail

🥳 Refresh Token 어디에 관리하면 좋을까?

AccessToken 인증 방식의 가장 큰 문제점은 토큰을 탈취당하면 악의적인 사용자로부터 보호받을 방법이 없다는 것입니다. 이미 발급된 토큰은 만료가 불가능하고, 만료될때까지 마음껏 악용될 수 있습니다.

그렇다면 그냥 JWT를 쓰지 않고 세션을 사용하면 되지 않을까 싶기도 합니다.
하지만 JWT를 사용하면 Restful API의 특징인 Stateless를 가져갈 수 있고, AccessToken 만으로 사용자를 식별하고 인증과 인가를 해줄 수 있기 때문에 MSA와 같은 다중 서버 구조에도 적용하기 좋습니다.

일단 Refresh Token은 클라이언트에서 관리하지 않아야합니다.
Refresh Token을 클라이언트에서 관리하게 되면, 토큰을 탈취 당할 경우 꽤 긴 시간동안 탈취한 토큰으로 AccessToken을 재발급 받아 큰 피해를 입을 수 있습니다.

AccessToken이 탈취 당할경우 토큰을 만료시킬 방법이 없으므로, 우리 팀의 전략은 AccessToken의 만료시간을 30분으로 짧게 설정하는 방법을 택했습니다.

Refresh Token 어디에 관리하면 좋을지 후보군을 두개정도 뽑아서 비교해보겠습니다.

후보 1 MySQL(RDB)

MySQL은 현재 팀에서 사용하고 있는 데이터베이스입니다. 회원 테이블에 refresh_token 이라는 컬럼을 추가하고, 로그인시 refresh_token을 insert 해준다면 좋은 방법일까요?

결론부터 말씀 드리자면, 팀에서는 별로 좋지 못한 방법이라는 의견이 다수였고, 저 또한 그랬습니다. 왜냐하면 AccessToken의 만료시간을 30분으로 짧게 두었기 때문에, 토큰 만료로 인해 401번 에러가 발생할 경우, 프론트에서는 AccessToken을 서버로 보내 재발급 요청을 하게 되고 서버는 AccessToken으로 RefreshToken을 조회해서 유효하다면 새 AccessToken을 보내줍니다.

프론트에서 토큰을 헤더에 담아 인증 요청을 보냈는데, 만약 인증이 실패한다면 몇번의 쿼리가 나가는지 생각해보겠습니다.

AccessToken에서 추출한 이메일로 회원을 조회하고 refreshToken이 있는지 확인하는 쿼리 한 번이면 해결이 됩니다.그렇다면 쿼리가 한번 나간다고 좋은 선택일까요?

RefreshToken 역시 유효기간이 있습니다.
저희 프로젝트에서는 14일의 유효기간을 두고 만료시 삭제하는 방식을 채택했습니다. 그렇다면 RDB를 사용할 경우,
스케줄러와 같은 프로그램을 만들어서 RefreshToken을 만료시키는 방법을 택해야합니다.

한 번의 쿼리가 나가더라도 이 쿼리는 한 유저당 30분에 한번이 나갑니다.
만약 우리 서비스에 50명이 2시간 동안 이 서비스에 머무른다면 어떻게 될까요?
무려 200번의 쿼리가 나가게됩니다. 현재 MySQL은 분양글, 게시판, 공지사항 회원가입 등 수 많은 비즈니스 로직에서 읽고 쓰는 작업을 하고 있습니다.

DB에도 많은 부하를 주는 작업인것에는 틀림 없습니다.
그래서 우리 팀은 사용하는 DB에 컬럼을 추가해서 RefreshToken을 저장하는 방식을 사용하지 않았습니다.

후보 2 Redis(No - SQL)


Redis는 고 성능의 Key-Value 데이터 구조 스토어입니다.
ANSI C 언어로 작성되어, Java와 같이 가상머신 위에서 동작하는 언어에 비해 성능 문제에서 자유롭다는 장점이 있습니다.

그렇다면 Redis를 적용할경우 어떤 이점이 있는지 살펴보겠습니다.
MySQL을 사용하게 되면, 스케줄러와 같은 프로그램을 만들어서 직접 데이터를 지워주는 작업을 해야됩니다.

하지만 Redis는 TTL(Time-To-Live)라는 기능이 있어서, 데이터를 저장하고 설정한 TTL에 따라서 자동으로 삭제되는 강력한 이점을 가지고 있습니다.

Redis를 사용하면 스케줄러를 개발해야하는 수고는 덜게 되었습니다.

그렇다면 Redis를 사용하면 Redis에 몇번 접근하게 될까요?
제가 작성한 로직에서는 Redis를 사용할 경우 AccessToken으로 RefreshToken을 조회할때 한번, 새로 발급한 AccessToken의 값을 update 해주기위해 한 번 접근하게 됩니다.

데이터베이스에 접근하는 횟수는 한 번 늘었습니다.
그럼에도 불구하고 왜 Redis를 선택했을까요?

Redis는 매우 빠릅니다. Redis는 캐싱용으로도 많이 사용되는 만큼 읽기 속도가 매우 빠릅니다. MySQL을 사용한다면 다른 비즈니스 로직 처리를 위해 접근을 많이 하게 되는데, RefreshToken 관리를 위해 사용하게 되면 주기적으로 요청이 가게되어 부하가 염려되었습니다.

MySQL을 사용하게되면, 스케줄러 프로그램을 작성해야 하는데 크론 표현식으로 특정 시간대에 RefreshToken을 삭제하는 로직을 수행하게 됩니다. 그렇다면 삭제 시점에서 1분뒤 또는 30초뒤 삭제되는 토큰이라면 원래 만료시간보다 더 사용할 수 있게 되는 것입니다.

그렇다고 스케줄러를 1분이나 30분마다 서버에서 실행시키는 것은 부하가 굉장할 것이라고 생각이 들었습니다.

그래서 Redis를 이용해 Refresh Token 관리를 진행하기로 결정하였습니다.

😎 Redis 설치와 설정

Redis 설치는 EC2 환경에서 docker-compose를 이용하여 설치해주었습니다.

version: '3.7'
services:
  redis-refresh-token:
    image: redis:alpine
    command: redis-server /usr/local/etc/redis-refresh-token/redis.conf --requirepass {사용할 비밀번호}
    ports:
      - "6379:6379"
    volumes:
      - ./data/refresh-token-data:/data
      - ./data/refresh-token-data/redis.conf:/usr/local/etc/redis-refresh-token/redis.conf

Redis는 비밀번호를 꼭 설정해서 작업하는 것이 좋습니다. 비밀번호를 열어 두었다가 곤혹스러운 일을 겪은 적이 있어서 그 부분은 마지막에 말씀 드리겠습니다.

위에 작성된 파일을 EC2로 옮겨주고 아래 명령어를 입력합니다.

$ sudo docker-compose up -d

Redis 이미지를 pull 받아오면서 Redis가 실행됩니다.
Redis에 접속해서 cli 환경으로 간단한 작업을 하려고 하는 경우에는 아래와 같은 명령어를 입력하여 접속합니다.

// 현재 실행중인 컨테이너 확인
$ sudo docker ps
// Redis cli 접속
$ sudo docker exec -it <redis의 container 번호> redis-cli -a <redis 비밀번호>

이렇게 하면 EC2쪽 설정은 끝났습니다. 마지막으로 Redis 접근을 위해서 EC2 보안 그룹의 인바운드 규칙 편집에 들어가서 6379 포트를 열어주시면 됩니다.

이제 Spring 설정을 해보겠습니다.

  • Redis 의존성을 추가합니다.

  • application.yml 파일에 redis 설정을 추가합니다.

  • RedisConfig 작성

@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(redisProperties.getHost());
        redisStandaloneConfiguration.setPort(redisProperties.getPort());
        redisStandaloneConfiguration.setPassword(redisProperties.getPassword());
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {

        // redisTemplate 를 받아와서 set, get, delete 를 사용
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        /*
         * setKeySerializer, setValueSerializer 설정
         * redis-cli 을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
         */
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }
}
  • RedisProperties 작성
@Data
@Component
@ConfigurationProperties(prefix = "spring.redis")
public class RedisProperties {

    private String host;
    private int port;
    private String password;
}

이제 기본적인 설정은 끝났습니다.
이제 기능을 개발하기 위한 클래스들을 작성해보겠습니다.

🤩 RefreshToken을 관리하는 로직 작성

먼저 Redis에서 사용할 객체인 RefreshToken을 작성해보겠습니다

@Getter
@AllArgsConstructor
@RedisHash(value = "jwtToken", timeToLive = 60 * 60 * 24 * 14)
public class RefreshToken implements Serializable {

    @Id
    private String id;

    @Indexed
    private String accessToken;

    private String refreshToken;

    public void updateAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }

}

중요한 부분만 뜯어서 살펴보겠습니다.

RefreshToken 객체의 직렬화와 역직렬화가 지원되도록 마커 인터페이스인 Serializable 인터페이스를 구현했습니다.
@RedisHash는 Hash Collection을 명시하는 애너테이션입니다. RedisHash의 key는 value인 jwtToken과 @Id가 붙은 id 필드의 값을 합성하여 사용합니다.

timeToLive는 초단위로 언제까지 데이터가 존재할건지 정할 수 있습니다. 저는 14일로 유효기간을 정했습니다.


@Indexed 애너테이션을 사용하면 JPA를 사용하듯이 findByAccessToken과 같은 질의가 가능해집니다.
RefreshToken을 찾을때 AccessToken 기반으로 찾을 것이므로, @Indexed 애너테이션을 붙여주었습니다.

다음은 RefreshTokenRepository를 작성해보겠습니다.

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

    // accessToken으로 RefreshToken을 찾아온다.
    Optional<RefreshToken> findByAccessToken(String accessToken);
}

CrudRepository를 상속받고, findByAccessToken을 메서드를 만들어주었습니다.

이제 RefreshTokenService를 작성하겠습니다.

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository repository;

    @Transactional
    public void saveTokenInfo(String email, String refreshToken, String accessToken) {
        repository.save(new RefreshToken(email, accessToken, refreshToken));
    }

    @Transactional
    public void removeRefreshToken(String accessToken) {
        RefreshToken token = repository.findByAccessToken(accessToken)
                .orElseThrow(IllegalArgumentException::new);

        repository.delete(token);
    }
}

아주 간단한 로직으로 이루어져 있기 때문에 따로 설명은 드리지 않겠습니다.
새로운 토큰을 저장하고, 토큰을 삭제하는 두가지 기능을 가지고 있습니다.

토큰을 재발급하고, 로그아웃시 refreshToken을 삭제하는 AuthController를 작성해보겠습니다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final RefreshTokenRepository tokenRepository;
    private final RefreshTokenService tokenService;
    private final JwtUtil jwtUtil;
    private final EmitterRepository emitterRepository;

    @PostMapping("token/logout")
    public ResponseEntity<StatusResponseDto> logout(@RequestHeader("Authorization") final String accessToken) {

        // 엑세스 토큰으로 현재 Redis 정보 삭제
        tokenService.removeRefreshToken(accessToken);
        return ResponseEntity.ok(StatusResponseDto.addStatus(200));
    }

    @PostMapping("/token/refresh")
    public ResponseEntity<TokenResponseStatus> refresh(@RequestHeader("Authorization") final String accessToken) {

        // 액세스 토큰으로 Refresh 토큰 객체를 조회
        Optional<RefreshToken> refreshToken = tokenRepository.findByAccessToken(accessToken);

        // RefreshToken이 존재하고 유효하다면 실행
        if (refreshToken.isPresent() && jwtUtil.verifyToken(refreshToken.get().getRefreshToken())) {
            // RefreshToken 객체를 꺼내온다.
            RefreshToken resultToken = refreshToken.get();
            // 권한과 아이디를 추출해 새로운 액세스토큰을 만든다.
            String newAccessToken = jwtUtil.generateAccessToken(resultToken.getId(), jwtUtil.getRole(resultToken.getRefreshToken()));
            // 액세스 토큰의 값을 수정해준다.
            resultToken.updateAccessToken(newAccessToken);
            tokenRepository.save(resultToken);
            // 새로운 액세스 토큰을 반환해준다.
            return ResponseEntity.ok(TokenResponseStatus.addStatus(200, newAccessToken));
        }

        return ResponseEntity.badRequest().body(TokenResponseStatus.addStatus(400, null));
    }

}

로그아웃 메서드는 accessToken으로 RefreshToken을 지우는 단순한 로직을 가지고 있습니다.
토큰을 재발급받는 로직을 뜯어서 살펴보겠습니다.

  • AccessToken으로 refreshToken을 조회합니다.

  • RefreshToken이 현재 존재하고, RefreshToken이 만료되지 않았다면 재발급을 수행합니다.

  • AccessToken으로 RefreshToken을 찾을 수 없거나 RefreshToken이 만료 되었을경우 400번 에러를 내려줍니다.

이제 모든 로직이 작성되었습니다. 마지막으로 JwtAuthFilter를 일부 수정하겠습니다.

shouldNotFilter 메서드를 추가하였습니다. 이 메서드를 이용하면 필터를 거치지 않을 URL 패턴일경우, 필터를 건너 뛰게 됩니다.

token/ 을 포함한 요청 URI를 가질경우 필터를 수행하지 않도록 작성했습니다.
기존에 AccessToken의 값이 없는 경우 토큰 검사를 생략하는 조건문을 두었었는데, RefreshToken 재발급이나 로그아웃 기능의 경우, 필터를 수행하면 토큰의 값은 있지만 토큰이 유효하지 않을 수 있기 때문에 이렇게 로직을 변경하였습니다.

🤔 대망의 테스트!

RefreshToken 재발급 테스트를 위해서 JwtUtil 클래스의 AccessToken 만료 기한을 2초로 두겠습니다.

이제 서버를 키고 로그인을 시도해서 토큰을 가져오겠습니다.

토큰은 발급 받았는데 2초가 지나고 나니 401번 에러가 나오고 있습니다.
그렇다면 다시 토큰을 재발급 해보겠습니다.


토큰이 정상 재발급 되었습니다.
그렇다면 이 토큰을 Redis에 저장이 되어있는지 확인해보겠습니다.

7번 행에 저장이 되어 있는 것을 확인할 수 있습니다.

그렇다면 logout API를 통해 삭제도 제대로 되는지 확인해보겠습니다.

토큰이 정상적으로 삭제 된 것을 볼 수 있습니다.
삭제한 토큰으로 다시 재발급 요청을 보내보겠습니다.

예상한대로 동작하는 것을 볼 수 있습니다.

👿 TMI 약간의 트러블 슈팅

현재 회원기능에 대한 작업이 전부 끝나고 프론트앤드 개발자 분들이 통합중인데 오늘 갑자기 RefreshToken으로 재발급이 되지 않는다는 말씀을 주셔서 확인을해봤습니다.

몇번을 재발급을 해봐도 제대로 동작하는데 자꾸 401 에러가 서버에서 난다는 것이였습니다.
결론부터 말씀드리면 저랑 완전 똑같은 증상을 겪으신 블로거분이 계셔서 글을 읽어보니 Redis에 비밀번호를 설정하지 않아서 해킹을 당했다는 것이였습니다.

이 게시글을 보고 바로 비밀번호를 설정하고 나니, 문제가 해결되었습니다.
도움주신 블로거분께 감사드리며, 링크를 달아놓겠습니다. 같은 문제가 있으신분은 읽어보시면 좋겠습니다.

https://thxwelchs.github.io/%EB%82%B4redis%EA%B0%80%ED%95%B4%ED%82%B9%EB%8B%B9%ED%96%88%EB%8B%A4%EA%B3%A0%EC%8B%AC%EC%A7%80%EC%96%B4local%EC%9D%B8%EB%8D%B0/

🤠 다음으로..!!

드디어 5번의 게시글을 거쳐서 여기까지 오신다고 모두 고생 많으셨습니다!

OAuth 2.0과 Spring Security JWT까지 한번도 사용해보지 않던 기술들이라서 걱정도 되고 작업하면서 놓친 부분이 많아서 시간도 오래걸렸지만 그만큼 완성해보니 재밌고 좋은 시간들이였습니다.

지금까지 이 시리즈를 읽어주셔서 감사합니다!

🙇

참고한 레퍼런스

https://azderica.github.io/01-db-nosql-redis/

https://thxwelchs.github.io/%EB%82%B4redis%EA%B0%80%ED%95%B4%ED%82%B9%EB%8B%B9%ED%96%88%EB%8B%A4%EA%B3%A0%EC%8B%AC%EC%A7%80%EC%96%B4local%EC%9D%B8%EB%8D%B0/

12개의 댓글

comment-user-thumbnail
2024년 2월 11일

와.! 로그인 기능을 리팩토링 하고 싶어서 이리 저리 찾아보고 있었는데, 너무 정리가 잘 되어 있어서 감탄했습니다. 감사합니다.~

1개의 답글
comment-user-thumbnail
2024년 3월 22일

안녕하세요! 좋은 글 작성해주셔서 감사합니다!! 작성하신 글 보고 열심히 공부하고 있습니다. 혹시 프로젝트 기간 얼마나 소요됐는지 여쭤봐도 될까요?

1개의 답글
comment-user-thumbnail
2024년 4월 24일

안녕하세요! 로그인에 대해 첫 게시글부터 차근차근 읽어가며 정독하고 있었습니다. 정리 너무 잘해주셔서 감사합니다 !! 혹시 프론트와 테스트하기 전에는 어떻게 테스트를 하면 좋을까요? 현재 JwtAuthFilter에서 accessToken을 Header에서 가져온다고 했는데, 이런 테스트는 어느 api든 상관없이 진행하면 되는건가요 ?

1개의 답글
comment-user-thumbnail
2024년 9월 26일

소셜로그인을 적용해야되는데 도움 많이 받았습니다!
궁금한 점이 있는데 토큰 재발급 요청을 보낼 때, JwtAuthFilter에서 "AccessToken을 검증하고, 만료되었을경우 예외를 발생시킨다" 부분 때문에 예외가 터지면서 혹시 테스트 하실 때는 이 부분 예외 처리를 어떻게 하신건가요?
지우면 정상적으로 토큰이 재발급됩니다.

1개의 답글
comment-user-thumbnail
2024년 10월 8일

로그인 구현을 위해 정독중인데 자세하고 잘 써주셔서 감사합니다!!!
혹시 약1년전 글인데 아쉽거나 리팩토링 할 부분 있으신가요?

1개의 답글