[Spring Cloud gateway] - 게이트웨이를 사용한 jwt 유효성 검사

yeom yaloo·2023년 8월 17일
0

쇼핑몰

목록 보기
17/19
post-thumbnail

[들어가기에 앞서..]

1. 왜 게이트 웨이를 사용해서 jwt 유효성 검사를 진행하나?

보안 강화: JWT는 클라이언트와 서버 간에 인증을 위해 사용되는 토큰입니다. 클라이언트가 발급한 JWT를 서버에 전달하면, 서버는 해당 JWT의 유효성을 검사하여 해당 클라이언트가 인증된 사용자인지 확인합니다. 게이트웨이에서 유효성을 검사하여 정상적인 토큰만 허용하고, 위조된 토큰이나 만료된 토큰 등을 거부하여 보안을 강화할 수 있습니다.

접근 제어 및 인가: JWT는 사용자의 역할(Role)이나 권한(Authority) 정보를 포함할 수 있습니다. 게이트웨이에서 JWT를 검사하여 해당 사용자가 특정 리소스에 접근할 권한이 있는지 확인할 수 있습니다. 이를 통해 인가된 사용자만이 특정 기능이나 리소스에 접근할 수 있도록 제어할 수 있습니다.

단일 인증 지점: 여러 마이크로서비스로 구성된 MSA(Microservices Architecture) 환경에서 각 서비스마다 별도의 인증 로직을 구현하는 것은 복잡하고 비효율적일 수 있습니다. 게이트웨이에서 JWT 유효성 검사를 하면, 모든 인증과 인가 관련 로직을 중앙 집중화하여 관리할 수 있습니다. 이를 통해 단일 인증 지점(Single Sign-On)을 구현하거나 중복된 인증 로직을 방지할 수 있습니다.

클라이언트 단순화: 클라이언트 애플리케이션에서 JWT를 관리하고 유효성 검사를 직접 수행하는 것은 복잡할 수 있습니다. 게이트웨이에서 JWT 유효성 검사를 대신해주면 클라이언트는 단순히 JWT를 요청 헤더에 추가하기만 하면 됩니다. 클라이언트 애플리케이션을 더 단순화할 수 있습니다.

중앙화된 관리: 게이트웨이에서 JWT 유효성 검사를 관리하면, 변경이나 업데이트가 필요한 경우 한 곳에서 처리할 수 있습니다. 여러 서비스를 수정하지 않아도 되므로 유지보수가 편리해집니다.

2. 게이트웨이를 도입한 이유를 생각하면 해당 작업을 진행하는 이유를 알수 있다.

게이트웨이를 클라이언트와 여러 백엔드 서버들 중간에 두어서 보안과, 불필요한 호출을 줄이고 클라이언트에게는 어떤 서버가 해당 요청에 대한 응답을 주는지에 대한 불필요한 정보를 노출하지 않는 등의 이점으로 도입했음을 생각하면 위의 이유들이 전부 이해 된다.

그러니까 왜 jwt 유효성을 검사를 게이트웨이에서 하는가아?

간단하게 해당 작업을 위한 필터를 구성해서 등록하고 사용하기만 하면 되니까 유지보수도 편해지고 클라이언트 측에서는 해당 jwt에 대한 검사가 필요 없어지며 게이트웨이에서 Jwt 유효성을 확인해주니 필요할때마다 해당 작업을 진행할 필요가 없어 코드의 중복이 줄어들 것이고 결과적으로 게이트웨이가 이 작업을 중앙에서 진행해주니 위조, 만료된 토큰 등의 경우를 거절해주어 보안이 강화될 것이다.

3. 쿠키, 세션 그리고 JWT

우리가 JWT를 도입한 이유는 사실상 남들이 좋다니까 보안에도 좋고 stateless하게 사용이 가능하고 분산된 서버에서는 세션처럼 클러스터링을 굳이 머리아프게 고민하지 않아도 된다는 장점이 있기 때문이라고는 알고 있을 것이다.

쿠키

  • 조작 가능성이 높음
  • 보안이 구려서 개인정보 저장엔 지양하는 것이 맞음

세션

  • 메모리에 저장해서 저장 공간에 한계가 존재 (서버 자원을 사용)
  • 로드 밸런스와 같은 기술을 적용할 때 세션 클러스터링 등을 고려해야 함 머리가 아프다.
  • 쿠키에 비해서 느리다. (그러나 쿠키에 비해서 보안이 괜찮다.)

jwt
[장점]

  • 데이터의 위변조를 방지한다.
  • JWT는 인증에 필요한 모든 정보를 담고 있기 때문에 인증을 위한 별도의 저장소가 없어도 된다.
  • 세션(Stateful)과 다르게 서버는 무상태(StateLess)가 된다.
  • 확장성이 우수하다.

[단점]

  • 쿠키/세션과 다르게 토큰의 길이가 길어, 인증 요청이 많아질수록 네트워크 부하가 심해진다.
  • Payload 자체는 암호화가 되지 않아 중요한 정보는 담을 수 없다.
  • 토큰을 탈취당한다면 대처가 매우 어렵다.

사실 위의 장단점만 봤을 땐 딱히 JWT가 그다지 ;; 매력적으로 보이진 않는다.

4. 그럼 진짜 왜 도입했어 JWT!!!

4-1. 남들이 써보는 기술은 다 써보고 싶어서..

  • jwt를 쿠키, 세션 대신해서 많이 쓰니까 꼭 써보고 싶었다..

4-2. 나는 백엔드 개발자로 트래픽에 따른.. 분산 작업을 잘 해야 하니까..

  • 라는 생각으로 해당 Jwt라는 기술을 도입하고 사용하고 있다.
  • 그리고 msa 환경을 도입해서 프로젝트를 꾸리고 있으니까..
  • 또.. 게이트 웨이를 중앙에 두어서 해당 작업을 진행하고 있으니까.. 단일 인증 지점이라는 목표를.. 이루고 싶었다.

여러 이유로 JWT를 도입했고 해당 서비스 아키텍쳐에 맞춰서 이를 하나씩 구현하고자 했다.

[Spring cloud gateway 와 JWT]

1. 게이트웨이에 JWT?

1-1. 매번 들어오는 인증 작업을 어떻게 진행할 것?

  • 해당 물음 때문에 시작된 작업이었다.
  • 인증/인가 서버에서 해당 작업을 진행하는 Controller를 작성하고 이를 계속 불러서 이 사용자가 인증되었는지를 확인할까 고민했었다.
  • 그런데 너 ~ 무 비효율적이고 API 호출에 따른 비용도,, 코드 중복도.. 별로 마음에 들지 않았다.

1-2. 그래서 유효성 검사를 중앙에 존재하는 게이트웨이를 이용하기로 했다.

  • MSA 환경의 경우엔 API gateway가 중앙에 있어서 클라이언트 요청을 처리하는데 이를 이용하면 한 번에 처리할 수 있겠다!

1-3. 게이트웨이에 무엇을 할건데?

  • 필터를 넣어서 Jwt를 확인하는 작업을 진행합니다
  • 그리고 라우터를 사용해서 해당 경로로 들어온 작업을 처리할 것입니다.

1-4. request header로 넘어온 jwt의 유효성 검사 필터 도입

  • 이것이 게이트웨이에 작업한 나의 궁극적인 기능 목표이다.
  • 유효하지 못한, 아니면 jwt를 헤더로 넘기지 않는, 제대로 된 jwt이 아닌 경우라면 다 쳐내는 작업이라고 요약 설명할 수 있다.

2. 해당 작업의 흐름

  1. gateway url host로 들어온 요청의 정보가 해당 Predicates에 적용된 Path와 일치할 때 해당 필터는 동작한다.
    👉 중요한 것은 gateway routes를 사용하기 때문에 해당 경로는 꼭 게이트웨이를 통해서 걸쳐 들어와야 한다는 것
  2. predicates는 조건문을 생각하면 된다.
  3. 필터는 원하는 작업에 따라 작업을 진행한다.
    👉 여기서는 request header로 넘어오는 jwt 유효성 검사를 진행한다.
  4. 유효성 검사 결과에 따라 로직이 실행된다.

3. 실제 그럼 어떻게 구성했을까?

스프링 클라우드 게이트웨이를 사용한 라우터 작업은 사실 두가지로 가능하다

  • 자바를 이용한 RouteLocator 빈(bean) 등록
  • yml 파일을 이용한 작업

3-1 자바를 이용한 RouteLocator 빈(bean) 등록

import com.yaloostore.gateway.filter.CustomAuthorizationFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.gateway.route.builder.UriSpec;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.util.function.Function;

@Slf4j
@Configuration
@ConfigurationProperties(prefix = "yaloostore")
public class GatewayConfig {
    private String shopUrl;
    private String tokenUrlPattern;
    private String authUrl;


    @Bean
    public RouteLocator routeLocator(CustomAuthorizationFilter authorizationFilter,
                                     RestTemplate restTemplate,
                                     RouteLocatorBuilder builder) {
        return builder.routes()
                .route("token", r-> r.path(tokenUrlPattern)
                        .filters(tokenFilter(authorizationFilter, restTemplate))
                        .uri(shopUrl))
                .build();
    }

    private Function<GatewayFilterSpec, UriSpec> tokenFilter(CustomAuthorizationFilter filter,
                                                             RestTemplate restTemplate) {
        return f -> f.filter(
                filter.apply(
                        new CustomAuthorizationFilter.Config(restTemplate, authUrl)
                )
        );

    }

    public String getTokenUrlPattern() {
        return tokenUrlPattern;
    }

    public String getAuthUrl() {
        return authUrl;
    }

    public String getShopUrl() {
        return shopUrl;
    }

    public void setShopUrl(String shopUrl) {
        this.shopUrl = shopUrl;
    }

    public void setTokenUrlPattern(String tokenUrlPattern) {
        this.tokenUrlPattern = tokenUrlPattern;
    }

    public void setAuthUrl(String authUrl) {
        this.authUrl = authUrl;
    }
}
  • 해당 작업은 게이트웨이 필터 내에서 프로퍼티 주입을 위해서 java를 이용해 작성했습니다.

[application.yml]

yaloostore:
  token-url-pattern: /token/**
  shop-url: http://localhost:8081
  auth-url: http://localhost:8083
  • prefix로 yaloostore을 설정해두었다면 위의 패턴으로 url, patter을 지정해주면 된다.
  • 이때 Prefix must be in canonical form : 네이밍 표준 형식을 사용해야 한다.

3.2 Yml 파일을 이용한 라우터 등록


spring:
  cloud:
    gateway:
      routes:
        - id: yalooStore-shop
          uri: http://localhost:8081
          predicates:
            - Path= /api/**

        - id: yalooStore-front
          uri: http://localhost:8082
          predicates:
            - Path=/members/**, /, /error, /products/** , /product/**, /auth-login, /static/**, /assets/**, /css/**, /js/**, /img/**, /fonts/**

        - id: yalooStore-auth
          uri: http://localhost:8083
          predicates:
            - Path= /auth/**

  • id: 해당 라우터 이름
  • uri: 조건을 먼저 살펴보고 조건에 맞게 설정해둔 uri로 해당 요청을 게이트웨이는 보내준다. (3)
  • predicates(1) -> 필수
    • 해당 조건으로 들어온 작업의 경우엔 설정해준 uri로 작업을 보내준다.
    • 이때 gateway uri로 들어오는 작업의 조건에 한해서만 진행됨 (작성된 uri로 들어온 조건은 상관 없음 적용 안 됨)
    • 또한 predicates에 들어가는 Path 같은 경우엔 대문자로 시작해야 한다.
  • filter: 작성한 게이트웨이 필터를 적용 (2)
  • 간단하게 위의 숫자대로 흐름이 흘러간다 생각하면 된다.
  • 위에 작성해둔 flow 대로 해당 요청이 들어오면 핸들러가 작동하게 되고 조건을 살펴보고 조건에 맞춰서 설정해둔 filter uri로 해당 작업을 보내준다.

4. yml, java 코드로 등록한 routes

  • 두개 모두 같이 등록해서 사용 가능해 보인다.
  • 본인 프로젝트에 두개를 같이 넣어서 사용 중

[GatewayFilter]

1. gatewayFilter?

  • 스프링 클라우드 게이트웨이에서는 게이트웨이 필터를 여러 종류로 제공하고 있습니다.
  • 게이트웨이 필터의 경우엔 요청, 응답을 필터 체인으로 변경하고 추가하는 등의 작업을 진행합니다.
  • 요청, 응답을 조작하거나 검사하는 기능 역시 진행합니다.
  • 결과적으로 게이트웨이 필터를 사용하면 게이트웨이 기능을 확장할 수 있습니다.

2. JWT 유효성 검사 CustomGatewayFilter

2-1. 게이트웨이 필터

import com.yalooStore.common_utils.dto.ResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.asn1.ocsp.ResponseData;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.Objects;

/**
 * jwt를 사용한 토큰 유효성 검사에 사용되는 필터 클래스입니다.
 * */
@Component
@Slf4j
public class CustomAuthorizationFilter extends AbstractGatewayFilterFactory<CustomAuthorizationFilter.Config> {


    @RequiredArgsConstructor
    public static class Config{
        // 필요 설정을 여기에서 진행
        //private final RedisTemplate<String, Object> redisTemplate;
        private final RestTemplate restTemplate;
        private final String authUrl;

    }
    public CustomAuthorizationFilter(){
        super(Config.class);
    }


    /**
     * 해당 조건문에 맞는 요청어오면 jwt이 있는지 유효한지를 확인하는 작업을 진행합니다.
     *
     * @param config 필터에서 사용할 설정
     * @return 토큰 유효성 검사를 진행하는 gatewayFilter
     * */
    @Override
    public GatewayFilter apply(Config config) {
        return (((exchange, chain) -> {
            String token = exchange.getRequest().getHeaders().getFirst("Authorization");


            //넘어온 Authorization 헤더가 없으면 UNAUTHORIZATION를 날림
            if (Objects.isNull(token)){
                log.info("Authorization header not exist");
                return unAuthorizedHandler(exchange);
            }

            // Authorization: Bearer 로 시작되기 때문에 이 역시 확인 작업을 진행
            String accessToken = prefixRemoveToken(token);
            if (Objects.isNull(accessToken)){
                log.info("해당 토큰이 Bearer로 시작하지 않습니다.");
                return unAuthorizedHandler(exchange);
            }

            boolean isValidToken = checkIsValidToken(accessToken,config);

            if (!isValidToken){
                log.info("is not Valid Token");
                return unAuthorizedHandler(exchange);
            }

            return chain.filter(exchange);
        }));
    }

    private boolean checkIsValidToken(String accessToken, Config config) {

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);

        log.info("jwt header!!! === {} ", headers.get("Authorization").toString());
        HttpEntity entity = new HttpEntity(headers);

        URI uri = UriComponentsBuilder.fromUriString(config.authUrl)
                .pathSegment("authorizations", "isValidToken").build().toUri();

        ResponseEntity<ResponseDto<Void>> response = config.restTemplate.exchange(uri, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {
        });

        log.info(String.valueOf(response.getBody().isSuccess()));

        if(!response.getBody().isSuccess()){
            return false;
        }

        return true;
    }

    private String prefixRemoveToken(String token) {

        if (!token.startsWith("Bearer ")){
            return null;
        }

        return token.substring(7);
    }

    private Mono<Void> unAuthorizedHandler(ServerWebExchange exchange) {

        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);

        return response.setComplete();
    }
}

[동작 시켜보자]

1. Authorization 토큰이 없을 경우

  • 토큰이 없을 경우에 걸린다.

2. Bearer로 시작하지 않을 경우

  • JWT를 사용하는 경우엔 접두사로 Bearer이 붙는다.
  • 이 특징을 이용해서 제대로된 토큰인지 확인한다.

3. 유효하지 않은 토큰을 넘겨줄 경우

  • 인증/인가를 담당하는 서버에서 해당 토큰을 확인해서 유효성 여부를 넘겨준다.
profile
즐겁고 괴로운 개발😎

0개의 댓글