본문 바로가기
개발/Spring

Redis를 통한 JWT Refresh Token 관리

by solchan98 2022. 2. 11.

깃허브 저장소

 

GitHub - solchan98/Playground: 🛝 개발 공부 놀이터 🛝

🛝 개발 공부 놀이터 🛝. Contribute to solchan98/Playground development by creating an account on GitHub.

github.com

 

+2022-09-28 새로 작성되었습니다.

본 포스팅은 이전 발급받은 JWT로 요청하기 과정에 이어서 진행된다.

JWT는 한 번 발급되면 만료되기 전 까지 삭제할 수 없다. 이 문제를 어떻게 해결할까?

일반적으로 RefreshToken이라는 추가적인 토큰을 통해 이를 해결한다.
그러면 어떻게 해결하는지 자세히 알아보자.

Access Token과 Refresh Token

리프레시 토큰을 발급하기 전, 리프레시 토큰이 어떻게 사용되는지 알아보자.

각 토큰의 이름이 뜻하는 대로,
어세스 토큰은 접근에 관여하는 토큰이고
리프레시 토큰은 재발급에 관여하는 토큰이다.

JWT는 발급한 후, 삭제가 불가능하기 때문에 접근에 관여하는 토큰은 유효시간을 길게 부여할 수 없다. 하지만 자동 로그인 혹은 로그인 유지를 위해서는 유효시간이 긴 토큰이 필요하다. 이때 사용되는 것이 Refresh Token이다.

Access Token 재발급은 크게 2가지 방법으로 볼 수 있다.

  1. 요청마다 Access Token과 Refresh Token을 같이 넘기는 방법.
  2. 재발급 API를 만들고 서버에서 Access Token이 만료되었다고 응답하면 Refresh Token으로 요청하여 재발급 받기.

여기서는 2번의 방법으로 진행된다.

생명 주기

어세스 토큰과 리프레시 토큰의 생명 흐름을 알아보자.

우선, Access Token 먼저 보자. Access Token은 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검증을 하며 사용자 권한을 인증한다.

이런 역할을 하는 Access Token이 탈취되면 토큰이 만료되기 전 까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해진다. 따라서 Access Token의 유효 주기는 짧게 가져가야 한다.

그러면, 자동 로그인 혹은 로그인 유지는?
이제 Refresh Token의 일이 시작된다. Refresh Token은 한 번 발급되면 Access Token보다 훨씬 길게 발급된다. 대신에 접근에 대한 권한을 주는 것이 아니라 Access Token 재발급에만 관여한다.

Access Token의 재발급 방법

그럼 어떻게 재발급에 관여하는지 알아보자.

보통 Refresh Token은 로그인 성공시 발급되며 저장소에 저장하여 관리된다.
그리고 사용자가 로그아웃을 하면 저장소에서 Refresh Token을 삭제하여 사용이 불가능하도록 한다. 사용이 불가능한 이유는 아래 재발급 과정을 확인하면 알 수 있다.

Access Token이 만료되어 재발급이 진행되면 다음의 과정을 통해 재발급이 된다.

  1. RefreshToken을 AccessToken 처럼 HTTP 헤더의 Authorization 속성으로 전달 받는다.
  2. 토큰의 payload(Subject)의 type을 통해 ATK인지 RTK인지 구분한다.
  3. RTK이며, URI가 /account/reissue인 경우 재발급을 진행한다.
  4. ATK 재발급은 RTK의 payload에서 유저의 email을 꺼낸 뒤, Redis 인메모리에 해당 유저의 존재 유무로 결정된다.
  5. TokenResponse에 새로운 ATK를 넣어 응답한다.

이후 클라이언트는 재발급된 Access Token을 요청 헤더에 포함하여 요청을 보내면 정상적으로 접근이 허용된다.

RTK는 /account/reissue URI 접근에만 사용 가능하며, Redis 인메모리에서 사용자의 정보 조회를 위해 payload에서 유저 email를 얻는 용도로만 사용된다.

따라서 RTK로 다른 URI는 접근할 수 없다. 이를 위해 token Subject에 type 필드가 존재하는 것이다.

 

구현은 다음의 순서대로 진행된다.

  • RTK인 경우에 대한 필터 조건 처리하기
  • Redis 사용하기
  • RefreshToken 발급
  • AccessToken 재발급

RTK인 경우에 대한 필터 조건 처리하기

RTK는 오로지 ATK 재발급에만 사용된다고 했다.
따라서 토큰 검증이 진행되는 필터단계에서 조건이 추가된다.
다음과 같이 JwtAuthenticationFilter.java를 수정한다.

 

추가된 조건은 간단하다.
토큰의 Type이 RTK인 경우 Request URI가 /account/reissue인 경우에만 허용한다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final AccountDetailsService accountDetailsService;

    public JwtAuthenticationFilter(JwtProvider jwtProvider, AccountDetailsService accountDetailsService) {
        this.jwtProvider = jwtProvider;
        this.accountDetailsService = accountDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("Authorization");
        if (!Objects.isNull(authorization)) {
            String atk = authorization.substring(7);
            try {
                Subject subject = jwtProvider.getSubject(atk);
                String requestURI = request.getRequestURI();
                if (subject.getType().equals("RTK") && !requestURI.equals("/account/reissue")) {
                    throw new JwtException("토큰을 확인하세요.");
                }
                UserDetails userDetails = accountDetailsService.loadUserByUsername(subject.getEmail());
                Authentication token = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(token);
            } catch (JwtException e) {
                request.setAttribute("exception", e.getMessage());
            }
        }
        filterChain.doFilter(request, response);
    }
}

Redis 사용하기

ATK 재발급을 위해 Redis 인메모리 저장소를 사용한다.
키-벨류 형식으로 저장되는데 email-RTK 형식으로 저장한다.

 

사실상 RTK는 /account/reissue로 접근 시, RTK Subject에서 email을 꺼낸 후 더 이상 이용되지 않는다.

왜? 이미 /account/reissue로 접근하기 위해서는 유효한 RTK이어야 하며, 유효한 RTK에서 꺼낸 email이기 때문이다.

 

기본적으로 Redis가 설치되어있어야 한다. (Docker를 사용하면 간단함.)
Redis 설치 참고

프로젝트에 Redis 설정은 다음의 과정을 통해 진행된다.

  1. 디펜던시 추가
  2. RedisConfig 작성
  3. RedisDao 작성

Redis 디펜던시 추가

간단하게 build.gradle에 다음을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

RedisConfig 작성

디펜던시을 추가하였기 때문에 이제 설정파일을 작성한다.

RedisConfig에 Redis의 HOST 및 PORT값을 설정하기 위해 설정파일에(application.yml 또는 application.properties) 추가해야한다.

아래의 PORT 값은 설치된 Redis가 동작하고있는 port값을 넣는다.
Redis는 기본적으로 6379로 동작한다.

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

RedisConfig
아래의 위치에 작성한다.
/springbootjwt/config/RedisConfig.java

@Configuration
@EnableRedisRepositories
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);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

RedisDao

어디서든 redisTemplate를 받아와서 set, get, delete.. 등을 사용할 수 있으나 그렇게 사용하지 않고 RedisDao를 작성하여 사용한다.

 

RedisDao
아래의 위치에 작성한다.
/springbootjwt/common/RedisDao.java

redisTemplate의 사용법은 자세히 다루지 않는다.

이제 아래의 메서드를 통해 redis 저장소에 Key-Value 쌍으로 데이터를 넣고 가져오며 삭제 가능하다.

@Component
public class RedisDao {

    private final RedisTemplate<String, String> redisTemplate;

    public RedisDao(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void setValues(String key, String data) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public String getValues(String key) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}

로그인 시, RefreshToken 함께 발급

Redis에 대한 설정이 마무리 되었기에 RefreshToken 발급을 진행할 수 있다.
RefreshToken은 기존 로그인 과정에 추가적인 과정이 진행된다.

  • JwtProvider

JwtProvider

ATK 발급 시, atkLive를 통해 만료 시간을 설정했던 것 처럼 RTK 역시 rtkLive 변수를 추가하여 사용한다.
추가로 설정파일(application.properties 또는 application.yml)에서 rtkLive에 대하여 추가한다.

(참고로 빠른 토큰 만료 및 재발급 테스트를 위해 ATK 및 RTK의 만료 시간을 기존 값에서 조정하였다.)

...
  jwt:
    key: key
    live:
      atk: 60000
      rtk: 300000
...

기존의 JwtProvider의 createTokensByLogin메서드에서 RTK 발급 과정을 추가하면 된다.

로그인 시, 기존에는 TokenResponse의 rtk 필드가 null이었지만, 이번 과정을 통해 rtk값 또한 추가하여 응답한다.

@Component
@RequiredArgsConstructor
public class JwtProvider {

    private final RedisDao redisDao;
    private final ObjectMapper objectMapper;

    @Value("${spring.jwt.key}")
    private String key;

    @Value("${spring.jwt.live.atk}")
    private Long atkLive;

    @Value("${spring.jwt.live.rtk}")
    private Long rtkLive;

    @PostConstruct
    protected void init() {
        key = Base64.getEncoder().encodeToString(key.getBytes());
    }

    public TokenResponse createTokensByLogin(AccountResponse accountResponse) throws JsonProcessingException {
        Subject atkSubject = Subject.atk(
                accountResponse.getAccountId(),
                accountResponse.getEmail(),
                accountResponse.getNickname());
        Subject rtkSubject = Subject.rtk(
                accountResponse.getAccountId(),
                accountResponse.getEmail(),
                accountResponse.getNickname());
        String atk = createToken(atkSubject, atkLive);
        String rtk = createToken(rtkSubject, rtkLive);
        redisDao.setValues(accountResponse.getEmail(), rtk, Duration.ofMillis(rtkLive));
        return new TokenResponse(atk, rtk);
    }

    private String createToken(Subject subject, Long tokenLive) throws JsonProcessingException {
        String subjectStr = objectMapper.writeValueAsString(subject);
        Claims claims = Jwts.claims()
                .setSubject(subjectStr);
        Date date = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(date)
                .setExpiration(new Date(date.getTime() + tokenLive))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }

    public Subject getSubject(String atk) throws JsonProcessingException {
        String subjectStr = Jwts.parser().setSigningKey(key).parseClaimsJws(atk).getBody().getSubject();
        return objectMapper.readValue(subjectStr, Subject.class);
    }
}

이제 기존과 똑같이 로그인을 요청하여 응답을 확인해보면 AccessToken과 RefreshToken을 확인할 수 있다.

JwtProvider reissue 메서드 구현

이제 JwtProvider의 마지막 구현이다.

필터 단계에서 검증된 RTK에서 꺼낸 유저 email이 Redis 인메모리에 존재하는지 확인 후, ATK 재발급을 진행한다.

여기서 TokenResponse를 응답하는데 ATK만 세팅하여 응답한다.

@Component
@RequiredArgsConstructor
public class JwtProvider {

    ...

    public TokenResponse reissueAtk(AccountResponse accountResponse) throws JsonProcessingException {
        String rtkInRedis = redisDao.getValues(accountResponse.getEmail());
        if (Objects.isNull(rtkInRedis)) throw new ForbiddenException("인증 정보가 만료되었습니다.");
        Subject atkSubject = Subject.atk(
                accountResponse.getAccountId(),
                accountResponse.getEmail(),
                accountResponse.getNickname());
        String atk = createToken(atkSubject, atkLive);
        return new TokenResponse(atk, null);
    }
}

AccessToken 재발급

이제 RefreshToken을 발급하였기 때문에 AccessToken을 재발급할 수 있다.

AccessToken 재발급을 위한 API를 추가로 개설해야한다.

 

필터단계에서 RTK에 대한 유효성이 체크된 후, Controller에 도달한다.
따라서 AccountResponse DTO를 생성하여 JwtProvider에 새로운 ATK (TokenResponse)를 전달받아 응답한다.

AccountController

@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {

    private final AccountService accountService;
    private final JwtProvider jwtProvider;

    ...

    @GetMapping("/reissue")
    public TokenResponse reissue(
            @AuthenticationPrincipal AccountDetails accountDetails
    ) throws JsonProcessingException {
        AccountResponse accountResponse = AccountResponse.of(accountDetails.getAccount());
        return jwtProvider.reissueAtk(accountResponse);
    }
}

이제 새로 개설한 API로 HTTP 헤더의 Authorization 필드에 Bearer RTK 형식으로 요청하면 다음과 같이 새로운 AccessToken 발급을 확인할 수 있다.


JWT를 통한 로그인 포스팅
1. 로그인과 JWT 발급
2. 발급받은 JWT로 요청하기
3. Redis를 통한 JWT Refresh Token 관리
4. Redis를 통한 로그아웃 이후, AccessToken 관리 (BlackList)