안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
이번 포스팅은 AWS로 서버를 배포하는 과정에서 발생했던 Spring Cloud Gateway의 Authentication 문제에 대해서 다뤄보려고 합니다.

 

 

1. Sprong Cloud Gateway 역할 


Gateway Server는 Spring Cloud로 구성할 수 있으며 MSA 아키텍처에서 중요한 역할을 수행하는 서버입니다. Spring Cloud Gateway는 각 서비스별 요청을 라우팅 하고, 보안 및 로드밸런싱 기능을 제공할 수 있습니다.

 

먼저 라우팅 기능은 MSA 아키텍처로 구성되어 있는 다양한 서비스가 gateway에 연결되어 요청 라우팅을 처리할 수 있습니다.

보안적인 측면에서는 다양한 필터를 통해 요청에 대한 응답이나 보안 처리, 승인 및 거부 등을 할 수 있습니다. 또한 로드 밸런싱의 기능도 포함하고 있습니다. 라운드 로빈, 가중치 로빈 밸런싱 등의 알고리즘을 제공하고 있습니다.

Spring Cloud Gateway는 Discovery Client를 제공합니다. 이를 통해 Eureka 서버에 등록하여 라우팅 되는 서비스 등을 자동화하여 처리할 수 있습니다.

 

제가 적용하고자 한 아키텍처 구조도에서는 GateWay Server의 역할을 확인할 수 있습니다.
각 비즈니스 서비스는 Gateway-server에 라우팅 되어 있고 인증 인가 처리를 Gateway-server에서 담당하고 있습니다.

 

 

 

2. Spring Cloud Gateway 설정의 어려움과 에러 발생

 

저는 Spring Cloud가 익숙하지 않아서 Gateway를 개발하는데 많은 어려움이 있었습니다. ReactiveWebFlux 형태로의 코드 작성이 기반이 되어야 하다 보니 익숙하지 않았고, 도중에 MVC 관련 코드도 많이 있었습니다. 하지만 배우면서 여러 번 작성해 보고 에러를 통해 성장할 수 있기 때문에 이를 꾸준히 리펙토링 하면서 에러를 해결하고자 하였습니다.


제가 겪었던 문제는 Authentication 인증 관련 부분입니다. 저는 Gateway-Server에서 라우팅 역할과 Jwt 인증을 처리하는 역할을 수행하도록 코드를 작성하였습니다. Local 상에서는 문제가 발생하지 않았지만, AWS로 Dev용 Config Server와, Eureka Server를 배포하고 Gateway 서버를 연결하는 과정에서 많은 에러에 봉착하였습니다. 

 

첫 번째는, 모든 Uri에 401 Error가 발생하였습니다. 이는 곧 잘못된 설정 정보로 인한 Gateway-Server가 작동되지 않는다는 것을 의미하였습니다. 디버깅 모드로 Authentication 관련 코드를 찍어보아도, 디버깅을 거치지 않은 채로 Intellij에서 401 에러를 반환하였습니다.

 

두 번째는, 회원가입과 로그인을 마친 유저가 Jwt 토큰으로 인증을 처리한 후 라우팅된 서비스로 요청이 위임되어 로직을 수행할 때, 401 Error가 발생한 것이었습니다. 저는 Gateway에서 인증 정보를 SecurityContext에 저장하지 않고 라우팅된 서비스에서 SecurityContext를 저장하도록 코드를 작성하였습니다. 따라서, Gateway에서는 JWT 토큰을 검증하고 검증된 유저라면 서비스 로직으로 이동하여 SecurityContext에 저장이 되어 비즈니스 로직을 수행할 수 있었어야 했습니다.

하지만,  Jwt 인증이 되어 라우팅이 되어야하는 요청이 401 Error가 발생하였습니다.

 

마지막 문제는 이 모든 것을 해결했을 때, 발견한 문제입니다. gateway의 AuthorizationHeaderFilter를 거치지 않고 요청이 처리되고 있었다는 점입니다. 분명 Jwt 요청이 실패하면 400 error 코드가 발견되어야 하는데 500 에러가 발생하였습니다.

 

3. 문제 해결 과정 - Filter에 대한 이해

 

이 과정에서 먼저 각 필터들의 역할을 정리하는 단계를 수행하였습니다. 이것저것 적용하다 보니 어떠한 역할을 수행하는지 이해하지 못하여 복합적인 문제가 발생하였습니다. 따라서 먼저 Spring Cloud Gateway에서 적용할 수 있는 필터를 정리하고자 합니다.

 

 

a. GatewayFilter

 

GatewatFilter는 Spring Cloud Gateway에서 Http 요청 및 응답을 필터링하는 역할을 수행합니다.
구체적으로 <인증 및 권한 부여, 요청 및 응답 수정, 요청 거부> 등의 역할을 수행하며, LoggingFilter로 로그 기록을 남기거나 ReWritePathFilter로 요청 경로를 수정할 수 있습니다. GatewayFilter의 인터페이스를 구현한다면 추가적인 로직을 작성할 수 있습니다.

 

 

b. AbstractGatewayFilterFactory

 

AbstractGatewatFilterFactory는 Spring Cloud Gateway에서 GatewayFilter를 생성하는 팩토리 클래스입니다.
이 팩토리를 활용하면, 필터 인스턴스를 생성 및 초기화, 구성을 할 수 있습니다. 
구체적으로 , 팩토리로 생성된 인스턴스는 SpringBean으로 등록되며, Spring ApplicationContext에서 사용할 수 있습니다.

또한 인스턴스의 설정 값을 지정할 수 있고, 인스턴스의 종속성을 주입하거나 필터 인스턴스 간의 관계를 설정할 수 있습니다.

 

 

c. SecurityWebFilterChain


SecurityWebFilterChain는 Spring WebFlux에서 사용하는 SecurityFitlerChain입니다.
Spring Security 최신 버전은 SecurityFilterChain을 활용하여 SecurityConfig를 작성하도록 하고 있습니다.
이때, 주의할 점은 SecurityFilterChain은 Spring MVC에서 적용하는 보안 필터 체인이기 때문에 
현재 사용하고 있는 프레임워크에 따라 다른 필터를 적용하여야 합니다.

 

 

4. 문제 해결 과정 - DEBUG 로그 확인

 

logging:
  level:
    org.springframework.security: DEBUG

 

application.yml을 활용하면, 발생 원인을 DEBUG 모드로 확인할 수 있습니다. 이를 적용하여 발생한 원인에 대해서 파악해 보도록 하였습니다. 

 

2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@840d33d 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d' 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied 2023-03-09T22:36:15.513+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d'

 

이 문제는 WebSession과 Jwt는 다른 인증 방식이지만, 현재 WebSession에 대한 처리를 하지 않았기 때문에 Security 인증 이후 WebSession에 값을 입력하지 않아 발생한 에러입니다. 즉 SecurityContext가 WebSession에서 값을 찾을 수 없기에 발생한 것이라고 추측할 수 있습니다.

 

2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@310d058b 2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied

 

다음 에러는 Access Denied로 인가 문제가 발생한 부분입니다. Jwt 토큰이 인증되었음에도 불구하고 인가 처리가 되지 않아 Gateway에서 member-service로 라우팅이 되지 않는 문제가 발생하였습니다. 

 

 

5. 문제 해결 과정 - 코드 수정하기 

 

먼저 No SecurityContext found in WebSession 관련 부분입니다.

@Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.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;
        }
    }

 

저는 Jwt 토큰을 인증하는 역할만 Gateway에 부여했기 때문에, WebSession에서 인증 정보를 관리할 필요가 없다고 판단하였습니다. 따라서, StatelessWebSessionSecurityContextRepository라는 CustomRepository를 생성하여 Mono.empty() 빈 값을 주입하고 빈 값을 load 하는 코드를 추가하였습니다. 그 결과, WebSession 관련 문제를 해결할 수 있었습니다.

 

 

다음 문제는 AccessDenied 문제입니다.
저는 Jwt 토큰 인증 처리를 앞 서 설명한 AbstractGatewayFilterFactory를 상속한 AuthorizationHeaderFilter에서 이 역할을 수행하도록 구현하였습니다. 따라서 만약 Jwt 토큰에 인증에러가 발생하면 앞 단에서 요청 거부를 처리하기 때문에 SecurityWebFilterChain에서는 이 역할을 수행할 필요가 없었습니다. 뿐만 아니라, 저는 WebSession에 값을 넣지 않는 방법을 이용했기 때문에 Spring은 특정 permitAll()로 선언된 whiteList가 아닌 anyRequest.authenticated()에서 인증이 되지 않았다고 판단이 되어 요청을 거부한 것이었습니다.


따라서 코드를 수정한 결과는 다음과 같습니다.

현재 주석된 부분을 제거한 것입니다. 즉, 현재 모든 요청은 permitall()로 되어 있지만 앞 단의 GatewayFilter에서 jwt 토큰을 검증하여 요청을 승인 및 거부하고 있기 때문에 WebSession에서 에러가 발생하지 않고 Access Denied도 발생하지 않았습니다.

 

    @Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.build();

    }

 

마지막 문제는 RouteLocater의 위치 문제였습니다.
routes()는 선언된 라우터에 일치하는 라우터에 먼저 적용됩니다.
저는 기존에는 다음과 같이 작성하였습니다. 하지만 이 경우 만약 /member-service/users의 요청이 오면 하단의 요청이
수행되는 것이 아니라 /**에 포함되기 때문에 상단 요청이 먼저 처리되는 것이었습니다. 

 

.route("member-service", r -> r
        .path("/member-service/**")
        .uri("lb://member-service")
)

.route("member-service", r -> r
        .path("/member-service/users")
        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
        .uri("lb://member-service"))

 

저는 이 문제를 해결하기 위해 라우터 하는 path를 재조정하여 filter 처리가 필요한 uri를 먼저 선언하여 필터에 적용되도록 수정하였습니다. 

 

@Configuration
public class FilterConfig {

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeaderFilter authorizationHeaderFilter) {
        return builder.routes()

                .route("member-service", r -> r
                        .path("/member-service/users")
                        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
                        .uri("lb://member-service"))
                

                .route("member-service", r -> r
                        .path("/member-service/**")
                        .uri("lb://member-service"))

 

 

6. 코드 테스트

 

이것을 바탕으로 Intellij에서 코드를 검증하는 단계를 거쳤습니다.

 

먼저 gateway-server와 member-service가 정상 작동하는 것을 확인할 수 있습니다.

 

 

### 1. 폼 로그인 성공

POST {{baseUrl}}/login
Content-Type: application/json
Accept: */*
Connection: keep-alive

{
  "email": "kose@naver.com",
  "password": "kose123456"
}

> {%
    client.test("폼 로그인 회원 가입 성공, 토큰 클라이언트 저장", function() {
        client.assert(response.status === 200, "Response status is OK");
        client.global.set("accessToken", response.body.accessToken);
        client.global.set("refreshToken", response.body.refreshToken);
        client.global.set("userId", response.body.userId);
        client.log(client.global.get("accessToken"));
        client.log(client.global.get("refreshToken"));
        client.log(client.global.get("userId"));

    });
%}

 

만약 아이디나 패스워드를 달리하면 다음의 결과가 나옵니다.

 

 

이제 유저 정보를 확인하는 uri입니다.

해당 요청은 accessToken, refreshToken, userId를 필요로 합니다.

 

### 1. 유저 정보 요청

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer {{accessToken}},
RefreshToken: {{refreshToken}},
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 200, "Response status is OK");
    });
%}

 

인증 정보를 달리하면 다음과 같이 테스트를 실패합니다. 

 

### 2. 유저 정보 요청 인증 실패

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ
RefreshToken: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ,
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 400, "Response status is OK");
    });
%}

 

7. 느낀 점과 반성

 

최근에 개발을 하며, 좋아 보이는 것 혹은 있어 보이는 설계에 많은 관심이 있었던 것 같습니다.

야생적으로 이것저것 시도해 보는 것도 중요하지만, 그러한 시도 속에서 섬세한 부분에 대한 명확한 이해가 바탕이 되었어야 하는데, 정작 중요한 기술에 대한 견고함이 많이 부족했던 것 같습니다.

Dev 서버를 운영하며 local에서 이상 없었던 부분에 대해서 다시 테스트하고 코드의 견고함을 추가할 수 있었고,

이전 테스트에서 확인하지 못했던 부분까지 확인하니 정말 뿌듯한 하루가 될 수 있었던 것 같습니다.

 

혼자 프로젝트를 설계하고 배포까지 진행하다 보니 많이 힘들고 지치지만,

개발자로 살아갈 수 있다는 것이 내일 또 힘내서 개발할 수 있게 하는 원동력이 되는 것 같습니다.

 

존경하는 영한님의 말씀인 "기술적 겸손함"을 항상 되새기도록 하겠습니다.

오늘은 이만 글을 마치도록 하겠습니다.!

읽어주셔서 감사드립니다.!

+ Recent posts