Backend/spring security

spring security : refresh 토큰 구현(redis 활용)

attlet 2023. 10. 24. 19:50

jwt 토큰을 통해 로그인을 구현하면 세션에 비해 서버에 부담이 적고, 확장하기도 편리하다는 장점이 있다. 하지만 온전히 사용자가 보내는 http 메시지에 담긴 토큰을 기반으로 서버가 유저를 판단하기 때문에, 이 토큰이 탈취당하면 탈취자를 사용자로 오인하게 되어 매우 위험하다.

 

 

프로젝트에서 이를 극복하기 위해 refresh 토큰을 도입했다.

 

 

Refresh Token 이란?


이전에 작성한 jwt토큰을 이용한 로그인 방식은 엄밀히 보면 access를 위한 토큰이라 볼 수 있다. 그래서 사람들은 보통 이 토큰을 access token이라 지정하는 경우도 많다.

 

문제는 이 토큰이 탈취당하면 리스크가 크다는 점인데, 이는  access 토큰의 유효시간을 짧게 설정해서 보완할 수 있다.

 

 

유효시간이 짧다면 공격하는 사람이 탈취해도 금방 토큰이 만료되어 유효성 통과를 금방 하지 못하게 될 것이다. 하지만 문제는 이렇게 되면 사용자는 계속 로그인을 반복해야하는 불편함이 생긴다.

 

 

그래서 이 불편함을 해결하기 위해 refresh 토큰을 같이 생성한다. refresh token을 사용하는 과정은 다음과 같다.

 

 

 

출처 : https://nowgnas.github.io/posts/refreshtoken/

 

 

1. user가 로그인 api를 통해 서버에 로그인하도록 요청한다.

 

2. 만약 DB에 사용자가 있는 지 확인되면, access 토큰과 refresh 토큰을 발급해준다.

 

3. 이제 user는 서버에 api를 호출할 때 마다 access토큰을 헤더에 담아 메시지를 전송한다. 

 

4. 서버에서는 이 access토큰을 파싱해서 실제 유저인지, 권한을 갖는 지 인증, 인가 작업을 거친다.

 

5. 만약 토큰이 유효하면, api를 실행한다.

 

6.만약 유효기간이 지나 유효하지 않다면, 만료되었다는 것을 서버가 클라이언트에 알린다.

 

7. 만료되었다는 것을 알게 되면, 클라이언트에서 서버로 refresh 토큰을 전송해 다시 access토큰을 재발급 하도록 요청한다. 

 

8. 서버가 재발급 요청을 받으면, refresh 토큰을 검증해 유효한지 확인한다. 만약 refresh 토큰도 유효하지 않으면, 사용자는 결국 재 로그인 해야한다.

 

9. 만약 refresh 토큰이 유효하면, access토큰을 새로 생성해서 클라이언트에게 전송해준다. 사용자는 재발급된 토큰으로 다시 api호출이 가능해진다.

 

 

 

대충 요로한 과정을 거쳐 refresh 토큰을 생성하고, 사용한다. 이제 이전까지 작성한 프로젝트에 refresh토큰을 적용한 과정을 알아보자.

 

 

 

 

Refresh token 저장 위치


access 토큰 같은 경우 클라이언트에게 보내고 나면 클라이언트 로컬 스토리지 같은 곳에 저장되어 사용한다. 허나 refresh 토큰은 access 토큰의 탈취를 염려해 사용하는 것이기에, 서버에 저장되는 경우가 많다. 

 

또한 refresh 토큰도 access 토큰보다는 길지만 유효기간이 존재한다. 결국 언젠가 사라져야 하며, 사라진다는 것은 서버 개발자가 직접 지워야한다는 이야기처럼 들린다.

 

 

유효기간이 지난 refresh 토큰을 서버에서 자동으로 삭제할 방법을 찾아보니, redis를 알 수 있었다. redis는 key - value 형태로 데이터를 저장하는 noSQL 인 메모리 데이터베이스로, 데이터에 유효기간을 부여할 수 있다는 특징이 있다.

 

 

 

 

 

 

코드 구현


 

 

이렇게 db도 정했으니 본격적으로 구현해보자. 

 

 

먼저 refresh 토큰을 저장하는 redis를 설정하도록 한다. 

 

 

 

 

application.yml

redis:
    host: localhost
    port: 6379

 

application.yml파일에 위 내용을 추가한다. redis 데이터베이스는 기본적으로 6379포트를 사용한다.

 

 

build.gradle

//database
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

gradle 파일에 redis를 추가한다.

 

 

 

 

 

그 다음 redis를 위한 configuration파일을 작성했다.

 

 

 

RedisConfig.java

@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        return new LettuceConnectionFactory(host, port);   //Lettuce vs Jedis 중 lettuce를 선택. 더 좋음.
    }


    //redistemplate를 이용해 db 서버에 set, get, delete 등 사용할 수 잇음.
    @Bean
    public RedisTemplate<?,?> redisTemplate(){        //redistemplate는 트랜잭션을 지원한다. 트랜잭션 안에서 오류가 발생하면 그 작업을 모두 취소한다.
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }



}

 

@Value를 통해 application.yml파일의 내용을 변수로 가져온다. 

 

redisConnectionFactory를 빈으로 등록할 때, 반환할 객체를 Lettuce로 정했다.  Lettuce는 RedisConnectionFactory를 구현한 구현체이기에 저렇게 작성하는 것이 가능하다. 

 

Jedis라는 것도 있었는데, 동기 방식이기에 블로킹되거나 하는 이슈가 있고, 성능도 떨어진다는 이야기가 있었다. 그래서 동기 비동기 다 지원하고 non-blocking을 지원하는 Lettuce를 선택했다.

 

 

자세한 내용은 다음 글에 가보면 볼 수 있다.

 

 

https://redis.com/blog/jedis-vs-lettuce-an-exploration/

 

Jedis vs. Lettuce: An Exploration | Redis

Jedis and Lettuce are popular Redis clients for Java developers. Check out this deep dive into the main differences between Jedis vs Lettuce.

redis.com

 

 

그 다음은 생성한 redisConnectionFactory 빈을 이용해 redisTemplate를 생성해 빈으로 등록한다. 이것으로 redis DB와 직접 상호작용할 수 있다. (기존에 작성했던 repository를 떠올리면 될 것 같다.)

 

 

 

 

이렇게 설정파일도 작성했다면, refresh 토큰을 위한 코드를 작성해보자.

 

 

RefreshToken.java

@Getter
@Setter
@NoArgsConstructor
@Builder
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24)   //단위는 초단위. 60초 뒤 데이터 삭제. 테스트를 위해 짧게 60초로 설정.
public class RefreshToken extends BaseEntity {
    @Id
    private String username;
    @Indexed
    private String refreshToken;

    public RefreshToken(String username, String refreshToken){
        this.username = username;
        this.refreshToken = refreshToken;
    }
    public void updateRefreshToken(String refreshToken){
        this.refreshToken = refreshToken;
    }

}

 

refreshtoken을 redis에 저장할 때를 위한 엔티티이다. username 과 그 사용자의 refreshToken이 저장된다. 이전에 mariadb에 테이블을 생성할 때 @entity를 사용했지만, redis에서는 필요없다. redis는 관계형 데이터베이스가 아니라는 사실을 기억하자.

 

 

 

 

그 다음, redis와 상호작용하는 refreshTokenRepository를 작성했다.

 

 

@Repository
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
    private final RedisTemplate redisTemplate;
    private final Logger log = LoggerFactory.getLogger(RefreshTokenRepositoryImpl.class);

    @Autowired
    public RefreshTokenRepositoryImpl(RedisTemplate redisTemplate){
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void save(RefreshToken refreshToken){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();  //opsFor은 특정 컬렉션의 작업(operation)들을 호출할 수 있는 인터페이스를 반환. 이것은 string을 위한 것.

        if(!Objects.isNull(valueOperations.get(refreshToken.getUsername()))){    //만약 이미 그 username을 key로 한 refreshtoken이 있을 경우, 업데이트를 위해 삭제.
            redisTemplate.delete(refreshToken.getUsername());
            log.info("refreshToken Repository save -> update ");
        }
        valueOperations.set(refreshToken.getUsername(), refreshToken.getRefreshToken());  //redis에 데이터 저장.
        redisTemplate.expire(refreshToken.getUsername(), 60 * 60 * 24, TimeUnit.SECONDS);  //그 데이터 만료 시간 지정. 24시간.
    }


    @Override
    public Optional<RefreshToken> findByUsername(String username){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String refreshToken = valueOperations.get(username);     //username을 key로 가진 데이터 찾음.

        if(refreshToken== null){
            return Optional.empty();
        }
        else{
            return Optional.of(new RefreshToken(username, refreshToken));
        }
    }
    @Override
    public void delete(String username){
        redisTemplate.delete(username);
    }
}

 

 

위에서 언급했던 redisTemplate를 사용했다. save가 바로 redis DB에 데이터를 저장하는 메서드이다.

 

redisTemplate의 opsForValue 함수는 쉽게 말해 value값을 저장하거나 추출할 때 사용되는 연산(operation)들을 모아놓은 인터페이스이다. 이 인터페이스의 set, get함수를 통해 db에 데이터를 저장하거나, 맞는 데이터를 찾을 수 있다.

 

 

save에 보면 if문을 통해 save하기 전, 데이터를 체크하는 부분이 있다. 이는 jpaRepository처럼 이미 존재하는 user로 save할 시 save가 아닌 update연산을 하기 위함이다. 이렇게 하지 않으면 똑같은 user가 두 개의 refreshToken을 가지게 될 수 있다.

 

 

 

다 작성한 후, refresh token을 발급하는 부분을 jwtTokenProvider에 추가하도록 하겠다.

 

 

 

JwtTokenProvider.java

 public String createRefreshToken(String username){
        log.info("JwtToken create RefreshToken ==> refresh token 생성 시작");
        Claims claims = Jwts.claims().setSubject(username);
//        claims.put("roles" ,roles);

        Date now = new Date();
        String refreshToken = Jwts.builder()              //refresh 토큰에는 사용자 이름은 들어가지만 권한은 들어가지 않았음.
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshValidMilliSecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        log.info("JwtToken create refresh Token ==> refresh 토큰 생성 완료");
        return refreshToken;
        //DB에 실제로 refresh 토큰 저장해야함.
    }

 

이 코드는 jwtTokenProvider에 추가된 메서드이다. 이 메서드를 통해 refresh 토큰을 생성한다. 기존에 access token생성과 차이점은 토큰에 정보를 별로 저장하지 않아도 된다는 점이다.

 

 

그래서 role, 즉 사용자의 권한 정보는 저장하지 않는 모습을 볼 수 있다. 물론 나는 사용자 아이디는 넣었지만, 이는 필요해서 넣은 것이지 필요없다면 굳이 넣지 않아도 된다. 

 

 

 

 

이것을 signService에서 활용한 모습은 다음과 같다.

 

 

    @Override
    @Transactional
    public ResponseSignInDto signIn(RequestSignInDto requestSignInDto) {
        log.info("SignService signin ==> 회원 인증 확인 시작");
        String username = requestSignInDto.getUsername();
        String password = requestSignInDto.getPassword();

        User user = userRepository.getByUsername(username)
                .orElseThrow(() -> new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.NOT_FOUND, "잘못된 id"));

        log.info("회원 id : " + username);

        if(!passwordEncoder.matches(password, user.getPassword())){
            throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.BAD_REQUEST, "비밀번호 불일치");
        }

        log.info("비밀번호 일치");

        String accessToken = jwtTokenProvider.createToken(user.getUsername(), user.getRoles());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getUsername());

   
        refreshTokenRepository.save(new RefreshToken(username, refreshToken));


        ResponseSignInDto responseSignInDto = ResponseSignInDto.builder()
                .token(accessToken)
                .refreshToken(refreshToken)
                .user_id(user.getId())
                .username(username)
                .build();


        log.info("ResponseSignInDto 생성");
        setSuccess(responseSignInDto);
        return responseSignInDto;
    }

 

작성한 createRefreshToken이 사용된 부분이다. 로그인을 하는 메서드로, 사용자가 로그인하면 access token 뿐 아니라 refresh token도 발급해주고, refresh token은 작성했던 refreshTokenRepository의 save를 통해 redis에 저장한다. 

 

 

 

 

여기까지 했다면 이제 토큰 만료가 확인된 후 새 access token을 요청하는 api를 작성하자. 만료되었음을 확인하는 방법은 redis에 그 사용자에게 발급된 refresh token이 남아있는지 체크하면 된다.

 

 

SignController.java

 

@PostMapping("/reissue")
@Operation(summary = "토큰 재발급", description = "refresh 토큰으로 access 토큰을 재발급합니다.", responses = {
        @ApiResponse(responseCode = "200", description = "재발급 성공", content = {
                @Content(mediaType = "application/json", schema =
                @Schema(implementation = ResponseRefreshDto.class))
        }),
        @ApiResponse(responseCode = "400", description = "재발급 실패")
})
public ResponseEntity<ResponseRefreshDto> reissue(@RequestBody RequestRefreshDto requestRefreshDto, HttpServletRequest request){
    log.info("SignController reissue ==> 토큰 재발급 메서드");
    ResponseRefreshDto responseRefreshDto = signService.reissue(requestRefreshDto, request);

    return ResponseEntity.status(HttpStatus.OK).body(responseRefreshDto);
}

 

로그인을 위한 컨트롤러에 reissue를 추가했다. 이 api는 사용자 access token이 만료되었다는 사실을 프론트에서 인지하면, 그 이후 호출하는 api이다. requestRefreshDto는 사용자의 refresh token필드가 있고, 사용자측에서 로그인 시 발급받은 refresh token을 담아서 보내면 된다.

 

 

 

 

 

signService.java

@Override
public ResponseRefreshDto reissue(RequestRefreshDto requestRefreshDto, HttpServletRequest request) {

    log.info("reissue ==> refresh 토큰 통한 토큰 재발급 시작");
    log.info("get refreshtoken : " + requestRefreshDto.getRefreshToken());
    String refreshToken = requestRefreshDto.getRefreshToken();


    if(!jwtTokenProvider.validateRefreshToken(refreshToken)){   //refresh 토큰이 유효기간이 지났는지 검증
        throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.UNAUTHORIZED, "재로그인 필요");
    }
    log.info("reissue ==> refresh 토큰 검증 성공");

    String username = jwtTokenProvider.getUsername(refreshToken);

    RefreshToken findRefreshToken = refreshTokenRepository.findByUsername(username)    //DB에 실제로 그 유저에게 발급된 refresh토큰이 있는지 확인
            .orElseThrow(() -> new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.BAD_REQUEST, "로그아웃된 사용자"));
    log.info("reissue ==> DB에 사용자 이름과 refresh 토큰 존재 확인");


    if(!findRefreshToken.getRefreshToken().equals(refreshToken)){
        throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.BAD_REQUEST, "DB의 refresh토큰과 일치하지 않음.");
    }
    log.info("reissue ==> DB refresh token과 일치 확인");

    User user = userRepository.getByUsername(username)
            .orElseThrow(() -> new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.BAD_REQUEST, username + " 은 없는 사용자 아이디입니다."));


    String newAccessToken = jwtTokenProvider.createToken(     //새로운 토큰 발급.
            username,
            user.getAuthorities()
                    .stream()
                    .map(Objects::toString)
                    .collect(Collectors.toList())
    );

    log.info("reissue ==> 새 토큰 발급 : " + newAccessToken);
    log.info("토큰 authorities : " +  user.getAuthorities()
            .stream()
            .map(Objects::toString)
            .collect(Collectors.toList()));        //잘 되는 지 확인용. 나중엔 지워야 함.

    String newRefreshToken = jwtTokenProvider.createRefreshToken(username);

    findRefreshToken.updateRefreshToken(newRefreshToken);    //refresh 토큰도 업데이트.
    refreshTokenRepository.save(findRefreshToken);

    ResponseRefreshDto responseRefreshDto = new ResponseRefreshDto(newAccessToken, newRefreshToken);

    return responseRefreshDto;
}

 

signService에 reissue 서비스 로직을 추가했다. 엄청 길다.. 크게 과정을 나누면 다음과 같다.

 

 

1. refresh 토큰이 유효한 지 검증한다. 유효하지 않다면, 결국 사용자는 다시 로그인해야한다.

 

2.  DB에 사용자 아이디를 key로 저장한 데이터가 있는 지 확인한다. 없다면, 역시나 유효기간이 지났으니 재로그인 해야하는 것이다.

 

3. DB에 사용자 아아디(key)와 refresh token(value) 데이터가 존재한다면, 사용자가 보낸 refresh token과 저장된 refresh token이 같은 지 비교한다. 

 

4. 위 과정에서 문제가 없으면, refresh 토큰이 이상이 없다는 뜻이니 새로운 access token을 사용자에게 발급해준다. 

 

5. 이후 그 사용자의 refresh 토큰을 업데이트해서 redis에 적용한다. 

 

 

이런 과정으로 흘러간다. 

 

 

 

이렇게 refresh 토큰을 통해 새 토큰을 재발급 받는 과정까지 알아보았다. 이를 통해 사용자는 자주 로그인할 필요가 없이 api를 지속적으로 호출하면 계속해서 로그인 상태를 유지할 수 있다.  refresh 토큰을 이용하면 이렇게 보안을 위해 access token의 지속시간을 짧게 설정해야하는 단점을 극복할 수 있다.