ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 토큰 인증과 권한체크를 Spring Cloud Gateway로 분리하기
    Project 2024. 9. 7. 17:09

     

     

    현재 시스템은 DeliveryApp에 Spring security 의존성을 추가하여 구현한 필터 두개를 필터체인에 등록해놨다. 각 필터들의 역할은 다음과 같다.

     

    JwtAutehnticationFilter
    - UsernamePasswordAuthenticationFilter 구현체, 로그인 기능 담당

    JwtAuthorizationFilter
    - HttpServletRequest의 Authorization 헤더에서 accessToken을 읽어 토큰 검증 및 claims에 넣은 email을 추출
    - 추출한 email로 DB에서 유저정보를 가져와 Servlet에서 쓸 수 있도록 SpringContextHolder에 저장

     

     

    로그인, 회원가입의 엔드포인트인 /auth로 시작하는 URL들은 Auth 정보가 없기 때문에 JwtAuthorizationFilter는 통과된다.

     

    현재 프로젝트의 추가 요구사항은 토큰 검증 및 권한확인을 Gateway로 분리하는 것이며, 게이트웨이에서의 권한 확인은 캐싱된 로그인한 유저의 권한정보를 가져와 검증하는 것으로 진행해야 했다.

     

    해당 요구사항을 주말동안 반영했어야 했기 때문에 최대한 기존의 틀을 유지하면서 개발을 진행했다. 

     

     

     

    🧨 Spring Cloud Gateway에서 Spring Security 의존성 문제 봉착 

     

     처음엔 단순히 gateway에 구현한 인증코드들만 다 옮기면 되는거 아닌가...? 라고 생각했었다. 하지만 Spring Cloud Gateway는 Spring Web(Servlet)이 아닌 Spring Webflux기반이다. 블로킹 방식의 Serlvet과 달리 논블로킹 API로 구성되고, 비동기-논블로킹 방식을 지원한다. DispatcherServlet이 아닌 DispatcherHandler가 사용되고 따라서 API도 Servelt API들이 아니다. 따라서 이전 코드를 그대로 사용할 수는 없다. 

     

    또한 Spring webflux 의존성과 Spring Security 의존성을 동시에 사용하니 특정 구현체가 없다는 에러가 계속해서 발생했다. Spring Security가 Servlet 기반으로 만들어진것이기 때문에 사용하려면 Webfulx security를 써야하는것 같았다. 

     

    따라서 최대한 기존의 코드를 그대로 유지하면서, 인증과 권한체크를 게이트웨이에서 진행하기 위해 다음과 같이 분리해서 구현했다.  

     

    1. Spring Cloud Gateway에서는 jjwt 의존성만 추가하여 토큰 검증, 파싱 및 권한 체크들을 수행하는 필터를 게이트웨이에 추가한다.
    2. 로그인 필터(JwtAutehnticationFilter)는 그대로 기존 서버로 유지하여 /auth 엔드포인트들은 게이트웨이 필터를 거치지 않고 통과하고 사용자 신원인증을 검증하는 절차는 기존서버에서 진행한다. 
    3. 이미 모든 컨트롤러들이 @AuthenticationPrinciple을 통해 UserDetails를 사용하는 방식이기 때문에 기존의 JwtAuthorizationFilter에는 DB에서 유저정보를 가져와 Spring ContextHolder에 저장하는 역할만 수행되도록 수정한다.

     

     

    시퀀스 다이어그램으로 단계를 설명하면 다음과 같다.

     

     

     로그인 성공시 attemptAuthentication 메소드는 Authentication 객체를 생성하여 UserDetilsService 구현체와 등록된 PasswordEncoder 빈으로 관련 유저를 찾아 인증을 수행하도록 할 것이다. 이제 게이트웨이에서 로그인한 유저의 권한을 캐싱된 것을 확인해야하기 때문에 해당 메소드 마지막 단계에 캐싱 로직을 추가했다. 

     

     

     

     

    auth 이외의 엔드포인트들은 토큰 검증과 권한을 게이트웨이에서 수행해야한다. 따라서 GlobalFilter를 구현한 필터를 다음과 같이 작성했다. 

     

    // import 생략 
    @Component
    @Slf4j(topic = "UserRoleValidationFilter ")
    public class UserRoleValidationFilter implements GlobalFilter {
    
      public static final String AUTH_URI = "auth";
      public static final String AUTHORIZATION_HEADER = "Authorization";
      public static final String BEARER_PREFIX = "Bearer ";
      private final RedisComponent redisComponent;
      @Value("${jwt.secret-key}")
      private String secret;
    
      public UserRoleValidationFilter(RedisComponent redisComponent) {
        this.redisComponent = redisComponent;
      }
    
      public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String uri = exchange.getRequest().getURI().getPath().split("/")[1];
        HttpMethod method = exchange.getRequest().getMethod();
        log.info("uri : {}", uri);
    
        // 인증이 필요한 URI 경로인지 확인
        if (uri.equals(AUTH_URI)) {
          return chain.filter(exchange); // 인증이 필요 없는 경우, 다음 필터로 진행
        }
    
        // 1. JWT 토큰 검증
        String token = resolveToken(exchange.getRequest());
        log.info("{}", token);
    
        try {
          String email = getSignature(token);
          log.info("User signature: {}", email);
    
          // 2. 요청 API의 접근 권한과 로그인한 유저의 권한을 가지고 있는 Redis에서 조회한 권한과 동일한지 검증
          UserAuthorityResponseDto userAuthority = redisComponent.getUserAuthorityFromRedis(email)
              .orElseThrow(() -> new DeliveryApplicationException(ErrorCode.NOT_FOUND_USER));
    
          if (hasPermission(uri, method, userAuthority.getRole())) {
            exchange.getRequest().mutate()
                .header("requestedUserEmail", email)// 추후 Order Server에서 해당 email로 유저정보를 가져와야하기 때문에 requst 헤더에 넣어준다
                .build();
    
            return chain.filter(exchange); // 필터 체인의 다음 단계로 진행
          } else {
            return Mono.error(new DeliveryApplicationException(ErrorCode.NOT_PERMISSION));
          }
        } catch (Exception e) {
          return Mono.error(e);
        }}
    
      private String getSignature(String token) {
        try {
          Claims claims =
              Jwts.parserBuilder()
                  .setSigningKey(getKey(secret))
                  .build()
                  .parseClaimsJws(token)
                  .getBody();
          return claims.get("email", String.class);
        } catch (ExpiredJwtException e) {
          throw new DeliveryApplicationException(ErrorCode.EXPIRED_TOKEN);
        } catch (SecurityException
                 | MalformedJwtException
                 | SignatureException
                 | UnsupportedJwtException
                 | IllegalArgumentException e) {
          throw new DeliveryApplicationException(ErrorCode.INVALID_TOKEN);
        }
      }
    
      private static Key getKey(String secretKey) {
        byte[] keyByte = secretKey.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyByte);
      }
    
      private String resolveToken(ServerHttpRequest request) {
        String token = request.getHeaders().getFirst(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
          return token.split(" ")[1].trim();
        }
        throw new DeliveryApplicationException(ErrorCode.NOT_AUTH);
      }
    }

     

    토큰 검증에 통과하면 권한체크를 수행하게 되는데, 이때 캐시인 redis에 접근하여 유저권한 정보를 가져와 요청 URI의 접근권한이 있는지 확인하는 과정을 거친다.

     

    API별 접근권한은 현재 게이트웨이에서 UserAuthorityGroup이라는 enum 클래스로 엔드포인트 별, HttpMethod 별로 접근권한들을 정의해놨다. 기존의 Order Server에 있던 JwtAuthorizationFilter는 UserAuthorization필터로 이름을 변경하여 다음과 같이 수정했다.

     

      @Override
      protected void doFilterInternal(
          HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
          throws ServletException, IOException {
        try {
          String email = request.getHeader("requestedUserEmail");// 게이트웨이에서 저장한 이메일 사용
          log.info("requested User email: {}", email);
          if (email == null) throw new DeliveryApplicationException(ErrorCode.INVALID_TOKEN);
    
          UserDetails userDetails = userDetailService.loadUserByUsername(email);
    
          UsernamePasswordAuthenticationToken authenticationToken =
              new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
          authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
          SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    
        } catch (RuntimeException exception) {
          log.error("occurs exception in UserAuthorizationFilter");
          request.setAttribute("exception", exception);
        }
    
        filterChain.doFilter(request, response);
      }

     

     

    🙋🏻‍♀️ 지금 방식의 문제점과 앞으로 해결할 수 있는 방안에 대해서 생각해보기

     

     

     사실 인증은 인증서버를 따로 둬서 처리하고 게이트웨이는 라우팅과 트래픽 처리만 담당하도록 가볍게 가져가야 한다. 많은 트래픽 상황에서 곧 게이트웨이가 병목지점이 되고 결과적으로 분리한것의 장점을 못가져갈 수 있기 때문이다. 

     

     지금 방식은 토큰 검증에서 문제가 발생하거나 접근이 제한된 유저의 요청인 경우, 바로 게이트웨이에서 예외처리를 해버리기 때문에 기존에 Order Server에서 했던 리소스를 줄여줄 수는 있겠다. 빠른 차단으로 보안적으로도 이점이 있다. 

     

     하지만 API별 접근권한을 게이트웨이에서 관리하게 되면서 게이트웨이와 서버간의 강한 결합이 발생하게 되었다. 앞으로 Order Server에 특정 API가 추가될때마다 해당 권한을 게이트웨이에서 입력해주기 위해 수정을 해야하고, 기존 API 별 권한을 수정하게 되면서 Order Server가 그 영향을 받게 되었다. 분리를 했는데 라이프사이클이 비슷하게 가는 것이다. 서비스가 완전히 독립적이지 못하고, 유지보수 측면에서도 어렵게 됐다. 

     

     

    따라서 충분한 시간을 가지고 작업한다면 다음과 같이 인증서버를 만들어서 분리하고 싶다.

    1. 게이트웨이는 라우팅만 수행한다. 라우팅 설정에서 auth 서버의 path를 /**로 설정해서 일단 모든 요청은 auth로 가게끔하고 filters 설정을 통해 /auth/redirect로 요청을 바꾼다. 그리고 auth 외의 각 엔드포인트 별로 서버정보를 설정한다. 
    2. auth 서버에서는 로그인 회원가입이 아닌 모든 요청은 토큰 검증 필터를 통과하게 만들어 토큰 예외는 해당 필터에서 처리하도록 한다. 
    3. auth 서버에 /auth/redirect API를 추가하여 여기서 리다이렉트 요청을 보낸다.
    4. 게이트웨이에는 리다이렉트 302 요청이 왔을시 Location정보를 통해 본래 uri요청을 확인하고 본래의 엔드포인트 서비스로 가게끔 라우팅해준다 

     

    이렇게 게이트웨이는 가볍게 가져가고 인증 관련한 코드는 인증서버에서 처리하게끔.. 🤔 계획은 이런데 실제로 해봐야한다. 프로젝트 기간 끝나고 시간 남을때 도전해봐야겠다

     

     

     

     

     

     

     

Designed by Tistory.