[Spring security + JWT + Redis] #5 Refresh token

devwuu·2023년 8월 17일

security

목록 보기
5/6

이 게시글은 강의를 듣고 사이드 프로젝트에 적용한 내용이 주를 이루고 있습니다
수강 강의 : (인프런) 스프링부트 시큐리티 & JWT 강의
🏠 Basic Exam : https://github.com/devwuu/spring-security-exam
🏠 사이드 프로젝트 : https://github.com/devwuu/todaktodak


1. Refresh Token이란?

보안을 위해 Access Token은 보통 짧은 유효시간을 갖는다. Access Token이 만료되면 사용자는 다시 인증(로그인)을 해야하는데 짧은 유효 시간 때문에 자주 인증해야하는 불편함이 생긴다. 이 경우, Access Token보다 유효시간이 긴 Refresh Token을 사용함으로써 재인증의 번거로움을 줄여줄 수 있다. 일반적으로 Access Token이 만료되면 Refresh Token으로 Access Token을 재발급 받고, 추후 Refresh Token까지 만료되고 나서야 재인증(로그인)하는 방식으로 이루어진다.

내가 직접 구현해본 적은 없지만 Refresh Token은 일반적으로 DB에 저장해 관리한다는 이야기를 들은 적이 있어서 조금 더 구글링을 해봤다. Refresh Token의 경우, 유효시간이 길고 Access Token을 재발급 받을 수 있는 수단이 되기 때문에 탈취당했을 때를 대비해 Token 소유자의 Username, 발급 당시 Ip 등 다양한 정보를 함께 DB에 저장한다는 이야기도 있었고 DB에 저장할 거면 결국 Session과 비교했을 때 큰 이득이 없다는 이야기도 있었다.

결국 내가 선택한 방법은 Redis에 Refresh Token과 User정보를 저장하는 방식이었다. Redis는 (기본적으로)싱글 스레드로 동작하는 인메모리 데이터 저장소로 데이터 처리 속도가 빨라 DB에 저장하는 것보다 유리하다고 한다. 또 저장할 때 유효 시간을 정할 수 있기 때문에 나중에 Batch를 따로 돌릴 필요가 없다는 점도 좋았다. 무엇보다 내가 실무에 있을 때 들었던 이야기와 어느정도 유사했고, 구글링 했을 때 Redis에 Token을 저장하는 방식이 꽤 나와 마음이 갔다.


2. Spring-data-redis 적용하기

(1) redis 다운로드 및 실행

redis는 docker를 사용하기로 했다. 프로젝트 DB 역시 docker container로 띄웠기 때문에 동일한 환경으로 구축하는 게 일관성 있을 것 같았다. container를 띄우는 기본 명령어는 $ docker run --name some-redis -d redis 인데 port 옵션이 없으면 interlliJ에서 연결이 안되어서 port 옵션만 추가해줬다.

docker pull redis
docker run -p 6379:6379 --name redis -d redis

(2) redis 연결

spring boot에서 redis는 JPA 를 사용하는 것과 비슷했다 (데이터 저장소라서 그런가...) 처음엔 redis + jwt 키워드로 구글링을 해서 설정을 했는데 버전이 바뀌면서 설정 방법이 바뀐건지 내 프로젝트에선 제대로 연결되지 않았다. 다행히 공식 문서에 사용 방법이 아주 친절히 적혀있어서 어렵지 않게 적용할 수 있었다. 사랑해요!

1) 의존성 추가

2개 모두 추가해줘야한다!

   //redis
    // https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis
    implementation 'org.springframework.data:spring-data-redis:3.1.2'
    // https://mvnrepository.com/artifact/io.lettuce/lettuce-core
    implementation 'io.lettuce:lettuce-core:6.2.5.RELEASE'

2) 환경설정

환경별로 설정을 바꿔주기 위해 port와 host 설정은 application-local.yml 파일로 분리했다.

spring:
  ...
  data:
    redis:
      host: localhost
      port: 6379

@Configuration
@EnableRedisRepositories
public class RedisConfiguration {

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

    @Bean
    public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

3) RedisHash 및 Repository 추가

데이터를 저장하는 방식에는 (1)RedisTemplate를 사용하는 방법과 (2)Repository를 사용하는 방법이 있는데 Token을 관리하는 용도로 간단하게만 사용할 것이기 때문에 Repository를 사용하는 방법을 선택했다.

@RedisHash(value = "refresh")
@Getter
@Setter
@Accessors(chain = true, fluent = true)
@NoArgsConstructor
public class UserRefreshToken {

    @Id
    private String id;

    private String refreshToken;

    @TimeToLive(unit = TimeUnit.MINUTES)
    private Long expiration;

    public UserRefreshToken(UserDetails userDetails, String refreshToken, Long expiration){
        this.id = userDetails.getUsername();
        this.refreshToken = refreshToken;
        this.expiration = expiration;
    }

}

public interface UserRefreshTokenRepository extends CrudRepository<UserRefreshToken, String> {
}

@RedisHash의 value는 domain을 구분하는 이름으로 @Id 는 데이터의 식별자로 사용이 된다.

⭐️⭐️ 4) Repository 사용시 주의 사항 ⭐️⭐️


Redis Repository 방식은 Transaction을 제공하지 않는다. 즉, 로직 중간에 exception이 발생해도 처리된 스크립트는 롤백되지 않고 완료가 된다. 데이터 간 일관성이 중요한 데이터라면 Repository 방식이 아니라 RedisTemplate을 이용하는 방식으로 설정해야하며 RedisTemplate을 사용할 때도 Transaction과 관련된 설정은 추가적으로 해줘야한다. 공식에서 제공해주는 가이드는 다음과 같다. https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#tx.spring


3. Refresh Token 발급

본격적으로 Refresh Token을 구현하기 전에 Refresh Token과 관련된 비즈니스 로직을 정리해봤다.

  • 사용자가 id와 password로 로그인을 시도한다
  • 인증이 완료되면 Access Token과 Refresh Token을 발급한다
  • Refresh Token은 Redis에 저장한다
  • Access Token이 만료된다
  • 사용자가 Refresh Token으로 Access Token 재발급을 요청한다
  • Refresh Token을 검증한다
  • Redis에서 해당 사용자의 Refresh Token을 찾는다
  • Redis에 저장된 Refresh Token과 사용자가 전달한 Refresh Token을 비교 검증한다
  • Token이 검증되면 Access Token을 재발급한다

Refresh Token이 추가되면서 위 로직과 별도로 추가되어야 하는 검증 로직도 정리했다.
  • Token이 Access Token인지 Refresh Token인지 구분되어야 한다
    ➡️ Refresh Token으로 API 요청을 인증/인가 해주지 않는다
  • Token 발급 요청시에 Id, Password로 요청하는 경우와 Refresh Token으로 요청하는 경우로 나뉜다
    ➡️ Token 요청용 DTO가 별도로 필요하다
  • 발급해주는 Token이 2종류가 되었다
    ➡️ Token 응답용 DTO가 별도로 필요하다

(1) AuthenticationRequest / AuthenticationResponse 추가

가장 간단한 DTO 추가부터 시작하기로 했다.

@Getter @Setter
@Accessors(fluent = true, chain = true)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class AuthenticationRequest implements Serializable {

    private static final long serialVersionUID = -1695490485907383846L;

    private String id;
    private String password;
    private String refreshToken;

}
@Getter @Setter
@Accessors(chain = true, fluent = true)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class AuthenticationResponse implements Serializable {

    private static final long serialVersionUID = 9181222863315822243L;

    private String refreshToken;
    private String accessToken;

}

(2) JwtUtil에 generateRefreshToken() 구현

JwtProvider에 Refresh Token을 검증하고 발급하는 메서드를 추가해줬다. 더불어 Token 발급 및 검증 외에 다른 유틸성 메서드도 추가되어 클래스 이름을 변경해주었다.

@Service
@RequiredArgsConstructor
public class JwtUtil {

    private final JwtProperties properties;
    private final UserRefreshTokenRepository tokenRepository;
    
	...

    public String generateRefreshToken(UserDetails userDetails){
        String token = createToken(userDetails, properties.getRefreshTokenSubject(), properties.getRefreshTokenExpiredAt());
        UserRefreshToken refreshToken = new UserRefreshToken(userDetails, token, (long)properties.getRefreshTokenExpiredTime());
        tokenRepository.save(refreshToken);
        return token;
    }

    public Optional<DecodedJWT> verifyRefreshToken(String token){
        DecodedJWT decodedJWT = decodeToken(token);
        Optional<UserRefreshToken> refreshToken = tokenRepository.findById(decodedJWT.getClaim("id").asString());
        return refreshToken.filter(userRefreshToken -> userRefreshToken.refreshToken().equals(token)).map(userRefreshToken -> decodedJWT);
    }
    
    public Boolean isAccessToken(String header){
        return decodeToken(header).getSubject().equals(properties.getAccessTokenSubject());
    }

}

(3) UserAuthenticationFilter 수정

기존에 UserDTO로 받았던 걸 AuthenticationRequest DTO로 변경했다. userId와 password로 인증하는 경우엔 AuthenticationManager를 통해 사용자 인증을 하고 Refresh Token으로 인증하는 경우엔 userDetailService에서 바로 user를 인증하도록 했다 (AuthorizationFilter와 동일한 로직)

public class UserAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    ...

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        AuthenticationRequest authenticationRequest = JsonUtil.readValue(request, AuthenticationRequest.class);

        if(StringUtil.isEmpty(authenticationRequest.refreshToken())){
            UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(authenticationRequest.id(), authenticationRequest.password());
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            return authenticate;
        }else{
            Optional<DecodedJWT> verifyToken = jwtUtil.verifyRefreshToken(authenticationRequest.refreshToken());
            DecodedJWT decodedJWT = verifyToken.orElseThrow(() -> new InvalidTokenException("INVALID TOKEN"));
            UserDetails userDetails = userDetailsService.loadUserByUsername(decodedJWT.getClaim("id").asString());
            UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(userDetails, null, userDetails.getAuthorities());
            return authenticationToken;
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        UserDetails principal = (UserDetails) authResult.getPrincipal();

        // access token과 refresh token 모두 재발급
        String accessToken = jwtUtil.generateAccessToken(principal);
        String refreshToken = jwtUtil.generateRefreshToken(principal);

        AuthenticationResponse token = new AuthenticationResponse().accessToken(accessToken).refreshToken(refreshToken);
        JsonUtil.writeValue(response.getOutputStream(), token);

    }

}

상기 로직에서 Refresh Token으로 인증하는 경우에, Authentication 타입이 아닌 UsernamePasswordAuthenticationToken 타입으로 return 해도 Authentication이 정상적으로 완료되는 이유는 UsernamePasswordAuthenticationToken 타입이 Authentication의 구현체이기 때문이다.


(4) UserAuthorizationFilter 수정

UserAuthorizationFilter에 Access Token인지 검증하는 로직을 추가했다.

public class UserAuthorizationFilter extends OncePerRequestFilter {

    ...

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");

        if(StringUtil.isEmpty(header) || !jwtUtil.isStartWithPrefix(header) ||!jwtUtil.isAccessToken(header)){
            filterChain.doFilter(request, response);
            return;
        }
        Optional<DecodedJWT> decodedJWT = jwtUtil.verifyAccessToken(header); // 로그아웃된 user인지 확인
        if(decodedJWT.isEmpty()){
            throw new InvalidTokenException("INVALID ACCESS TOKEN"); // 로그아웃된 user일 경우
        }
        String id = decodedJWT.get().getClaim("id").asString();
        UserDetails details = userDetailsService.loadUserByUsername(id);
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.authenticated(details, null, details.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(token);
        filterChain.doFilter(request, response);
    }
}

4. 마치며

Refresh Token을 다루는 방법에는 여러가지가 있겠지만, 가장 일반적으로, 또 간단하게 적용해볼 수 있는 방법이 어떤 것일지를 고민했다. 우선 작동하는 결과물을 얻었다는 데 의의가 있다고 생각한다. 아직 포스팅하진 않았지만 Redis를 이용해 logout 기능도 구현해볼 수 있었기 때문에 꽤 괜찮은 선택이지 않았나 하는 생각이 든다(아직까지는...)


출처

https://taes-k.github.io/2020/07/23/redis-essential/
https://hub.docker.com/_/redis
https://docs.spring.io/spring-data/redis/docs/current/reference/html/#redis.repositories
https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#redis:connectors:lettuce

profile
일단 한다

0개의 댓글