Spring Cloud Netflix Zuul로 API Gateway 만들기 - JWT 인증

가영·2021년 11월 14일
0

사용하기로 한 이유

편리함

이번에 만들어보려고 하는 서비스에서 서버를 msa를 적용해보기로 했다.
나와 다른 개발자 한 명 둘 다 엄청 규모가 큰 서비스를 만들어본 건아니라서 MSA를 하는게 맞을까 고민을 많이 했지만 각 도메인을 쉽게 분업하고 관리하기 위해 netflix에서 만든 편리한 proxy 도구인 zuul을 사용하기로 결정했다.

일단 우리의 서비스에 나눌 수 있는 마이크로 서비스는 대략적으로 다음과 같다.

  • 유저 정보(로그인/회원가입) 서버
  • 글 관련 api 서버
  • 실시간 알림 정보 서버
  • 채팅 서버

중복로직 제거

일단 우리는 zuul을 가장 먼저 인증의 목적으로 활용해보기로 했다.

proxy 서버로 zuul을 가장 앞단에 두고, 헤더에 토큰이 들어가는 모든 요청을 필터링하는 작업을 할 것이다.

원래는 각 서버에서 jwt 유효성검사를 진행해야했다면, 이제는 zuul api gateway에서 검사 후, jwt payload를 요청에 추가해 서비스 서버에서는 decode할 필요가 없게 만들기 위해서이다.

확실히, 이후에 내가 담당했던 글 관련 api 서버를 개발할 때는 테스트도 굉장히 편리 했다. 이전에는 항상 jwt 발행하고 헤더에 넣어 api 테스트를 해야했지만 zuul 적용 후에는 payload를 바로 원하는 곳에 넣어 (나는 헤더) decode 단계를 완전히 배제하고 개발할 수 있었다.

Zuul application 만들기

의존성 추가

일단 netflix-zuul 의존성을 추가해준다.
찾으러 가기

나는 java-gradle 프로젝트이므로 다음 의존성을 추가해주었다.

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-zuul'

Zuul 애플리케이션으로 동작하게 하기

@EnableZuulProxy
@SpringBootApplication
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }

}

application 클래스에 @EnableZuulProxy 어노테이션을 붙여 zuul 앱으로 동작하게 해준다.

원하는 서비스 uri 매핑하기

우리는 빠른 적용을 위해 일단 하나의 물리서버에서 여러 포트에 다른 서버를 띄워 운영하려고 했다. 다른 포트 또는 다른 서버에서 동작시키고 있는 서비스를 zuul을 통해 접근할 수 있게 하려면, application.yml 파일에 다음처럼 zuul 설정을 추가해준다.

zuul:
  routes:
    auth: # 뒤에 붙는 uri
      url: http://localhost:8100

위의 설정파일을 예로 들면, zuul 애플리케이션 (api gateway)가 http://localhost:8080 일 때, auth 서버로 연결되는 uri는
http://localhost:8080/auth이다. 클라이언트가 http://localhost:8080/auth로 요청을 보내면 그 요청은 http://localhost:8100로 가고 응답도 여기서 보내준다.

만약 /api/v1 를 prefix로 하고싶다면 다음처럼 해주면 된다.

zuul:
  routes:
    api:
      v1:
        auth:
          url: http://localhost:8100

Zuul 필터 만들기

자 이제 우리가 원하는 건 api gateway에서 각 서버에 요청을 보내기 전에 jwt를 처리하도록 만드는 것이다.

이것을 구현하기 위해 zuul이 제공하는 필터 기능인 ZuulFilter 를 상속하여 커스텀 필터를 만들어 줄 것이다.

사실 급하게 만들어서 zuul의 기능을 완벽히 활용하진 못한 것 같지만, 기록은 남겨두려고 한다!

RequestContextHandler.java

  • 요청의 jwt 토큰을 decode해서 서비스에서 필요로 하는 userId를 넣어주는 메서드(basicAuth)와 이따 필터에서 필터링이 되었을 경우 failed request를 보내는 메서드(setFailedRequest)를 여기에 두었다.
package com.sungan.apiGateway.support;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.context.RequestContext;
import com.sungan.apiGateway.jwt.JwtKeyProvider;
import com.sungan.apiGateway.jwt.TokenPayload;
import com.sungan.apiGateway.response.SunganResponse;
import io.jsonwebtoken.JwtException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class RequestContextHandler {

    private final JwtKeyProvider jwtKeyProvider;
    
    public RequestContextHandler(JwtKeyProvider jwtKeyProvider) {
        this.jwtKeyProvider = jwtKeyProvider;
    }

    public void basicAuth() throws JwtException {
  	RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest req = context.getRequest();
        // token을 decode한다.
        TokenPayload payload = jwtKeyProvider.decodeToken(req.getHeader("Authorization")).orElseThrow();
        // payload에서 userId를 꺼내 요청 헤더에 추가한다.
        Long userId = payload.getUserId();
        // 이렇게 userId를 추가한 요청을 실제 서비스 서버로 보내게 될 것이다.
        context.addZuulRequestHeader("userId", String.valueOf(userId));
    }

    // AuthFilter에서 필터링시 사용할 메서드
    public void setFailedRequest(SunganResponse res) {
        try {
            RequestContext context = RequestContext.getCurrentContext();
            HttpServletResponse response = context.getResponse();
            
            context.setResponseStatusCode(res.getStatusCode());
            
            if (context.getResponseBody() == null) { // 이미 res body가 설정돼있을 경우는 건드리지 않는다는 뜻?
               // response를 설정해주기 위해서 mapper 생성
                ObjectMapper mapper = new ObjectMapper(); response.setContentType("application/json");
                context.set("responseBody", mapper.writeValueAsString(res));
                context.setSendZuulResponse(false); // 이건 진자 무슨뜻인지를 모르겠다.
            }
        } catch (JsonProcessingException e) {
            setFailedRequest(new SunganResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Json processing failed."));
        }
    }
}

위에 코드에서 setSendZuulResponse 가 뭘 하는건진 잘 모르겠다. 허허.

zuul javadoc에 이렇게 써있긴하다..

AuthFilter.java

package com.sungan.apiGateway;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.sungan.apiGateway.response.SunganResponse;
import com.sungan.apiGateway.support.RequestContextHandler;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

@Component
public class AuthFilter extends ZuulFilter {

    private final RequestContextHandler requestContextHandler;

    public AuthFilter(RequestContextHandler requestContextHandler) {
        this.requestContextHandler = requestContextHandler;
    }

    @Override
    public String filterType() {
        return "route";
    }

    @Override
    public int filterOrder() {
        return -1;
    }

    @Override
    public boolean shouldFilter() { // true를 반환하면 해당 요청을 필터링한다.
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest req = context.getRequest();
        String uri = req.getRequestURI();
        
        // 지금 만드는 이 필터는 로그인 후 jwt를 decode할 때 사용하므로, 로그인을 하는 api에서는 비활성화를 시킴.
        boolean excludeFiltering = uri.matches("/api/v1/auth/.*")
        return !excludeFiltering; // true일 경우 run() 실행.
    }

    @Override
    public Object run() { // filtering 메서드
        try {
            requestContextHandler.basicAuth(); // jwt를 decode하여 header에 userId를 넣어 준다.
        } catch (SignatureException e) { // 실패시 필터
            requestContextHandler.setFailedRequest(new SunganResponse(HttpStatus.BAD_REQUEST, "Unable to verify RSA signature."));
        } catch (ExpiredJwtException e) {
            requestContextHandler.setFailedRequest(new SunganResponse(HttpStatus.UNAUTHORIZED, "Token expired."));
        } catch (IllegalArgumentException e) {
            requestContextHandler.setFailedRequest(new SunganResponse(HttpStatus.UNAUTHORIZED, "Invalid token."));
        } catch (Exception e) {
            requestContextHandler.setFailedRequest(new SunganResponse(HttpStatus.UNAUTHORIZED, "Token required."));
        } finally {
            return null;
        }
    }


}

0개의 댓글