[CLOUD NATIVE] 클라우드 네이티브한 서비스 만들기[3] - 인증과 인가

seongbin·2025년 11월 12일

CLOUD NATIVE

목록 보기
4/7

Spring Security?

msa구조에서는 모놀리식과 다르게 Spring Security를 사용하기 어렵다.
우선 서비스 별로 세션을 공유할 수 없기에 세션 방식은 구현이 불가능하다.
그래서 필연적으로 토큰 방식을 사용해야하는데 Security의 필터체인을 사용하려면 서비스별로 Security를 구현해야한다.
그래서 중복적인 코드를 피하고 싶고, 인증 관련 로직은 apigateway에만 두고 싶다는 이유로 Security에 의존하지 않고 인증을 구현하였다.

Authentication

위에서 말한 것처럼 SpringSecurity없이 소셜로그인(구글)+JWT 구조로 인증을 진행할 것이다.
해당 글에서는 Security나 jwt 관련 글보다는, 클라우드 네이티브여서 모놀리식 방식과 다른 점 위주로 작성해볼 것이다.

또 프론트엔드에게 소셜로그인 서버에서 'code'를 받아 백엔드에게 넘겨주는 방식을 가지고 로그인을 구현할 것이다.

나는 AuthService와 Auth관련 엔드포인트를 마이크로서비스 중 user-service에 넣어서 구현하였는데 아예 따로 auth-service를 만들어서 관리해도 될 것 같다.
사견이지만 user를 생성하고 관리하는 거니까 그냥 user-service에다가 하는게 좋다.
(auth-service를 따로 만들면 유저 생성시 user-service에 요청해야 함, db 공유 안 됨, 컨테이너 추가로 관리해야함)

토큰을 만드는 곳은 auth-service고 토큰을 검증하는 곳은 apigateway-service이기에 JwtTokenProvider를 중복으로 사용해야하긴 한다.
토큰을 생성하고, 검증한다는 차이가 있기에 이름을 달리해서 사용해도 좋을 것 같지만 둘 다 많이 쓰이는 JwtTokenProvider로 이름을 붙였다.

코드 구현

모든 요청은 apigateway를 거쳐서 오기 때문에 apigateway-service의 yml파일에 경로 WhiteList를 만들어서 특정 경로가 WhiteList에 속한다면 JWT 검증없이 내부 서비스에 들어갈 수 있게 구현하였다 (회원가입/로그인시 accessToken이 없기 때문에).

로그인 흐름은 다음 블로그를 참고해서 구현했다. [OAuth] 프론트/백의 소셜로그인 협업

apigateway-service/application.yml

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: user-service
              uri: http://localhost:8081
              predicates:
                - Path=/api/v1/users/**
            - id: group-service
              uri: http://localhost:8082
              predicates:
                - Path=/api/v1/groups/**
            - id: todo-service
              uri: http://localhost:8083
              predicates:
                - Path=/api/v1/todos/**
           default-filters:
             - name: AuthorizationHeaderFilter  
                
# 경로 화이트리스트
gateway:
  jwt:
    whitelist:
      - /api/v1/users/auth/**
      - /**/v3/api-docs
      - /swagger-ui/**
      - /swagger-ui.html
      
app:
  jwt:
    secret: {JWT_SECRET_KEY}

AuthorizationHeaderFilter는 요청마다 jwt 토큰을 검증하기 위한 필터이다.아래에 구현할 것이다.

경로 화이트리스트를 만들어 해당 경로에 오는 요청들은 필터를 거치지 않고 넘길 것이다.
스웨거 관련 엔드포인트도 일단 열어두어 프론트가 활용하기 쉽게 구현하였다.

jwt-secret key는 user-service/application.yml에 들어가는 값과 같아야 한다.

apigateway-service/AuthorizationHeaderFilter.java

/*
    요청마다 jwt accesstoken을 검증하기 위한 필터
 */
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

    private final JwtTokenProvider tokenProvider;
    private final JwtProperties jwtProperties;
    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    public AuthorizationHeaderFilter(JwtTokenProvider tokenProvider, JwtProperties jwtProperties) {
        super(Config.class);
        this.tokenProvider = tokenProvider;
        this.jwtProperties = jwtProperties;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String path = exchange.getRequest().getURI().getPath();

            // 화이트리스트 체크
            if (jwtProperties.getWhitelist().stream().anyMatch(pattern -> pathMatcher.match(pattern, path))) {
                return chain.filter(exchange);
            }

            HttpHeaders headers = exchange.getRequest().getHeaders();
            String authorizationHeader = headers.getFirst(HttpHeaders.AUTHORIZATION);

            if (!authorizationHeader.startsWith("Bearer ")) {
                return onError(exchange, "Invalid authorization header");
            }

            String token = authorizationHeader.substring(7); // "Bearer " 제거
            if (!tokenProvider.validToken(token)) {
                return onError(exchange, "Invalid access token");
            }

            // userId를 헤더에 추가
            String subject = tokenProvider.getSubject(token);
            ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .header(GateWayConstant.GATEWAY_AUTH_HEADER, subject)
                    .build();

            log.info("AuthorizationHeaderFilter: userId= {}", mutatedRequest.getHeaders().get(GateWayConstant.GATEWAY_AUTH_HEADER));

            return chain.filter(exchange.mutate().request(mutatedRequest).build());
        };
    }

    private Mono<Void> onError(org.springframework.web.server.ServerWebExchange exchange, String errorMsg) {
        log.error(errorMsg);

        ServerHttpResponse response = exchange.getResponse();

        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = String.format("{\"code\": %d, \"message\": \"%s\"}",
                HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 유저입니다.");
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));

        return response.writeWith(Mono.just(buffer));
    }

    public static class Config {}
}

이 포스트에서 가장 중요한 부분이라고 단언할 수 있을 코드다.
전제 조건은 apigateway-service에는 JWT 토큰 검증을 하는 tokenprovider 가 있고, user-service에서는 JWT 토큰 생성을 하는 tokenprovider가 있는 것이다.
security에 관련된 코드는 너무 길어질 것 같아 제외하겠다.

Spring Cloud Gateway - 커스텀 필터 만들기 이 블로그를 참고해서 커스텀했다.

우선 프론트는 백엔드에게 비동기로 요청을 보내기 때문에 webflux-mono를 사용하였다.

동작과정은 경로를 받아서 화이트리스트에 존재한다면 바로 다음 chain으로 넘겨주고 (토큰 검증없이),
아니라면 헤더에서 accessToken을 검증해 토큰 값이 올바르다면 userId를 추출해 GateWayConstant.GATEWAY_AUTH_HEADER를 키 값으로 헤더에 userId를 실어준다.
이후 다음 filter로 넘겨준다.

apigateway-service/JwtProperties.java

/*
    .yml 파일에서 whitelist 설정값을 가져오기 위한 클래스
    whitelist : JWT 검증을 우회할 경로들
 */
@Setter
@Getter
@Component
@ConfigurationProperties(prefix = "gateway.jwt")
public class JwtProperties {

    private List<String> whitelist = new ArrayList<>();

}

whitelist를 application.yml에서 가져와서 AuthorizationHeaderFilter에서 사용할 수 있게 해주는 코드다.

CORS 설정

@Configuration
public class CorsConfig {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);           // 쿠키 허용
        config.setAllowedOrigins(Arrays.asList({허용할 도메인 입력]));       // 도메인 설정
        config.addAllowedHeader("*");               // 모든 헤더 허용
        config.addAllowedMethod("*");               // 모든 HTTP 메서드 허용

        config.addExposedHeader("Authorization"); // 브라우저가 응답에서 Authorization 값을 읽을 수 있도록 설정

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); // 모든 경로에 적용

        return new CorsWebFilter(source);
    }
}

인증/인가에 직접적으로 관계는 없지만, 테스트시 cors 설정이 없으면 제대로 동작을 안할 가능성이 높기 때문에 추가해주었다. 어차피 나중에 무조건 추가해줘야 한다.

WebFluxSecurityConfig.java - 실패

@Configuration
@RequiredArgsConstructor
@EnableWebFluxSecurity
public class WebFluxSecurityConfig {

    @Bean
    public SecurityWebFilterChain configure(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable) // csrf 비활성화
                .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) // cors 설정
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable) // formLogin 비활성화
                .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))// login dialog disabled & 401 HttpStatus return
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // jwt 기반이므로 securityContext에 저장 x
                .authorizeExchange(auth -> auth
                        .pathMatchers("/api/v1/users/auth/**").permitAll() // login 엔드포인트
                        .anyExchange().authenticated()
                )
                .build();
    }
    

처음에는 이렇게 WebFluxSecurityConfig를 만들어서 사용하려고 했다.
하지만 계속 요청시 팝업 로그인이 뜨는 문제가 발생했다.

분명히 .httpBasic을 disable했지만 계속해서 문제가 생겼다.
추측으로는 apigateway-service이후에 내부 서비스에 가서 기본 인증을 또 시도해서 팝업창이 계속 뜬 것 같다.
apigateway-service이외에는 토큰 검증로직을 넣고 싶지 않았기에 spring security를 사용하는 것을 포기하고 내가 직접 필터를 만들게 된 이유 중 하나다.

etc

코드들을 작성하진 않겠지만 내가 작성한 코드들은 다음과 같다.

  • AuthService - 로그인 서비스 로직를 수행
  • AuthController - login 엔드포인트를 받아옴
  • JwtTokenProvider - user-service와 apigateway-service 각각 구현
  • GoogleLoginService - code를 가지고 user 정보를 가져오는 서비스
  • GoogleTokenClient - 프론트한테 인가 code를 받아서 구글 서버 accesstoken을 받아오는 서비스
  • LoginApiClient - oauth 서버에서 accessToken을 가지고 실제 user 정보를 가져오는 서비스
  • 기타 도메인, dto등등

테스트 방법 (백엔드)

  1. https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&redirect_uri={redirect_uri} 들어가서 로그인 진행
  2. 이후 뜨는 브라우저에서 (ex. http://localhost:8080/login/oauth2/code/google?code=4%2F0AVGzR1C ... ) code 값을 가져옴
  3. 포스트맨을 활용해 method: POST, url: localhost:8080/{로그인 백엔드 엔드포인트}?code={code} 로 전송

당연히 소셜 서버에 redirect_uri 같은 설정들을 잘 해주어야 한다.

정리

  • msa 구조에선 토큰 방식으로 로그인을 진행한다.
  • apigateway에서만 토큰 검증을 진행하고 싶어서 spring security를 사용하지 않고 커스텀 필터(AuthorizationHeaderFilter)를 사용하였다.

0개의 댓글