Dev_Henry

[Spring] JWT refreshToken에 대한 고민, 사용하기(Redis) 본문

Web/Spring

[Spring] JWT refreshToken에 대한 고민, 사용하기(Redis)

데브헨리 2023. 7. 25. 00:03
728x90

진행중인 프로젝트에서 처음 구현할때 refreshToken도 만들긴 했는데 급하게 만들다 보니 redis 같은 db를 사용한 검증방식도 없었고 어차피 jwt관련 코드가 모두 리팩토링이 필요했기 때문에 refreshToken 부분도 새로 구현하면서 알아보려고 한다.

 

우선 refreshToken(이하 rtk)은 JWT방식의 단점을 어느정도 보완하고자 생긴 방법인데,

accessToken(이하 atk)을 탈취당하면 해커가 계정을 자유롭게 사용할 수 있기 떄문에 atk의 만료시간을 짧게 주고 비교적 만료시간이 긴 rtk을 함께 생성해 둔 뒤에 atk가 만료되었을때 rtk를 통해 atk를 재발급 해주는 방식으로 동작한다..

라는 내용으로 만들어진 rtk이고 공부를 하면서 찾아본 책, 블로그 등에서도 모두 이정도로 얘기한다. 그런데 이정도로 보안에 크게 의미가 있는지 납득하기 힘들었다.. 처음 공부하면서 jwt를 알게된 책에서는 로그인 시 그저 atk,rtk를 함께 클라이언트에게 전달하고, 클라이언트가 가지고 있다가 갱신이 필요할때 rtk를 보내주는 방식이었는데, atk가 탈취당할 수 있다면 rtk도 함께 탈취당할 수 있지 않은가?

많은 블로그에서 rtk는 유효시간이 길어 탈취당할 수 있지만, 오직 atk를 재발급하는 용도일 뿐 다른 쓸모가 없기때문에 괜찮다는 식으로 설명하는데, atk를 재발급할 수 있는 토큰과 atk가 다를게 뭔가.. 영화티켓 예매번호와 영화티켓의 차이 정도로 느껴질 뿐이다.

더 찾아보면 redis에 rtk를 저장해두고 요청받을때 비교하는 방식으로 검증하여 안정성을 높인다고 하는데, 그래봤자 로그아웃 혹은 어떤 이유로 블락시킨 rtk를 사용할 수 없게 할 뿐이지 정상적인 유저의 요청인지 털어간 해커의 요청인지는 구분할 수 없다.

 

계속해서 찾아보고 고민하다 내린 결론은 jwt방식이 아닌 어떤 로그인 방법에서도 탈취당했을 때 위험한건 똑같고, 그렇기 때문에 (꼭 엄청난 효과까지는 아니더라도)조금이나마 보안성을 높이기 위해서 rtk를 사용하는 것 아닐까?..

아마 모든 요청마다 함께 보내야하는 atk에 비해 꽁꽁 숨겨두었다가 필요할 때(토큰 갱신)만 사용하는 rtk가 조금이라도 뺏길 가능성이 낮지않을까?..하는 생각이다.

그리고 rtk를 사용하는 방법. 토큰을 재발급 해주는 과정을 어떻게 구성하냐에 따라 조금씩 효과를 더할 수 있을 것 같았다.

 

예를 들어, db에 rtk와 함께 사용자의 접속 ip주소를 저장해두고, 다른 위치에서 토큰을 갱신받을 때 사용자에게 확인 알람을 주는 방식이 있다. 지금 생각할 수 있는 방식중에는 가장 좋은 방법 같은데 이번 프로젝트에서는 사용하지 않겠다.

 

이번 프로젝트에서는 아래와 같은 방법으로 구현하려고 한다.

  1. 로그인 요청시 atk, rtk 발급.
    1. ) atk는 사용자 이름, 권한정보 포함하여 짧은 시간.
    2. ) rtk는 사용자 이름만 포함하여 비교적 긴 시간. Redis저장   (+24/03/13 다시보니 RTK에 사용자 이름도 필요없는듯)
  2. 평소 api요청시 atk만 사용
  3. atk만료시 atk와 rtk 함께 서버에 보냄.
  4. atk를 검증하고 atk에 담긴 유저네임으로 redis에 저장된 rtk를 찾음
  5. 전달 받은 rtk를 검증
  6. db의 rtk와 전달받은 rtk 비교
  7. atk를 새로 발급해줌. (rtk의 만료시간이 3일 이하라면 rtk도 함께 갱신)

(크게 의미가 있을지는 잘 모르겠지만ㅜ) 조금이라도 보안성을 높이기 위해 atk와 rtk를 모두 검증하고 db에 저장된 rtk를 검색할때는 atk에 담긴 정보로 검색하도록 만들어 atk와 rtk 둘다 필요하도록 했다.

 

이때 atk의 signature가 서버의 jwt키로 검증했을 때 정상적이면서 토큰의 유효기간은 만료된 상황임을 검사하고 payload를 가져와야 했는데 예외처리를 통해 구현해보았다.(아래에서 설명)

 

/*

redis를 DB로 사용하는 이유

인 메모리, 키-벨류 방식으로 빠른 속도

데이터의 만료시간 설정 가능

*/

 


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

redis를 사용하기 위해 의존성 추가

 

  • redisConfig
@Configuration
@EnableRedisRepositories
public class RedisConfig {

    private final String redisHost;
    private final int redisPort;

    public RedisConfig(@Value("${spring.redis.host}") final String redisHost,
                       @Value("${spring.redis.port}") final int redisPort) {
        this.redisHost = redisHost;
        this.redisPort = redisPort;
    }
    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    // setKeySerializer, setValueSerializer 설정으로 redis-cli를 통해 직접 데이터를 보는게 가능하다.
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}
 

redis를 처음 사용해봐 redis설정 파일은 인터넷을 참고했다.

(redis에 대해서는 나중에 추가로 공부해보자.)

 

  • JWTUtil
    public String generateToken(String userName,Collection<? extends GrantedAuthority> authorities) {
        // 권한 가져오기
        String authority = authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        String accessToken = Jwts.builder()
                .setSubject(userName)
                .claim("auth", authority)
                .setExpiration(Date.from(ZonedDateTime.now().plusDays(1).toInstant()))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return accessToken;
    }

    public String generateRefreshToken(String username) {
        long now = (new Date()).getTime();
        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setSubject(username)
                .setExpiration(Date.from(ZonedDateTime.now().plusDays(15).toInstant()))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return refreshToken;
    }
 

atk는 이름과 권한을 담아서 유효기간을 1일로, rtk는 이름만 담아 15일로 만들어 주도록 했다.

    public HashMap<Object,String> parseClaimsByExpiredToken(String accessToken) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
        } catch (ExpiredJwtException e) {
            try {
                String[] splitJwt = accessToken.split("\\.");

                Base64.Decoder decoder = Base64.getDecoder();
                String payload = new String(decoder.decode(splitJwt[1] .getBytes()));

                return new ObjectMapper().readValue(payload, HashMap.class);
            } catch (JsonProcessingException je) {
                log.error(je.getMessage());
                return null;
            }
        }
        return null;
    }
 

만료된 atk를 검증하고 payload를 가져오는 코드다.

 

Jwts.parserBuilder().setSigning

Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
 

원래 Jwt에서 제공해주는 기능으로 토큰의 값을 가져오기 위해서는 위와 같이 사용하는데, parseClaimsJws()과정에서 key를 통해 검증을 하게되고, 이때 기간이 만료된 상황 역시 예외를 터뜨린다. 그리고 기간은 신경쓰지 않고 서버에서 발급한 토큰이 맞는지만 검사하는 기능도 없다.

 

그래서 해당 코드의 내부를 살펴보았는데 아래와 같았다. (코드가 길어서 일부만 잘랐다.)

  • DefaultJwtParser
   @Override
    public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException, SignatureException {

..

        if (delimiterCount != 2) {
            String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
            throw new MalformedJwtException(msg);
        }
        if (sb.length() > 0) {
            base64UrlEncodedDigest = sb.toString();
        }

..

            if (algorithm == null || algorithm == SignatureAlgorithm.NONE) {
                //it is plaintext, but it has a signature.  This is invalid:
                String msg = "JWT string has a digest/signature, but the header does not reference a valid signature " +
                    "algorithm.";
                throw new MalformedJwtException(msg);
            }

            if (key != null && keyBytes != null) {
                throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
            } else if ((key != null || keyBytes != null) && signingKeyResolver != null) {
                String object = key != null ? "a key object" : "key bytes";
                throw new IllegalStateException("A signing key resolver and " + object + " cannot both be specified. Choose either.");
            }

..

            JwtSignatureValidator validator;
            try {
                algorithm.assertValidVerificationKey(key); //since 0.10.0: https://github.com/jwtk/jjwt/issues/334
                validator = createSignatureValidator(algorithm, key);
            } catch (WeakKeyException e) {
                throw e;
            } catch (InvalidKeyException | IllegalArgumentException e) {
                String algName = algorithm.getValue();
                String msg = "The parsed JWT indicates it was signed with the '" + algName + "' signature " +
                    "algorithm, but the provided " + key.getClass().getName() + " key may " +
                    "not be used to verify " + algName + " signatures.  Because the specified " +
                    "key reflects a specific and expected algorithm, and the JWT does not reflect " +
                    "this algorithm, it is likely that the JWT was not expected and therefore should not be " +
                    "trusted.  Another possibility is that the parser was provided the incorrect " +
                    "signature verification key, but this cannot be assumed for security reasons.";
                throw new UnsupportedJwtException(msg, e);
            }

            if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
                String msg = "JWT signature does not match locally computed signature. JWT validity cannot be " +
                    "asserted and should not be trusted.";
                throw new SignatureException(msg);
            }
        }

..

                if (max.after(exp)) {
                    String expVal = DateFormats.formatIso8601(exp, false);
                    String nowVal = DateFormats.formatIso8601(now, false);

                    long differenceMillis = maxTime - exp.getTime();

                    String msg = "JWT expired at " + expVal + ". Current time: " + nowVal + ", a difference of " +
                        differenceMillis + " milliseconds.  Allowed clock skew: " +
                        this.allowedClockSkewMillis + " milliseconds.";
                    throw new ExpiredJwtException(header, claims, msg);
                }
..
 

모두 자세히 해석하기는 어려웠지만 대충보기에도 상황별로 검증을 하고 예외처리가 되어있는데 그중 기간 만료 예외는 가장 밑에있다.

그리고 메서드 안에서 코드는 위에서 부터 아래로 진행된다.

 

        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
        } catch (ExpiredJwtException e) {
 

그래서 위와같이 코드를 작성해 expired상황만 예외를 받는다면 key검증은 정상적으로 통과하고 기간만료된 상황만 잡아낼수 있었다.

            try {
                String[] splitJwt = accessToken.split("\\.");

                Base64.Decoder decoder = Base64.getDecoder();
                String payload = new String(decoder.decode(splitJwt[1] .getBytes()));

                return new ObjectMapper().readValue(payload, HashMap.class);
            } catch (JsonProcessingException je) {
                log.error(je.getMessage());
                return null;
            }
 

정규표현식에서 특수문자를 매칭하기 위해서는 \\를 사용한다.

jwt의 구조는 header.payload.signature 로 이루어져있고 payload가 필요하기 때문에 .으로 나누어 두번째 값을 가져와 디코딩한다.

 

 

  • apiLoginSuccessHandler
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        log.info("Login Success Handler......................");

        String accessToken = jwtUtil.generateToken(authentication.getName(),authentication.getAuthorities());
        String refreshToken = jwtUtil.generateRefreshToken(authentication.getName());
        redisTemplate.opsForValue().set("RT:"+authentication.getName(),refreshToken, Duration.ofDays(15));

        TokenDTO tokenDTO = TokenDTO.builder().accessToken(accessToken).refreshToken(refreshToken).build();

        response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
        Gson gson = new Gson();

        String jsonStr = gson.toJson(tokenDTO);

        response.getWriter().println(jsonStr);

    }
 

로그인 성공 시 토큰을 발급할 때 rtk를 유저네임과 함께 redis에 저장한다.

 

  • refreshTokenFilter
..
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String path = request.getRequestURI();
        if(!path.equals(refreshPath)){
            log.info("skip refresh token filter .......");
            filterChain.doFilter(request,response);
            return;
        }
        log.info("Refresh Token Filter...run..................1");
        try {
            //전송된 json에서 accessToken과 refreshToken을 얻어온다
            Map<String,String> tokens = parseRequestJSON(request);

            String accessToken = tokens.get("accessToken");
            String refreshToken = tokens.get("refreshToken");
            HashMap<Object, String> claims = jwtUtil.parseClaimsByExpiredToken(accessToken); //만료된 atk를 검증하고 claim정보를 가져옴
            log.info(claims);

            jwtUtil.validateToken(refreshToken); //rtk 검증
            Claims refreshClaims = jwtUtil.parseClaims(refreshToken);
            String userName = claims.get("sub");

            String redisToken = (String) redisTemplate.opsForValue().get("RT:"+userName);
            if (!Objects.equals(redisToken, refreshToken)){  //atk의 userName으로 db에 저장된 rtk와 전달받은 rtk를 비교
                throw new TokenException(REFRESH_INVALID);
            }

            Date exp = refreshClaims.getExpiration();
            Date current = new Date(System.currentTimeMillis());

            //만료 시간과 현재 시간의 간격 계산
            //만일 3일 미만인 경우에는 Refresh Token도 다시 생성
            long gapTime = (exp.getTime() - current.getTime());
            if(gapTime < (1000 * 60 * 60 * 24 * 3  ) ){
                log.info("new Refresh Token required...  ");
                refreshToken = jwtUtil.generateRefreshToken(userName);
                redisTemplate.opsForValue().set("RT:"+userName,refreshToken, Duration.ofDays(15));
            }

            Member member = memberRepository.findByMemberId(userName).orElseThrow(()->{throw new MemberDoesNotExistException();});
            Collection<? extends GrantedAuthority> authorities = member.getRoleSet().stream()
                    .map(memberRole -> new SimpleGrantedAuthority("ROLE_"+memberRole.name()))
                    .collect(Collectors.toList());

            accessToken = jwtUtil.generateToken(userName,authorities);

            sendTokens(accessToken, refreshToken, response);
        }catch (TokenException e){
            e.sendResponseError(response);
        }catch (Exception e){
            throw new RuntimeException();
        }
    }
..
}
 

처음 설명한 과정대로 atk와 rtk를 검증하고 토큰을 갱신해준다.

 

 

 

위와 같은 과정으로 rtk를 사용하도록 리팩토링했고 일단 테스트 결과 예상대로 동작했고 토큰 갱신이 가능했다.

하지만 이런 로직으로 갱신하는게 효과적인 코드이고, 보안에 유의미하게 도움이 되는지는 확신이 없다.

멘토가 없고 이제 공부를 시작하는 입장에서 성능테스트를 해보기도 어려워 혼자 고민하고 그럴듯한 쪽으로 만들다 보니 정답을 알수없어 아쉬움이 남는다.

 

+ 기존에 이미 발급된 토큰을 막는 방법이 없어서, 비슷한 방법으로 접근을 막을 atk를 redis에 저장하는 흐름으로 로그아웃 기능을 추가했다.

 

jwt토큰 리팩토링 : https://devsungwon.tistory.com/entry/jwt%ED%86%A0%ED%81%B0-%EA%B4%80%EB%A0%A8-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81

소셜로그인 리팩토링 : https://devsungwon.tistory.com/entry/Oauth2-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81

 

728x90
반응형