Gateway에서 JWT 인증하기

최인호·2025년 3월 18일

게이트웨이에 SecurityConfig를 추가

이전게시물

MSA 아키텍처에서는 각 마이크로서비스마다 인증 로직을 반복 구현해줘야 하는데, 이러한 단점을 보완하며 모든 서비스에 동일한 인증 및 권한 부여 정책을 적용하기위해 게이트웨이 패턴을 적용하기로 했다.

  • 이점으로는 각 서비스는 인증 로직 없이 비즈니스 로직에만 집중 가능
  • 서비스 확장시에 용이

1. SecurityConfig

pom.xml 의존성 추가하기

  <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

SecurityConfig

package com.nhnacademy.gateway.config;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http){
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers("/api/auth/**", "/login", "/register", "/css/**", "/js/**")
                        .permitAll()
                        .anyExchange().authenticated()
                )
                .build();
    }
}
  • @EnableWebFluxSecurity: Spring WebFlux 환경에서 Spring Security를 활성화하는 어노테이션
  • 리액티브 환경에 맞게 설계됨

💡 리액티브 환경

비동기&논블로킹 방식으로 동작하는 환경
요청이 들어와도 쓰레드를 계속 점유하지 않고, 다른 요청을 처리할 수 있음

🛠 블로킹 vs 논블로킹 비교

1️⃣ 블로킹 I/O (Spring MVC 환경)

@GetMapping("/user")
public String getUser() {
    return userService.getUser(); // DB에서 데이터를 가져올 때까지 스레드 대기 (Blocking)
}

요청이 들어오면 getUser() 실행 후 완료될 때까지 스레드가 블로킹됨.
동시 요청이 많아지면 스레드가 부족해지고 성능 저하 발생.
2️⃣ 논블로킹 I/O (Spring WebFlux 환경)

@GetMapping("/user")
public Mono<String> getUser() {
    return userService.getUser(); // DB에서 데이터 가져오는 동안 스레드는 다른 작업 수행 (Non-blocking)
}

요청을 받으면 바로 Mono을 반환하고 스레드는 다른 요청을 처리.
DB 응답이 오면 그때 결과를 전달.

2. JwtAuthenticationFilter

@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    private final JwtProvider jwtProvider;
    private final List<String> allowedPaths;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
        this.allowedPaths = List.of("/api/auth/login", "/api/auth/register", "/login", "/register");
    }
  
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return null;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}
  • import org.springframework.beans.factory.annotation.Value;
  • Lombok의 Value ❌
  • key: JWT 토큰 서명을 검증하는 데 사용되는 키
  • allowedPaths: 인증 없이 접근 가능한 경로 목록

생성자

  • @Value("${jwt.secret}"): application.properties 또는 application.yml 파일에서 jwt.secret 값을 주입받음
  • Keys.hmacShaKeyFor(): 바이트 배열로부터 HMAC-SHA 키를 생성, JWT 토큰 검증에 사용됨
  • allowedPaths: 인증이 필요 없는 경로 목록을 초기화

2-1. filter 메소드 구현

  • import org.springframework.http.server.reactive.ServerHttpRequest;

  • Spring-Cloud-Gateway는 리액티브 기반이기에 패키지 클래스 임포트

    2-2 JwtProvider

    • 게이트웨이에서는 토큰 생성할 필요 없이 검증만 하고
    • 백엔드에서는 validateTokenOrThrow(String token) 메서드가 있어 토큰이 유효하지 않을 경우 예외를 발생 시키는데, 게이트웨이는 필터 내에서 직접 토큰 유효성을 확인하고 401 응답을 반환
    • 위의 사항을 고려 최소한의 기능으로 만든 JwtProvider도 Gateway에 만들어줘야함
    package com.nhnacademy.gateway.security;
    
    import io.jsonwebtoken.Claims;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.security.Keys;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Component;
    
    import java.security.Key;
    
    @Component
    public class JwtProvider {
    
        private final Key key;
    
        public JwtProvider(@Value("${jwt.secret}") String secretKey) {
            byte[] keyBytes = secretKey.getBytes();
            this.key = Keys.hmacShaKeyFor(keyBytes);
        }
    
        // 토큰 검증
        public boolean validateToken(String token) {
            try {
                Jwts.parserBuilder()
                        .setSigningKey(key)
                        .build()
                        .parseClaimsJws(token);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    
        // 토큰에서 이메일 추출
        public String getEmailFromToken(String token) {
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
            return claims.getSubject();
        }
    }
    
  • filter 메서드는 Spring Cloud Gateway에서 모든 요청이 통과하는 필터 로직을 구현

  • public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) 
  • 파라미터로 웹 요청과 응답에 대한 컨테이너인 SeverWebExchange, 필터 체인을 위한 파라미터

  @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 요청 정보 추출
        ServerHttpRequest request = exchange.getRequest(); // Spring WebFlux에서 HTTP 요청 정보를 담고 있는 객체
        String path = request.getURI().getPath(); // 요청 URL에서 경로 부분만 추출 ("/api/auth/login")

        // 2. 인증 예외 경로 확인
        if(isAllowedPath(path)){
            return chain.filter(exchange); // 인증 필요없는 경로면 다음 필터로 요청 전달
        }

        // 3. 정적 리소스 확인
        if (path.startsWith("/css/") || path.startsWith("/js/")) {
            return chain.filter(exchange);
        }

        // 4. JWT 토큰 추출
        String token = extractToken(request); // Authorization" 헤더에서 "Bearer " 다음에 오는 문자열을 토큰으로 추출

        // 5. 토큰 검증 및 오류 처리
        if (token == null || !jwtProvider.validateToken(token)) {
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete();
        }

        // 6. 사용자 정보 추가
        String email = jwtProvider.getEmailFromToken(token);

        ServerHttpRequest modifiedRequest = request.mutate()
                .header("X-User-Email", email)
                .build();

	  // 7. 수정된 요청 전달
        return chain.filter(exchange.mutate().request(modifiedRequest).build());
    }  
  1. 요청 경로를 확인하여 인증이 필요한지 판단
  2. 인증이 필요없는 경로인지 판단 후 필요없을 시 다음 필터로 요청 그대로 전달
  3. 경로가 정적 리소스인지 확인, 정적 리소스는 인증 없이 접근 가능 해야하기 때문
  4. Authorization 헤더에서 Bearer 토큰 추출
  5. 토큰이 없거나, 유효성 검증 실패시 ,401 상태코드 설정 후 응답 완료 후 클라이언트에게 반환
    -> 즉 유효하지 않은 토큰 접근 시 인증 오류 반환
  6. 토큰에서 사용자 이메일 추출 후, mutate() 메소드를 통해 기존 요청 객체를 수정하기 위한 빌더 생성
    -> header로 사용자 이메일 정보 추가
    -> .build() 수정된 요청 객체 생성
  7. 수정된 요청을 전달하는데, 기존 교환 객체를 수정하기 위해 빌더 생성 후 수정된 요청 객체를 교환 객체에 설정 후 생성! -> 수정된 교환 객체를 다음 필터로 전달

2-2. getOrder 메소드

@Override
  public int getOrder() {
      return -1;
  }
  • JWT 인증 필터는 일반적으로 다른 필터보다 먼저 실행되어야 하므로 getOrder 값을 음수로 설정

이제 JWT 인증필터가 게이트웨이에 구현되었기에

  • 인증되지 않은 요청은 게이트웨이에서 차단
  • 각 마이크로서비스마다 JWT 인증 로직을 구현할 필요 없음
  • 모든 서비스에 동일한 인증 정책 적용됨

게이트웨이는 다음과 같은 역할을 수행

  • JWT 토큰 검증
  • 인증되지 않은 요청 차단 (401 응답 반환)
  • 인증된 요청에 사용자 정보 추가 (X-User-Email 헤더)
  • 인증된 요청을 적절한 백엔드 서비스로 라우팅

백엔드 서비스는 게이트웨이가 추가한 X-User-Email 헤더를 통해 사용자를 식별할 수 있으며, 추가적인 권한 검사나 비즈니스 로직에 집중할 수 있게 됨

0개의 댓글