MSA(2)

김관주·2024년 8월 1일

6. API 게이트웨이 (Spring Cloud Gateway)

6.1 API 게이트웨이 개요

6.1.1 API 게이트웨이란?

  • API 게이트웨이는 클라이언트의 요청을 받아 백엔드 서비스로 라우팅하고, 다양한 부가 기능을 제공하는 중간 서버입니다.

6.1.2 API 게이트웨이의 주요 기능

  • 라우팅, 인증 및 권한 부여, 로드 밸런싱, 모니터링 및 로깅, 요청 및 응답 변환

6.2 Spring Cloud Gateway 개요

6.2.1 Spring Cloud Gateway란?

  • Spring Cloud Gateway는 Spring 프로젝트의 일환으로 개발된 API 게이트웨이로, 클라이언트 요청을 적절한 서비스로 라우팅하고 다양한 필터링 기능을 제공합니다.

6.3 Spring Cloud Gateway 필터링

6.3.1 필터 종류

  • Global Filter: 모든 요청에 대해 작동하는 필터
  • Gateway Filter: 특정 라우트에만 적용되는 필터

6.4.2 필터 구현

  • 필터를 구현하려면 GlobalFilter 또는 GatewayFilter 인터페이스를 구현하고, filter 메서드를 오버라이드해야 합니다.

6.4.2 필터 구현

  • 필터를 구현하려면 GlobalFilter 또는 GatewayFilter 인터페이스를 구현하고, filter 메서드를 오버라이드해야 합니다.

6.4.3 필터 주요 객체

  • Mono
    • Mono는 리액티브 프로그래밍에서 0 또는 1개의 데이터를 비동기적으로 처리합니다.
    • Mono<Void>는 아무 데이터도 반환하지 않음을 의미합니다.
  • ServerWebExchange
    • ServerWebExchange는 HTTP 요청과 응답을 캡슐화한 객체입니다.
    • exchange.getRequest()로 HTTP 요청을 가져옵니다.
    • exchange.getResponse()로 HTTP 응답을 가져옵니다.
  • GatewayFilterChain
    • GatewayFilterChain은 여러 필터를 체인처럼 연결합니다.
    • chain.filter(exchange)는 다음 필터로 요청을 전달합니다.

6.4.4 필터 시점별 종류

  • Pre 필터 Pre 필터는 요청이 처리되기 전에 실행됩니다. 따라서 Pre 필터에서는 요청을 가로채고 필요한 작업을 수행한 다음, 체인의 다음 필터로 요청을 전달합니다. 이때, 추가적인 비동기 작업을 수행할 필요가 없기 때문에 then 메서드를 사용할 필요가 없습니다.
    @Component
    public class PreFilter implements GlobalFilter, Ordered {
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            // 요청 로깅
            System.out.println("Request: " + exchange.getRequest().getPath());
            return chain.filter(exchange);
        }
    
        @Override
        public int getOrder() {  // 필터의 순서를 지정합니다.
            return -1;  // 필터 순서를 가장 높은 우선 순위로 설정합니다.
        }
    }
  • Post 필터 Post 필터는 요청이 처리된 후, 응답이 반환되기 전에 실행됩니다. Post 필터에서는 체인의 다음 필터가 완료된 후에 실행되어야 하는 추가적인 작업을 수행해야 합니다. 따라서 chain.filter(exchange)를 호출하여 다음 필터를 실행한 후, then 메서드를 사용하여 응답이 완료된 후에 실행할 작업을 정의합니다.
@Component
    public class PostFilter implements GlobalFilter, Ordered {
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                // 응답 로깅
                System.out.println("Response Status: " + exchange.getResponse().getStatusCode());
            }));
        }
    
        @Override
        public int getOrder() {
            return -1;
        }
    }

6.6 Zuul(Spring Boot 2)

Spring Boot2에서는 Zuul을 사용한다고 한다. 기본적인 구조만 알아가자

6.6.1 Zuul 설정

  • Spring Boot 2에서는 Zuul을 사용하여 API 게이트웨이를 설정할 수 있습니다.
  • build.gradle 파일 예시:
    
    dependencies {
        implementation 'org.springframework.cloud:spring-cloud-starter-netflix-zuul'
        implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    }    
  • Spring Boot 애플리케이션 설정:
    
    @SpringBootApplication
    @EnableZuulProxy
    public class ApiGatewayApplication {
        public static void main(String[] args) {
            SpringApplication.run(ApiGatewayApplication.class, args);
        }
    }
    

6.6.2 라우팅 설정

  • application.yml 파일에서 라우팅 설정을 정의할 수 있습니다.
  • 예시 설정 파일:
    
    zuul:
      routes:
        users-service:
          path: /users/**
          serviceId: users-service
        orders-service:
          path: /orders/**
          serviceId: orders-service
    
    eureka:
      client:
        service-url:
          defaultZone: http://localhost:8761/eureka/
    

6.6.3 필터 설정

  • Zuul 필터를 사용하여 요청 전후에 다양한 작업을 수행할 수 있습니다.
  • 예시 코드:
    
    @Component
    public class PreFilter extends ZuulFilter {
    
        @Override
        public String filterType() {
            return "pre";
        }
    
        @Override
        public int filterOrder() {
            return 1;
        }
    
        @Override
        public boolean shouldFilter() {
            return true;
        }
    
        @Override
        public Object run() throws ZuulException {
            RequestContext ctx = RequestContext.getCurrentContext();
            HttpServletRequest request = ctx.getRequest();
            // 요청 로깅
            System.out.println(String.format("%s request to %s", request.getMethod(), request.getRequestURL().toString()));
            return null;
        }
    }
    

6.7 실습

💡 클라우드 게이트웨이 + 유레카 + Order 인스턴스(1개) + Product 인스턴스(2개) 로 진행해봅니다.

6.7.1 Eureka 및 Order, Product

  • “로드밸런싱” 실습에서의 유레카 서버와 Order, Product를 그대로 복사하여 가져옵니다.

6.7.2 주문 애플리케이션

OrderController.java

@RestController
@RequestMapping("/order")
public class OrderController {

    @GetMapping
    public String getOrder() {
        return "Order details";
    }
}
  • port를 19092로 수정할 것 -> 19091은 이제 gateway가 사용하기 때문!!

6.7.3 상품 애플리케이션

  • 이전 코드와 동일, port만 19093,19094가 사용되도록 바꿔주기

6.7.4 게이트 웨이

  • Reactive Gateway, Spring Boot Actuator, Eureka Discovery Client, Lombok, Spring Web을 dependency에서 추가하자

CustomPreFilter.java

@Component
public class CustomPreFilter implements GlobalFilter, Ordered {

    private static final Logger logger = Logger.getLogger(CustomPreFilter.class.getName());

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest response = exchange.getRequest();
        logger.info("Pre Filter: Request URI is " + response.getURI());
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

CustomPostFilter.java

@Component
public class CustomPostFilter implements GlobalFilter, Ordered {

    private static final Logger logger = Logger.getLogger(CustomPostFilter.class.getName());

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            ServerHttpResponse response = exchange.getResponse();
            logger.info("Post Filter: Response status code is " + response.getStatusCode());
        }));
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;
    }
}

application.properties

spring.application.name=gateway-service

server.port=19091

spring.main.web-application-type=reactive

spring.cloud.gateway.routes[0].id=order-service
spring.cloud.gateway.routes[0].uri=lb://order-service
spring.cloud.gateway.routes[0].predicates[0]=Path=/order/**

spring.cloud.gateway.routes[1].id=product-service
spring.cloud.gateway.routes[1].uri=lb://product-service
spring.cloud.gateway.routes[1].predicates[0]=Path=/product/**

spring.cloud.gateway.routes[2].id=auth-service
spring.cloud.gateway.routes[2].uri=lb://auth-service
spring.cloud.gateway.routes[2].predicates[0]=Path=/auth/signIn

spring.cloud.gateway.discovery.locator.enabled=true

eureka.client.service-url.defaultZone=http://localhost:19090/eureka/

service.jwt.secret-key=401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1

6.7.5 실습

  • 유레카 서버 ⇒ 게이트웨이 ⇒ 주문 ⇒ 상품 순으로 어플리케이션을 실행합니다.

  • http://localhost:19091/product 를 여러번 호출 하면서 포트가 달라지는 것을 확인합니다. 이를 통해 로드밸런싱이 동작함을 확인합니다.

CustomPreFilter는 게이트웨이로 들어오는 HTTP 요청의 URI를 기록하며, 이 경우 요청된 URI는 http://localhost:19091/order였습니다. 그 후 요청이 처리되고 난 뒤, CustomPostFilter는 반환된 응답의 상태 코드를 기록했으며, 이 로그에서는 응답이 성공적으로 처리되었음을 나타내는 200 OK 상태 코드가 반환되었습니다.

7. 보안 구성 (OAuth2 + JWT)

7.1 보안 개요

7.1.1 보안의 중요성

  • 마이크로서비스 아키텍처에서는 각 서비스가 독립적으로 배포되고 통신하기 때문에 보안이 매우 중요합니다.

7.2 OAuth2 개요

7.2.1 OAuth2란?

  • OAuth2는 토큰 기반의 인증 및 권한 부여 프로토콜입니다.
  • 클라이언트 애플리케이션이 리소스 소유자의 권한을 얻어 보호된 리소스에 접근할 수 있도록 합니다.
  • OAuth2는 네 가지 역할을 정의합니다: 리소스 소유자, 클라이언트, 리소스 서버, 인증 서버

7.2.2 OAuth2의 주요 개념

  • Authorization Code Grant: 인증 코드를 사용하여 액세스 토큰을 얻는 방식
  • Implicit Grant: 클라이언트 애플리케이션에서 직접 액세스 토큰을 얻는 방식
  • Resource Owner Password Credentials Grant: 사용자 이름과 비밀번호를 사용하여 액세스 토큰을 얻는 방식
  • Client Credentials Grant: 클라이언트 애플리케이션이 자신의 자격 증명을 사용하여 액세스 토큰을 얻는 방식
    RR 방식으로 load balancer가 적용된 것을 확인할 수 있다.

7.3 JWT 개요

7.3.1 JWT란?

  • JWT(JSON Web Token)는 JSON 형식의 자가 포함된 토큰으로, 클레임(claim)을 포함하여 사용자에 대한 정보를 전달합니다.
  • JWT는 세 부분으로 구성됩니다: 헤더, 페이로드, 서명
  • 데이터의 무결성과 인증을 보장

7.4 실습

클라우드 게이트웨이의 Pre 필터에서 JWT 인증을 진행해봅니다.
우선 “스프링 클라우드 게이트웨이”에서 학습한 모든 프로젝트를 복사하여 사용하겠습니다.
여기에 Auth Service 를 생성하여 로그인 기능을 아주 간단하게 구현하겠습니다.
클라우드 게이트웨이에 Pre 필터를 하나 더 생성하여 로그인을 체크 하겠습니다.

생성한 토큰을 전달받아 다시 로그인에 사용해야 하므로 Postman 준비하기

7.4.1 Auth Service

  • 로그인을 담당하는 서비스 어플리케이션을 생성합니다. 로그인을 진행하면 토큰을 발급받고 이 토큰을 사용하여 Gateway를 호출 합니다.
  • Spring Web, Eureka Discovery Client, Lombok, Spring Boot Actuator, Spring Security를 dependency로 추가하자
  • implementation 'io.jsonwebtoken:jjwt:0.12.6' jwt는 build.gradle에 직접 추가하자!!

application.properties

spring.application.name=auth-service

eureka.client.service-url.defaultZone=http://localhost:19090/eureka/

service.jwt.access-expiration=3600000
service.jwt.secret-key=401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1

server.port=19095

AuthConfig.java

@Configuration
@EnableWebSecurity
public class AuthConfig {

    // SecurityFilterChain 빈을 정의합니다. 이 메서드는 Spring Security의 보안 필터 체인을 구성합니다.
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 보호를 비활성화합니다. CSRF 보호는 주로 브라우저 클라이언트를 대상으로 하는 공격을 방지하기 위해 사용됩니다.
            .csrf(csrf -> csrf.disable())
            // 요청에 대한 접근 권한을 설정합니다.
            .authorizeRequests(authorize -> authorize
                // /auth/signIn 경로에 대한 접근을 허용합니다. 이 경로는 인증 없이 접근할 수 있습니다.
                .requestMatchers("/auth/signIn").permitAll()
                // 그 외의 모든 요청은 인증이 필요합니다.
                .anyRequest().authenticated()
            )
            // 세션 관리 정책을 정의합니다. 여기서는 세션을 사용하지 않도록 STATELESS로 설정합니다.
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        // 설정된 보안 필터 체인을 반환합니다.
        return http.build();
    }
}

AuthService.java

@Service
public class Authservice {
    @Value("${spring.application.name}")
    private String issuer;
    @Value("${service.jwt.access-expiration}")
    private Long accessExpiration;

    private final SecretKey secretKey;

//  Base64 URL 인코딩된 비밀 키를 디코딩하여 HMAC-SHA 알고리즘에 적합한 SecretKey 객체를 생성합니다.
//  @param secretKey는 Base64 URL 인코딩된 비밀 키
    public Authservice(@Value("${service.jwt.secret-key}")String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

    public String createAccessToken(String userId) {

        return Jwts.builder()
                .claim("user_id",userId)
                .claim("role","ADMIN")
                .issuer(issuer)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis()+accessExpiration))
                // SecretKey를 사용하여 HMAC-SHA512 알고리즘으로 서명
                .signWith(secretKey, SignatureAlgorithm.HS512)
                .compact();

    }
}

AuthController.java

@RestController
@RequiredArgsConstructor
public class AuthController {
    private final Authservice authservice;
    @GetMapping("/auth/signIn")
    public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
        return ResponseEntity.ok(new AuthResponse(authservice.createAccessToken(user_id)));
    }
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    static class AuthResponse {
        private String access_token;

    }
}

7.4.2 Cloud Gateway

  • build.gradle에 jwt dependency 추가하기
  • application.properties는 6.7.4 게이트웨이에 작성된 것으로 교체하기!!

LocalJwtAuthenticationFilter.java

@Slf4j
@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {

    @Value("${service.jwt.secret-key}")
    private String secretKey;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        if (path.equals("/auth/signIn")) {
            return chain.filter(exchange);  // /signIn 경로는 필터를 적용하지 않음
        }

        String token = extractToken(exchange);

        if (token == null || !validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange);
    }

    private String extractToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }

    private boolean validateToken(String token) {
        try {
            SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
            Jws<Claims> claimsJws = Jwts.parser()
                    .verifyWith(key)
                    .build().parseSignedClaims(token);
            log.info("#####payload :: " + claimsJws.getPayload().toString());

            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

7.4.3 RUN

  • 유레카 서버 ⇒ 게이트웨이⇒ 인증 ⇒ 상품 순으로 어플리케이션을 실행합니다.

  • 게이트웨이에서 상품을 요청해 봅니다. 401 에러가 발생하는 것을 볼 수 있습니다.

게이트웨이에서 로그인을 요청하여 토큰을 발급받아보자 하지만 위처럼 503 에러가 나왔다. AuthService를 실행하지 않았거나 실행 순서가 다르면 서비스를 찾지 못하는 것이다. 순서를 잘 지키자~

순서를 지키니 올바른 access_token을 받게 되었다 이제 상품을 요청할때 header에 토큰을 넣어보자

gateway를 통해 jwt 토큰을 이용하여 로그인을 완료하였다.

오늘은 어제만큼 실패한 내용은 없었지만, jwt 토큰 생성과 검증 로직을 다시한번 살펴보고 나중에 jwt 토큰 구현할때는 deprecate 된 함수를 사용하지 않고 docs를 찾아보면서 새로운 함수들로 구현해보자는 생각을 가지게 되었습니다.

0개의 댓글