본문 바로가기
DDD&MSA

[MSA] API Gateway 에 인증 구현하기 - GatewayFilter 추가하기

by 덩라 2023. 10. 8.

본 포스팅은 MSA 공부 간 작성한 포스팅 입니다.

아래 포스팅을 먼저 보고 오시면 이해에 도움이 되시니 참고 부탁드립니다.

https://byunsw4.tistory.com/34

 

[MSA] API Gateway - 마이크로 서비스 Routing 처리하기(feat. Spring Cloud Gateway)

본 포스팅은 Spring Cloud 를 기반으로 MSA 구조를 학습하고자 작성하는 포스팅입니다. 아래 포스팅을 먼저 보고 오시면 이해가 수월하시니 참고바랍니다. https://byunsw4.tistory.com/31 [MSA] Micro Service Archi

byunsw4.tistory.com


1. 인증 처리를 수행하는 API Gateway

MSA 구조에서 API Gateway 는 마이크로 서비스 간의 routing 처리 외에도 마이크로 서비스에서 공통적으로 처리해야하는 기능을 수행하게 할 수 있습니다. 그 중 하나가 바로 "인증(Authorization)" 입니다.

 

API Gateway 에 인증 기능을 추가하면 아래와 같은 장점을 얻을 수 있습니다.

  1. 공통 인증 로직을 한 곳에서 처리할 수 있다.
  2. 마이크로 서비스에는 비지니스 로직만 구현하면 된다.

 

이번 포스팅에서 적용해볼 인증 방식은 JWT로 유명한 토큰 인증 방식 입니다.

API Gateway 에서 JWT 인증을 수행하는 경우, 인증 절차는 아래와 같이 처리됩니다. 

클라이언트 -> API Gateway -> Micro Service 인증 흐름

모든 클라이언트의 요청은 API Gateway 를 통과하여 인증이 필요한지 필요 없는지, 필요하면 토큰을 통해 사용자 정보를 parsing 해서 마이크로 서비스로 요청을 다시 보내는 흐름으로 진행됩니다.

 

2. API Gateway 에 적용할 Filter 생성하기

Spring Cloud Gateway 에 Filter 를 적용하기 위해선 GatewayFilter interface 를 implements 받아 filter() 메서드를 override 합니다.

@Component
public class JwtAuthorizationFilter implements GatewayFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return null;
    }
}

 

새로 만든 Filter 를 Gateway Routing 에 적용하기 위해서는 아래와 같이 적용하고 싶은 route 규칙에 filter 를 추가하면 됩니다.

@RequiredArgsConstructor
@Configuration
public class GatewayConfiguration {

    private final JwtAuthorizationFilter jwtAuthorizationFilter;

    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
                .route("user-service-1", predicateSpec -> predicateSpec
                        .path("/sign-up","/login")
                        .and().method(HttpMethod.POST)
                        .filters(gatewayFilterSpec -> gatewayFilterSpec
                                .removeRequestHeader(HttpHeaders.COOKIE)
                        )
                        .uri("lb://USER-SERVICE")
                )
                .route("user-service-2", predicateSpec -> predicateSpec
                        .path("/users/**")
                        .filters(gatewayFilterSpec -> gatewayFilterSpec
                                .removeRequestHeader(HttpHeaders.COOKIE)
                                .filter(jwtAuthorizationFilter)
                        )
                        .uri("lb://USER-SERVICE")
                )
                .route("main-service", predicateSpec -> predicateSpec
                        .path("/**")
                        .filters(gatewayFilterSpec -> gatewayFilterSpec
                                .removeRequestHeader(HttpHeaders.COOKIE)
                                .filter(jwtAuthorizationFilter)
                        )
                        .uri("lb://MAIN-SERVICE")
                )
                .build();
    }
}

 

위 설정은 사용자 인증이 필요없는 /login(로그인) 과 /sign-up(회원가입) 은 인증 필터를 적용하지 않고, 나머지 요청에 대해서는 인증 필터를 적용하는 routing 설정입니다. 

 

 

3. JWT 방식의 인증 정보 처리

그렇다면, 위에서 만든 GatewayFilter 에 JWT 방식을 추가해보겠습니다.

일단, jwt 구현을 위해 아래 의존성을 추가했습니다.

``` build.gradle

// ... 이상 생략

dependencies {
    // -- 중략 -- //

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

// ... 이하 생략

 

Jwt 에 대한 자세한 내용은 아래 jwt 공식 홈페이지를 참고해주세요!
출처 - https://jwt.io/

 

 

위에서 추가해준 jwt 를 통해 구현된 JwtAuthorizationFilter 입니다.

import static shoppingmall.apigateway.AuthorizationConstants.*;

@RequiredArgsConstructor
@Component
public class JwtAuthorizationFilter implements GatewayFilter {

    @Value("${auth.jwt.key}")
    private String key;

    private final ObjectMapper objectMapper;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("JwtAuthorizationFilter begin");
        try{
            List<String> authorizations = getAuthorizations(exchange);

            if(isNotExistsAuthorizationHeader(authorizations)) {
                throw new NotExistsAuthorization();
            }

            String authorization = authorizations.stream()
                    .filter(this::isBearerType)
                    .findFirst()
                    .orElseThrow(NotExistsAuthorization::new);

            String jwtToken = parseAuthorizationToken(authorization);
            if(isValidateExpire(jwtToken)) {
                throw new AccessTokenExpiredException();
            }

            exchange.getRequest().mutate().header(X_GATEWAY_HEADER, getSubjectOf(jwtToken));
            return chain.filter(exchange);
        } catch(NotExistsAuthorization e1) {
            return sendErrorResponse(exchange, 701, e1);
        } catch(AccessTokenExpiredException e2) {
            return sendErrorResponse(exchange, 702, e2);
        } catch(Exception e3){
            return sendErrorResponse(exchange, 999, e3);
        }
    }

    private Mono<Void> sendErrorResponse(ServerWebExchange exchange, int errorCode, Exception e) {
        try {
            ErrorResponse errorResponse = new ErrorResponse(errorCode, e.getMessage());
            String errorBody = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(errorResponse);

            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            DataBuffer buffer = response.bufferFactory().wrap(errorBody.getBytes(StandardCharsets.UTF_8));
            return response.writeWith(Flux.just(buffer));
        } catch (JsonProcessingException ex) {
            throw new RuntimeException(ex);
        }
    }

    private boolean isBearerType(String authorization) {
        return authorization.startsWith(AUTH_TYPE);
    }

    private List<String> getAuthorizations(ServerWebExchange exchange) {
        ServerHttpRequest request = exchange.getRequest();
        return request.getHeaders().get(HttpHeaders.AUTHORIZATION);
    }

    private String parseAuthorizationToken(String authorization) {
        return authorization.replace(AUTH_TYPE, "").trim();
    }

    private boolean isNotExistsAuthorizationHeader(List<String> authorizations) {
        return authorizations == null || authorizations.isEmpty();
    }

    private String getSubjectOf(String jwtToken) {
        return Jwts.parser().verifyWith(secretKey())
                .build()
                .parseSignedClaims(jwtToken)
                .getPayload()
                .getSubject();
    }

    private boolean isValidateExpire(String jwtToken) {
        Date expiration = Jwts.parser().verifyWith(secretKey())
                .build()
                .parseSignedClaims(jwtToken)
                .getPayload()
                .getExpiration();
        return expiration.before(new Date());
    }

    private SecretKey secretKey() {
        return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
    }

    record ErrorResponse(int code, String message){}
}

 

Gateway에서 요청이 들어오면 마이크로 서비스로 요청을 routing 하기 전에 @Override 가 붙은 filter() 메서드를 실행하게 됩니다.

요청에 포함된 jwt 값이 정상적으로 parsing 되고 만료시간 전이라 사용이 가능하면, token 을 만들 때 사용한 사용자의 고유 정보를 parsing 하여 request header에 추가해줍니다. 그 후에 return chain.filter(exchange); 를 통해 다음에 실행될 filter 를 실행하게 되고, filter 를 모두 실행하게 되면 마이크로 서비스로 요청이 전달됩니다.

그 외에 어떠한 이유로 filter() 메서드에서 예외가 발생하면, sendErrorResponse() 를 통해 이후에 동작할 Filter 가 실행되지 않고, 클라이언트에게 예외 응답이 return 되는 형태입니다.

 

4. 결과 테스트

현재 구현된 API Gateway 는 3가지의 Routing Pattern 을 가집니다.

  1. /login, /sign-up -> 별도의 filter 가 적용되지 않는 사용자 마이크로 서비스에 대한 요청
  2. /users/** -> JwtAuthorizationFilter 가 적용되는 사용자 마이크로 서비스에 대한 요청
  3. /** -> JwtAuthorizationFilter 가 적용되는 메인 마이크로 서비스에 대한 요청

위 3가지 요청에 대한 테스트를 postman 으로 해보면 아래와 같은 결과를 얻게 됩니다.

 

4-1. /sign-up, /login -> JwtAuthorizationFilter 동작 안함

위 2가지의 요청에 대해서는 JwtAuthorizationFilter 를 처리하지 않았기 때문에, Filter 시작할 때 찍어놓은 log 가 출력되지 않습니다.

/sign-up 요청과 /login 요청이 성공한 결과
JwtAuthorizationFilter 의 log 가 출력되지 않음

 

4-2. /users - JwtAuthorizationFilter 동작 함

위 요청에 대해서는 JwtAuthorizationFilter 가 동작할 것이기 때문에 Filter에 찍어놓은 log 가 출력됩니다.

/users 요청이 성공한 결과
JwtAuthorizationFilter 의 log 가 출력됨

 

4-3. 그 외 다른 요청 - JwtAuthorizationFilter 동작 함

앞선 두 분류의 요청은 사용자 마이크로 서비스에 대한 요청이었다면, 이번 요청은 그 외의 메인 마이크로 서비스 요청으로 테스트 해보겠습니다. 마찬가지로 JwtAuthorizationFilter 가 동작하기 때문에, 인증 정보를 추가한 상태로 요청을 보내봐야 합니다.

메인 마이크로 서비스의 API 중 하나인 /delivery 요청이 성공한 결과
JwtAuthorizationFilter 의 log 가 출력됨

 


참고 및 출처.

https://github.com/dongha-byun/springboot-shoppingmall-api-gateway

 

GitHub - dongha-byun/springboot-shoppingmall-api-gateway: Spring Cloud Gateway of SpringBoot Shoppingmall

Spring Cloud Gateway of SpringBoot Shoppingmall. Contribute to dongha-byun/springboot-shoppingmall-api-gateway development by creating an account on GitHub.

github.com

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4/dashboard

 

Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) - 인프런 | 강의

Spring framework의 Spring Cloud 제품군을 이용하여 마이크로서비스 애플리케이션을 개발해 보는 과정입니다. Cloud Native Application으로써의 Spring Cloud를 어떻게 사용하는지, 구성을 어떻게 하는지에 대해

www.inflearn.com

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

https://spring.io/projects/spring-cloud-gateway

 

Spring Cloud Gateway

This project provides a libraries for building an API Gateway on top of Spring WebFlux or Spring WebMVC. Spring Cloud Gateway aims to provide a simple, yet effective way to route to APIs and provide cross cutting concerns to them such as: security, monitor

spring.io

 

댓글