Spring Boot 기반 다수의 프로젝트를 하나의 Multi Module 프로젝트로 통합하기 (3) - 인증과 인가는 Gateway로, 토큰 발급은 Auth Api로

서론

 

모든 Request는 WebGateway가 받아서 각 Reqeust에 맞는 Service들로 매핑시키도록 아래와 같이 구성도를 작성하였습니다. 해당 Request가 올바른 권한과 인증을 부여받은 Request인가를 판단하기 위해, 앞으로 인증과 인가는 WebGateway에서, 인증과 인가 없이 요청할 수 있는 예외의 Request(Login, SignUp 등)는 Filter를 구현받지 않고 바로 Auth Service로 통과시켜주는 Gateway를 구현해보도록 하겠습니다.

 

(Playground라는 프로젝트 명칭은 학습 및 연구하며 실제로 적용해보는 놀이터용도로써 만든 프로젝트로서 큰 의미는 없습니다.)

Playground (가칭) 서비스 구성도


1. module-webGateway 역할 정의

webGateway에서 모든 web 관련 Request를 컨트롤 할 예정이기 때문에 역할 부여가 중요합니다. 저는 Request가 올바른 사용자로부터 (인증) 올바른 권한에 맞게 (인가) 요청된 것인지 확인하는 역할을 부여하기로 했습니다.

 

또한, 모든 MicroService (RestAPI 등)에 대한 명세서를 확인할 수 있도록 Swagger UI를 설치하여 각각의 서비스에 대한 API 문서 확인도 담당하는 역할을 부여하였습니다.

 

각 역할에 맞는 외부 의존성을 주입받아야 하는데, 저는 아래와 같이 module-webGateway의 dependencies를 작성했습니다.

bootJar {
    enabled = true
}
jar {
    enabled = true
}

dependencies {
    implementation project(':module-core')
    implementation project(':module-redis')

    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    // Spring Cloud
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // Mac Os Dependency
    implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.6.0'
}

 

core 모듈에는 전역에서 사용할 예외 관련 Class, 상황에 맞게 사용하기 위한 여러가지 Custom Annotation (Json 형식 판단하는 JsonMatches 등)을 구현하였고, redis 모듈에서는 redis를 사용하기 위한 설정과 기능 구현이(Service 및 Util 단위) 완료되어있습니다.


2. Security Configuration

인증과 인가를 처리하기 위해서 SpringSecurity를 활용하기로 하였고, Spring Cloud Gateway의 최신 버젼부터는 MVC 방식이 아닌 Reactive 방식만을 지원하기 때문에 WebFluxSecurity를 사용하여 설정을 진행하였습니다.

@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class WebFluxSecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity, JwtTokenizer jwtTokenizer) {

        serverHttpSecurity
                .cors(Customizer.withDefaults())
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .anonymous(ServerHttpSecurity.AnonymousSpec::disable);

        serverHttpSecurity.authorizeExchange((authorize) -> authorize
                // Gateway 에서는 AuthorizationHeaderFilter 에서 jwt를 체크하므로 authenticated 설정 불 필요. 때문에 전체 PermitAll로 설정
                .anyExchange().permitAll()
        )
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
        ;

        serverHttpSecurity.logout(logoutSpec -> {
            logoutSpec.logoutUrl("/api/v1/auth/logout")
                    .requiresLogout(new PathPatternParserServerWebExchangeMatcher("/api/v1/auth/logout", HttpMethod.POST));
        });
        return serverHttpSecurity.build();
    }

    private static class StatelessWebSessionSecurityContextRepository implements ServerSecurityContextRepository {

        private static final Mono<SecurityContext> EMPTY_CONTEXT = Mono.empty();

        @Override
        public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
            return Mono.empty();
        }

        @Override
        public Mono<SecurityContext> load(ServerWebExchange exchange) {
            return EMPTY_CONTEXT;
        }
    }

}

주석을 살펴보면 알겠지만, security에서 authenticated()를 사용하여 인증인가를 구현하는 방식이 아닌, 모든 Request에 대한 permitAll()을 해준 후에, AuthorizationHeaderFilter 이름의 Filter를 만들어서 인증 인가가 필요한 uri에 해당하는 service에만 적용하여 filter chain방식으로 구현하였습니다.


3. AuthorizationHeaderFileter 생성

이 Filter에서는 request의 Header에 포함되어있는 JWT Token의 검증을 구현합니다. Token이 정상적인 토큰인지, 만료된 토큰이 아닌지, 아직 유효한 토큰이지만 로그아웃을 진행했던 토큰이 아닌지 등에 대해서 검증합니다.

@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

    private final JwtTokenizer jwtTokenizer;
    private final AuthLoginService authLoginService;

    @Autowired
    public AuthorizationHeaderFilter(JwtTokenizer jwtTokenizer, AuthLoginService authLoginService) {
        super(Config.class);
        this.jwtTokenizer = jwtTokenizer;
        this.authLoginService = authLoginService;
    }

    @Override
    public GatewayFilter apply(AuthorizationHeaderFilter.Config config) {
        return (exchange, chain) -> {

            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("request uri : {}", request.getURI());

            log.warn("request headers : {}", request.getHeaders());

            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                response.setStatusCode(HttpStatus.UNPROCESSABLE_ENTITY);
                return response.setComplete();
            }

            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String jwt = authorizationHeader.replace("Bearer ", "");
            log.info("jwt : {}", jwt);

            // JWT 검증
            if (!isJwtValid(jwt)) {
                response.setStatusCode(HttpStatus.UNAVAILABLE_FOR_LEGAL_REASONS);
                return response.setComplete();
            }

            // Logout Token 검증
            if (isLogoutToken(jwt)) {
                response.setStatusCode(HttpStatus.FORBIDDEN);
                return response.setComplete();
            }

            return chain.filter(exchange);

        };
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        log.error(err);
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        return response.setComplete();
    }

    private boolean isJwtValid(String jwt) {

        Claims claims = jwtTokenizer.parseAccessToken(jwt); // 토큰에서 클레임을 파싱

        if (claims == null || Strings.isBlank(claims.getSubject())) { // 클레임이 없거나 이메일이 없으면 false
            return false;
        }

        String email = claims.getSubject(); // 이메일을 가져옴
        String id = claims.get("id", String.class); // 사용자 ID를 가져옴
        String name = claims.get("name", String.class); // 이름을 가져옴
        return true;
    }

    private boolean isLogoutToken(String jwt) {
        return !authLoginService.isLogin(jwt);
    }

    public static class Config {}
}

 

(참고)

저는 로그아웃을 할 경우 만료되지 않은 토큰이라도 사용하지 못 하게 하는 블랙리스트 방식의 기능을 구현했습니다. 해당 내용은 별도로 포스팅 할 예정입니다.


4. Application Yml 설정을 통한 Gateway 구성

Spring을 사용하며 참 좋다고 생각들었던 것은, 모든 구현이 비지니스로직을 통해서 이뤄지는 것이 아닌, 설정파일(Yml 등)에 설정만 잘 해주면 알아서 완성시켜주는 각 spring project(cloud, security 등)가 가지고 있는 매커니즘이였습니다. 비지니스로직을 통해서 구현하는 방법도 있었지만, 저는 yml 파일 설정을 통해 webGateway를 구현하였습니다.

 

1. JWT 관련 설정

우선 Jwt 토큰을 해석하기 위해 토큰을 발행하는 Auth Module에 있는 jwt 설정 키가 필요합니다.

jwt:
  access:
    secret: auth-service-jwt-access-secret
    expire: 86400000
  refresh:
    secret: auth-service-jwt-refresh-secret
    expire: 2592000000

2. Spring Cloud Gateway 설정

저는 Eureka Server를 사용하여 모든 어플리케이션을 관리하고 있습니다. 이 점 참고하여서 Eureka 사용을 안 할 경우에는 uri에 각 서비스의 절대경로를 사용하면 됩니다.

spring:
  application:
    name: playground-web-gateway
  config:
    import: "optional:configserver:"
  cloud:
    gateway:
      # 먼저 선언한 순서대로 필터 적용.
      routes:
        - id: auth-api
          # Eureka 사용할 경우
          uri: lb://auth-api
          # Eureka 사용 안할 경우
          #uri: http://localhost:18085
          # /api/v1/member/** 경로로 들어오는 요청을 member-api로 라우팅
          predicates:
            - Path=/api/v1/auth/login, /api/v1/auth/signup
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2

        - id : auth-api
          uri: lb://auth-api
          predicates:
            - Path=/api/v1/member/**, /api/v1/auth/logout
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2
            - AuthorizationHeaderFilter

        - id: article-api
          uri: lb://article-api
          predicates:
            - Path=/api/v1/article/test
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2

        - id: article-api
          uri: lb://article-api
          predicates:
            - Path=/api/v1/article/**
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2
            - AuthorizationHeaderFilter

        - id: mail-api
          uri: lb://mail-api
          predicates:
            - Path=/api/v1/mail/**
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2
            - AuthorizationHeaderFilter
  main:
    web-application-type: reactive

management:
  endpoints:
    web:
      exposure:
        include: gateway
  endpoint:
    gateway:
      enabled: true

내용을 잘 보시면 filters쪽에 인증 및 인가가 필요한 Request에는 AuthorizationHeaderFilter를 적용한 것을 확인할 수 있습니다.

3. Swaager를 위한 springdoc 작성

RestAPI로 사용되는 모든 어플리케이션의 spring docs url과 맵핑지어 webgateway/swagger-ui.html 에서 한 번에 확인할 수 있도록 설정해줍니다.

springdoc:
  swagger-ui:
    urls[0]:
      name: Auth API
      url: ${server.host}:${app.ports.auth-api}/api/v1/auth/v3/api-docs
    urls[1]:
      name: Article API
      url: ${server.host}:${app.ports.article-api}/api/v1/article/v3/api-docs
    urls[2]:
      name: Mail API
      url: ${server.host}:${app.ports.mail-api}/api/v1/mail/v3/api-docs
    use-root-path: true
    enabled: true
    path: /swagger-ui.html
    config-url: /v3/api-docs/swagger-config

5. 구동 및 테스트

Eureka application, WebGateway Application, Auth Api Application, Article Api Application, Mail Api Application, Rabbitmq Consumer Application 총 6개의 Application을 구동시킵니다.

 

Eureka 접속

http://localhost:8761 주소로 접속하면 현재 로컬에 구동되어있는 Eureka Server 현황을 확인할 수 있습니다.

모든 Application이 잘 구동되어있는 것을 확인할 수 있었습니다.

Swaager 확인

http://localhost:8080/swagger-ui.html 주소로 접속하면 우측 상단에 Select a definition 셀렉트박스를 통해서 구성된 springdocs를 전부 확인할 수 있습니다.

HTTP Test

마지막으로 Http Request를 통한 실제 API들을 점검해봅니다. host를 webGateway 포트인 8080포트로 지정한 후에 테스트를 진행합니다.