[Spring]스프링 시큐리티- AccessToken 재발급 구현_4 (feat. RefreshToken, 보안 경험)

cielo ru·2024년 5월 27일
0

Spring

목록 보기
5/9
post-thumbnail

이번에는 인증, 인가에 이어서 RefreshToken으로 AccessToken을 재발급하는 과정에 대해서 적어보려 한다.


➰ 서론

이전까지 구현했던 흐름을 살펴보자.

  1. 사용자가 아이디와 비밀번호를 입력하여 로그인을 요청한다.

  2. 서버는 DB를 조회해 아이디와 비밀번호가 일치하면 인증이 성공하며 AccessToken과 RefreshToken을 발급하여 클라이언트에게 반환한다.

  3. 클라이언트가 데이터가 필요할 때마다 AccessToken을 헤더에 담아 함께 요청한다.

  4. 서버는 AccessToken이 유효한지 검증하고 유효하다면 요청데이터를 담아 응답한다.

  5. 클라이언트가 요청을 할 때 AccessToken이 만료되었다면 서버는 만료되었다는 에러를 보낸다.

  6. 클라이언트는 만료 신호가 오면 로컬에 저장했던 RefreshToken을 함께 담아 AccessToken 재발급 요청을 한다.

  7. Redis에 저장되어 있는 RefreshToken과 클라이언트에서 보낸 RefreshToken이 일치하다면 새로운 AccessToken을 발급한다.

즉, 서버가 AccessToken을 발급하면 서버에 저장되지 않고 토큰에 대한 제어를 잃는다.(Stateless) 그러면 그 토큰이 탈취된다면 누구든 내 정보를 열람할 수 있는거 아닌가?


➰ 보안 회사에서의 경험

추가로 보안 회사에서 인턴으로 근무할 당시 경험했던 일을 적어보겠다. 인턴으로 근무하며 주로 모의해킹을 했었는데, 가장 많이 하는 일이 패킷을 잡아 취약점이 없는지 점검하는 일이었다. 중요 정보가 그대로 노출되진 않는지, 조작하였을 때 충분한 인증을 하는지를 확인했다.

그때 기본적으로 헤더에 담겨있던 것이 토큰이었다. 토큰의 경우 secret으로 만들어지기 때문에 조작 가능성은 없지만 헤더에 노출이 되어 있기 때문에 토큰을 가지고 중요 정보를 열람할 수 있는 가능성은 있었다.

실제로 토큰을 탈취하여 로그인을 안한 상태에서 어떠한 경로에 접근하면 접근이 되는 경우가 있었다. 물론 AccessToken 만 있었기 때문에 시간이 지나면 접근이 되지 않겠지만 토큰을 가진 누군가는 언제든지 권한 인증이 가능하고 중요한 정보에 접근이라도 한다면 큰 취약점으로 이어질 것이다.

해킹에 성공하며 특정 경로에 접근을 해보니, 토큰의 유효시간과 검증이 얼마나 중요한지 알게 되었다.

내 나름대로 토큰의 stateless한 점과 보안을 둘 다 잡고 싶었고 어떻게 해결해 나갔는지도 블로그를 통해 작성할 예정이다.


맞다.

AccessToken이 탈취되면 토큰이 만료되기 전까지 토큰을 가진 사람은 누구나 권한 인증이 가능하다.

이러한 문제점을 보완하기 위해서 액세스 토큰의 만료 기간을 짧게 주는 방식이 적용되고 있고 필자 또한 AccessToken 만료 시간을 30분으로 짧게 주었다.


➰ RefreshToken 사용 이유

AccessToken의 유효시간이 짧으면 유효시간이 지날때마다 AccessToken을 발급받기 위해 로그인을 해야 한다. 로그인을 30분 마다 하면 사용자는 매우 불편하다.

이러한 이유로 RefreshToken이 사용되었다.

JWT에서 보안도 중요하지만 사용자의 편의도 중요하다.
RefreshToken은 보안과 편의 둘 사이에서 타협한 결과라고 할 수 있다.

RefreshToken은 서버의 redis에 저장되며, 유효성을 검증하고 AccessToken이 만료될 때마다 자동으로 갱신시키는 역할을 한다. 리프레쉬 토큰은 Access token에 비해 훨씬 더 긴 유효 기간(필자의 경우 2일)으로 발급되며, 리프레시 토큰의 경우 접근에 대한 권한을 가진 것이 아니라 액세스 토큰 재발급에만 사용된다는 특징이 있다.

그 결과, Refresh Token 가 있어 Access Token이 만료되더라도 사용자는 로그인하지 않고도 API를 사용할 수 있다.


➰ RefreshToken 문제점과 대처방안

➰ 문제점

  • RefreshToken도 탈취될 수 있다.
    RefreshToken이 탈취된다면 RefreshToken으로 AccessToken을 재발급 받아 인증에 사용될 수 있다. 그렇게 때문에 적절한 대처 방안을 구현해야한다.

➰ 대처방안

  • 토큰을 탈취한 것을 감지하고, 서버 측에서 해당 토큰을 무효화
    : 로그인 시, redis를 사용하여 리프레시 토큰을 저장하고 로그인 요청 ip를 함께 저장하여 추후 재발급 요청이 왔을 때, 요청이 온 ip와 저장된 ip를 비교한다.
    : IP가 다른 경우 토큰을 재발급하지 않거나 알림을 보내는 등의 조치를 취한다.

  • 토큰의 만료 시간을 짧게 설정
    : 리프레쉬 토큰도 탈취될 수 있으므로 리프레쉬 토큰의 만료시간을 짧게 설정한다.(필자의 경우 2일)

  • 로그아웃 시에 블랙리스트에 Refresh Token을 저장
    : 로그아웃 후 리프레쉬 토큰이 탈취되어 사용될 수 있으므로 블랙리스트에 RefreshToken을 저장하여 토큰 사용을 무효화한다.


➰ RefreshToken 구현

이제 RefreshToken으로 AccessToken을 재발급 하는 로직을 구현해보자.

토큰 발급 구현

토큰 생성 및 검증은 이전 블로그에서 구현을 했으므로 이전 블로그를 참고하자.

코드를 보면 액세스 토큰을 만들 때 리프레쉬 토큰도 함께 만들어서 저장한 것을 확인할 수 있다.

       String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setExpiration(refreshTokenExpireDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        redisServiceImpl.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_VALID_TIME));

        return JWTAuthResponse.builder()
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .tokenType(BEARER)
                    .accessTokenExpireDate(ACCESS_TOKEN_VALID_TIME)
                    .build();

그 결과, tokenType, accessToken, refreshToken, accessTokenExpireDate가 반환된다.

이제 넘겨받은 클라이언트가 액세스 토큰을 어떻게 재발급 하는지를 살펴보자.

➰ UserController

클라이언트에서 토큰이 만료되었다는 예외를 받으면, /reissue 경로로 RefreshToken을 담아 요청한다.

    //토큰 재발급
    @PatchMapping("/reissue")
    public ResponseEntity<JWTAuthResponse> reissue(HttpServletRequest request,
                                  HttpServletResponse response) {

        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
        JWTAuthResponse newAccessToken = userService.reissueAccessToken(refreshToken);
        return ResponseEntity.ok(newAccessToken);
    }
  • 토큰 재발급은 기존 리소스(토큰)에서 일부 속성(액세스 토큰)만 변경하는 것이므로, 부분 업데이트를 의미하는 PATCH가 더 적합하여 @PatchMapping을 사용하였다.

➰ JwtTokenProvider

Request Header에서 RefreshToken 정보를 추출한다.

    // Request Header에 Refresh Token 정보를 추출하는 메서드
    public String resolveRefreshToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Refresh");
        if (StringUtils.hasText(bearerToken)) {
            return bearerToken;
        }
        return null;
    }

➰ UserServiceImpl

추출된 리프레쉬 토큰으로 액세스 토큰을 재발급한다.

    @Override
    public JWTAuthResponse reissueAccessToken(String refreshToken) {
        this.verifiedRefreshToken(refreshToken);
        String email = jwtTokenProvider.getEmail(refreshToken);
        String redisRefreshToken = redisService.getValues(email);

        if (redisService.checkExistsValue(redisRefreshToken) && refreshToken.equals(redisRefreshToken)) {
            Optional<UserEntity> findUser = this.findOne(email);
            UserEntity userEntity = UserEntity.of(findUser);
            JWTAuthResponse tokenDto = jwtTokenProvider.generateToken(email, jwtTokenProvider.getAuthentication(refreshToken), userEntity.getId());
            String newAccessToken = tokenDto.getAccessToken();
            long refreshTokenExpirationMillis = jwtTokenProvider.getRefreshTokenExpirationMillis();
            return tokenDto;
        } else throw new BusinessLogicException(ExceptionCode.TOKEN_IS_NOT_SAME);
    }

    private void verifiedRefreshToken(String refreshToken) {
        if (refreshToken == null) {
            throw new BusinessLogicException(ExceptionCode.HEADER_REFRESH_TOKEN_NOT_EXISTS);
        }
    }
    
    @Override
    public Optional<UserEntity> findOne(String email) {
        return userRepository.findByEmail(email);
    }
  • verifiedRefreshToken(String refreshToken) : 추출된 리프레쉬 토큰이 여부를 검증한다.

  • jwtTokenProvider.getEmail(refreshToken) : 요청으로 온 리프레쉬 토큰에서 저장된 리프레쉬 토큰의 key인 이메일 정보를 추출한다.

  • redisService.getValues(email) : 추출된 이메일 정보로 redis 에 저장된 리프레쉬 토큰을 꺼낸다.

  • if (redisService.checkExistsValue(redisRefreshToken) && refreshToken.equals(redisRefreshToken)) ~ : Redis에 저장된 리프레쉬 토큰과 요청으로 온 리프레쉬 토큰이 일치하다면 새로운 액세스 토큰을 생성하고 그렇지 않으면 TOKEN_IS_NOT_SAME 예외를 반환한다.

➰ RedisService

RefreshToken을 저장할 RedisService를 작성한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, Object> redisTemplate;

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

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

    @Transactional(readOnly = true)
    public String getValues(String key) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        if (values.get(key) == null) {
            return "false";
        }
        return (String) values.get(key);
    }

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

    public void expireValues(String key, int timeout) {
        redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS);
    }

    public void setHashOps(String key, Map<String, String> data) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.putAll(key, data);
    }

    @Transactional(readOnly = true)
    public String getHashOps(String key, String hashKey) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        return Boolean.TRUE.equals(values.hasKey(key, hashKey)) ? (String) redisTemplate.opsForHash().get(key, hashKey) : "";
    }

    public void deleteHashOps(String key, String hashKey) {
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.delete(key, hashKey);
    }

    public boolean checkExistsValue(String value) {
        return !value.equals("false");
    }

액세스 토큰이 만료된 후 refreshToken으로 재발급 요청을 하면 토큰이 재발급 되는 것을 확인할 수 있다.


다음 게시글에서는 리프레쉬 토큰의 한계를 어떻게 해결하였는지 포스팅 하도록 하겠습니다.


➰ 참고

https://wildeveloperetrain.tistory.com/245

https://engineerinsight.tistory.com/232

https://green-bin.tistory.com/76

profile
Cloud Engineer & BackEnd Developer

0개의 댓글