RefreshToken 도입 배경

이전 포스팅에서 세션/쿠키 방식의 로그인에 문제점들을 해결하고자 Spring Security를 이용한 JWT 로그인을 구현해 보았다.
하지만 JWT 통한 로그인 방식에는 토큰 탈취라는 보안상의 큰 문제가 있었다.
공격자는 탈취한 토큰을 통해 서버와 통신을할 수 있고 서버는 해당 요청이 사용자의 정당한 요청인지 공격자의 요청인지 구분할 수 없었다.
따라서 토큰에 유효시간을 두어 토큰이 탈취되더라도 일정 시간이 지나면 공격자가 서버와 통신할 수 없게 만들어야 했다.
하지만 이 경우 사용자도 토큰이 만료되는 시점에 다시 로그인을 해야되는 불편함이 생기고 이를 방지하기위해 RefreshToken를 도입하기로 했다.

RefreshToken 도입 후 로그인 전략

Refresh Token를 도입한 서비스의 사용자 인증절차는 다음과 같다.

  • 사용자가 로그인을 하면 AccessToken과 RefreshToken를 발급해준다.
  • 서버는 데이터베이스에 두 토큰을 한 쌍으로 저장한다.
  • 사용자는 인가가 필요한 요청에 대해 AccessToken를 헤더에 실어 보냄으로써 서버로부터 인가를 받는다.
  • AccessToken이 만료된경우 사용자는 RefreshToken를 통해 서버에게 토큰 재발급 요청을 한다.
  • 토큰 재발급 요청을 받은 서버는 AccessToken과 RefreshToken 각각 새로 만들어 새로운 한 쌍을 데이터베이스에 저장하고 사용자에게 발급한다.

AccessToken과 RefreshToken를 이런식으로 관리한다면 공격자에게 RefreshToken이 탈취당하더라도 다음과 같이 대처가능하다.

  • 공격자가 RefreshToken를 통해 AccessToken를 발급 받으려는 경우 AccessToken의 유효기간이 만료되지 않았다면 서버에서는 RefreshToken이 탈취되었다고 간주하고 데이터베이스에서 해당 쌍의 Token를 삭제한다.
    이 경우 공격자와 사용자 모두 재로그인이 요구된다.
  • 공격자가 RefreshToken를 통해 AccessToken를 발급 받으려는 경우 AccessToken의 유효기간 또한 만료되었다면 공격자는 새로운 쌍의 토큰을 발급받을 수 있지만 사용자가 해당 서비스를 이용하는 순간 서버는 토큰 탈취를 감지할 수 있게된다.

전체적인 토큰을 이용한 로그인 전략은 스택오버플로우의 아래 링크를 참고하였다.
https://stackoverflow.com/questions/32060478/is-a-refresh-token-really-necessary-when-using-jwt-token-authentication

구현 코드

RefreshToken의 도입 이유와 전략에 대한 개략적인 설명은 끝났으니 이제 구현된 코드를 살펴보자
이번 포스팅에서는 이전 포스팅과 비교해서 RefreshToken를 도입하는 과정에서 달라진 코드만 살펴보겠다.

본격적으로 코드를 살펴보기 전에 먼저 AccessToken과 RefreshToken를 한 쌍으로 관리하기 위해 만든 Entity를 보자.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenPair {

    @Id @GeneratedValue
    private Long id;
    private String accessToken;
    private String refreshToken;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private User user;

    public static TokenPair createTokenPair(String accessToken, String refreshToken, User user) {
        TokenPair tokenPair = new TokenPair();
        tokenPair.accessToken = accessToken;
        tokenPair.refreshToken = refreshToken;
        tokenPair.user = user;
        return tokenPair;
    }

    public void updateToken(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}

RefreshToken를 도입하면서 이전 포스팅과 달라진 부분은 크게 세 가지 이다.

  • Authentication 과정을 시큐리티 필터에서 MVC계층으로 옮김
  • 토큰의 효율적인 생산 및 관리를 위해 JwtUtils클래스 생성
  • refreshToken를 통한 토큰 갱신요청인 POST: /refresh 요청을 받는 controller 및 service 메소드 추가

먼저 AuthenticationFilter와 Authentication 성공 or 실패시 동작하는 AuthenticationSuccessHandlerCustom과 AuthenticationFailureHandlerCustom를 제거하고 추가한 코드를 살펴보자

@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {
    // ...
    @PostMapping("/login")
    public LoginResponse login(@Valid @RequestBody LoginRequest request) {
        return userService.login(request);
    }
    // ...
}
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
    private final JwtUtiles jwtUtiles;
    private final AuthenticationManager authenticationManager;
    private final TokenPairRepository tokenPairRepository;
    // ...
    public LoginResponse login(LoginRequest request) {
        // authenticationManager 이용한 아이디 및 비밀번호 확인
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getLoginId(), request.getPassword()));

        // 인증된 객체 생성
        PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();

        // 인증 객체를 통해 tokenPair 생성
        String jwt = jwtUtils.createAccessToken(authentication);
        String refresh = jwtUtils.createRefreshToken(authentication);
        TokenPair tokenPair = TokenPair.createTokenPair(jwt, refresh, principal.getUser());

        // 기존 토큰이 있으면 수정, 없으면 생성
        tokenPairRepository.findByUserId(principal.getUser().getId())
                .ifPresentOrElse(
                        (findTokenPair) -> findTokenPair.updateToken(jwt, refresh),
                        () -> tokenPairRepository.save(tokenPair)
                );

        LoginResponse response = principal.getUser().getLoginInfo();
        response.setAccessToken(jwtUtils.addPrefix(jwt));
        response.setRefreshToken(jwtUtils.addPrefix(refresh));
        return response;
    }
    // ...
}

이렇게 수정한 이유는 Authentication 부분을 security 필터에서 MVC계층으로 옮겨 Controller와 Service에 나누어 구현하기 위해서였다.
이전부터 필터에서의 데이터베이스 조회 부분이 부자연스럽다고 생각했었지만, 단순 조회여서 transaction를 열지 않아도 되고 findById 수준의 조회는 성능 이슈에도 크게 영향을 미치지 않을것 같아 그대로 진행했었다.
하지만 RefreshToken를 도입함으로써 Authentication 과정에서 transaction를 열어 dirty checking를 통한 update나 save 등
높은 수준의 데이터베이스 상호작용이 요구되었기 때문에 해당 기능을 service 계층으로 옮기는것이 좀 더 자연스러워 보였다.
여기서 주의할 점은 authenticationManger.authenticate()를 통해 사용자 인증시 인증에 통과하지 못하면 예외를 던지게 되는데,
해당 예외는 AuthenticationEntryPointCustom에서 처리하기 때문에 해당 service에서는 신경쓰지 않아도 된다.

두 번째 큰 변화는 위 코드에서도 쓰인 JwtUtils 클래스의 추가이다. 코드는 아래와 같다.

@Component
@RequiredArgsConstructor
public class JwtUtils {

    // HMAC512의 시크릿키
    @Value("${...}")
    private String secretKey;

    // accessToken 유효시간
    @Value("${...}")
    private int jwtExpirationInMs;

    // refreshToken 유효시간
    @Value("${...}")
    private int refreshExpirationDateInMs;
    // accessToken 생성 메서드
    public String createAccessToken(Authentication authentication) {
        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
        String token = JWT.create()
                .withSubject("ILuvIt_AccessToken")
                .withExpiresAt(new Date(System.currentTimeMillis() + jwtExpirationInMs * 1000L))
                .withClaim("id", userDetails.getUser().getId())
                .sign(Algorithm.HMAC512(secretKey));
        return token;
    }
    // refreshToken 생성 메서드
    public String createRefreshToken(Authentication authentication) {
        PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
        String token = JWT.create()
                .withSubject("ILuvIt_RefreshToken")
                .withExpiresAt(new Date(System.currentTimeMillis() + refreshExpirationDateInMs * 1000L))
                .withClaim("id", userDetails.getUser().getId())
                .sign(Algorithm.HMAC512(secretKey));
        return token;
    }
    // 토큰에서 사용자 id 추출
    public Long getUserIdFromToken(String token) {
        DecodedJWT jwt = JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
        return jwt.getClaim("id").asLong();
    }
    // 토큰이 만료됐는지 check
    public Boolean isExpired(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
            return false;
        } catch (TokenExpiredException e) {
            log.warn("[JwtVerificationException] token 기간 만료 : {}", e.getMessage());
            return true;
        } catch (JWTVerificationException e) {
            log.warn("[JWTVerificationException] token 파싱 실패 : {}", e.getMessage());
            return false;
        }
    }
    // 토큰이 유효한지 check
    public void validateToken(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
        } catch (JWTVerificationException e) {
            log.warn("[JWTVerificationException] token 파싱 실패 : {}", e.getMessage());
            throw e;
        }
    }
    // 토큰 타입 명시
    public String addPrefix(String token) {
        return "Bearer " + token;
    }
}

JwtUtils의 각 메서드의 역할은 주석으로 명시되어있다. 해당 클래스는 @Component를 이용하여 bean으로 등록함으로써 다른 빈에게 주입할 수 있게끔 했다.

마지막으로 refreshToken를 갱신하는 부분이다.

// ...
public class UserController {
    // ...
    @PostMapping("/refresh")
    public LoginResponse refresh(@Valid @RequestBody TokenRefreshRequest request) throws IOException {
        LoginResponse response = userService.refresh(request);
        if (response != null) {
            return response;
        } else {
            throw new JWTVerificationException("유효하지 않은 시도입니다.");
        }
    }
    // ...
}
// ...
public class UserService {
    // ...
    public LoginResponse refresh(TokenRefreshRequest request) {

        String requestRefreshTokenToken = request.getRefreshToken().replace("Bearer ", "");

        // 요청으로 받은 refreshToken 유효한지 확인
        jwtUtils.validateToken(requestRefreshTokenToken);

        // 이전에 받았던 refreshToken과 일치하는지 확인(tokenPair 유저당 하나로 유지)
        Long userId = jwtUtils.getUserIdFromToken(requestRefreshTokenToken);
        TokenPair findTokenPair = tokenPairRepository.findByUserIdWithUser(userId)
                .orElseThrow(() -> new JWTVerificationException("유효하지 않은 토큰입니다."));

        if (!requestRefreshTokenToken.equals(findTokenPair.getRefreshToken())) {
            throw new JWTVerificationException("유효하지 않은 토큰입니다.");
        }

        // 이전에 발급했던 AccessToken 만료되지 않았다면 refreshToken 탈취로 판단
        // TokenPair 삭제 -> 다시 로그인 해야됨
        if (jwtUtils.isExpired(findTokenPair.getAccessToken())) {
            // refreshToken 유효하고, AccessToken 정상적으로 Expired 상태일때
            PrincipalDetails principal = new PrincipalDetails(findTokenPair.getUser());
            Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());

            String jwt = jwtUtils.createAccessToken(authentication);
            String refresh = jwtUtils.createRefreshToken(authentication);
            findTokenPair.updateToken(jwt, refresh);

            LoginResponse response = principal.getUser().getLoginInfo();
            response.setAccessToken(jwtUtils.addPrefix(jwt));
            response.setRefreshToken(jwtUtils.addPrefix(refresh));
            return response;

        } else {
            // accessToken이 아직 만료되지 않은 상태 -> 토큰 탈취로 판단 -> delete tokenPair
            tokenPairRepository.delete(findTokenPair);
            return null;
        }
    }
    // ...
}

refreshToken과 함께 POST /refresh 요청을 받으면 먼저 요청으로 온 refreshToken의 유효성을 검사한다.
유효성 검사에 통과하지 못한다면 예외를 던지고 해당 예외는 ControllerAdvice에서 ExceptionHandler를 통해 client에게 401에러(Unauthorized)를 응답으로 보낸다.
해당 절차는 데이터베이스의 tokenPair와 비교해서 불일치 할때도 마찬가지로 적용된다.
요청이 데이터베이스에 저장된 정보와도 일치하는지까지 확인했다면,
이제는 요청으로 온 refreshToken과 쌍을 이루는 accessToken의 유효시간이 만료되었는지 검사한다.
이때 아직 accessToken이 만료되지 않았다면 데이터베이스의 해당 tokenPair를 지우고 예외를 던져 사용자가 다시 로그인 하게끔 작동해야한다.
여기서 주의를 기울였던 부분은 만약 delete(findTokenPair) 다음에 예외를 바로 던지게 되면
transaction이 롤백되어 데이터베이스에 기존 정보가 남아있게 된다는 것이였다.
따라서 service계층에서는 accessToken이 만료된 경우에만 response를 return 하고 accessToken이 만료되지 않은경우
데이터베이스에 delete(findTokenPair)를 실행 한 후 null를 return하여 controller에서 예외를 던지게끔 설계하였다.

이상으로 spring security를 이용한 JWT 로그인에 RefreshToken 도입 과정이였다.

'Spring' 카테고리의 다른 글

[spring] spring security를 이용한 JWT 로그인  (0) 2022.07.28
[spring] 관심사의 분리  (0) 2022.06.14

+ Recent posts