공부/Spring

[Spring Cloud] Gateway PreFilter에 로그인 추가하기 (OAuth2 + JWT)

린구 2024. 8. 5. 15:59
반응형

 

 

MSA는 각 서비스가 독립적으로 배포되기 통신되므로 보안이 매우 중요하다.

데이터 보호, 인증 및 권한 부여 등을 통해 시스템의 보안성을 확보해야 한다.

 

📌 OAuth2 

`OAuth2`는 `토큰 기반`의 인증 및 권한 부여 프로토콜이다.

클라이언트 어플리케이션이 리소스 소유자의 권한을 얻어 리소스에 접근할 수 있도록 한다.

 

📌 JWT

`JWT` (Json Web Token)은 JSON 형식의 자가 포함 토큰으로, 클레임을 포함하여 사용자에 대한 정보를 전달한다.

JWT는 헤더, 페이로드, 서명으로 구성되고 암호화를 통해 데이터의 무결성과 출처를 보장한다.

 

`JWT`의 주요 특징으로는 자가 포함, 간결성, 서명 및 암호화가 있다.

자가 포함 토큰으로 토큰 자체에 모든 정보를 포함하고 있어 별도의 상태 저장이 필요 없다!

https://jwt.io/ 에서 토큰을 decode 할 수 있다.

 

📌 보안 구성 실습

이번 실습에서는 Cloud Gateway의 Pre 필터에서 JWT 인증을 진행한다.

Auth Service를 생성하여 로그인 기능을 아주 간단하게 구현하고 Pre 필터를 하나 더 생성하여 로그인을 체크한다.

 

로그인을 담당하는 Auth 서비스를 생성한다.

의존성에 jwt를 추가한다!

 

spring:
  application:
    name: auth-service

eureka:
  client:
    service-url:
      defaultZone: http://localhost:19090/eureka/

service:
  jwt:
    access-expiration: 3600000
    secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"

server:
  port: 19095

`application.yml` 파일

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class AuthConfig {

    // SecurityFilterChain 빈을 정의합니다. 이 메서드는 Spring Security의 보안 필터 체인을 구성합니다.
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 보호를 비활성화합니다. CSRF 보호는 주로 브라우저 클라이언트를 대상으로 하는 공격을 방지하기 위해 사용됩니다.
            .csrf(csrf -> csrf.disable())
            // 요청에 대한 접근 권한을 설정합니다.
            .authorizeRequests(authorize -> authorize
                // /auth/signIn 경로에 대한 접근을 허용합니다. 이 경로는 인증 없이 접근할 수 있습니다.
                .requestMatchers("/auth/signIn").permitAll()
                // 그 외의 모든 요청은 인증이 필요합니다.
                .anyRequest().authenticated()
            )
            // 세션 관리 정책을 정의합니다. 여기서는 세션을 사용하지 않도록 STATELESS로 설정합니다.
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        // 설정된 보안 필터 체인을 반환합니다.
        return http.build();
    }
}

보안 파일도 설정한다.

/auth/signIn 경로 외의 요청에는 모두 인증이 필요하도록 하였다.

 

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;

@Service
public class AuthService {

    @Value("${spring.application.name}")
    private String issuer;

    @Value("${service.jwt.access-expiration}")
    private Long accessExpiration;

    private final SecretKey secretKey;

    /**
     * AuthService 생성자.
     * Base64 URL 인코딩된 비밀 키를 디코딩하여 HMAC-SHA 알고리즘에 적합한 SecretKey 객체 생성
     * @param secretKey Base64 URL 인코딩된 비밀 키
     */
    public AuthService(@Value("${service.jwt.secret-key}") String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

    /**
     * 사용자 ID를 받아 JWT 액세스 토큰 생성
     *
     * @param user_id 사용자 ID
     * @return 생성된 JWT 액세스 토큰
     */
    public String createAccessToken(String user_id) {
        return Jwts.builder()
                .claim("user_id", user_id)
                .claim("role", "ADMIN")
                // JWT 발행자를 설정
                .issuer(issuer)
                // JWT 발행 시간을 현재 시간으로 설정
                .issuedAt(new Date(System.currentTimeMillis()))
                // JWT 만료 시간을 설정
                .expiration(new Date(System.currentTimeMillis() + accessExpiration))
                // SecretKey를 사용하여 HMAC-SHA512 알고리즘으로 서명
                .signWith(secretKey, io.jsonwebtoken.SignatureAlgorithm.HS512)
                // JWT 문자열로 컴팩트하게 변환
                .compact();
    }
}

JWT 토큰을 생성하고 반환하는 `AuthService`를 작성한다.

 

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    /**
     * 사용자 ID를 받아 JWT 액세스 토큰을 생성하여 응답
     *
     * @param user_id 사용자 ID
     * @return JWT 액세스 토큰을 포함한 AuthResponse 객체 반환
     */
    @GetMapping("/auth/signIn")
    public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
        return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id)));
    }

    /**
     * JWT 액세스 토큰을 포함하는 응답 객체
     */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class AuthResponse {
        private String access_token;

    }
}

토큰을 생성하는 `AuthController`도 작성한다!

 

그 후 기존 게이트웨이 설정 파일에 JWT 인증 및 `auth-service` 라우팅 정보를 추가한다.

- id: auth-service  # 라우트 식별자
          uri: lb://auth-service  # 'auth-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
          predicates:
            - Path=/auth/signIn  # /auth/signIn 경로로 들어오는 요청을 이 라우트로 처리
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.crypto.SecretKey;
@Slf4j
@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {

    @Value("${service.jwt.secret-key}")
    private String secretKey;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        if (path.equals("/auth/signIn")) {
            return chain.filter(exchange);  // /signIn 경로는 필터를 적용하지 않음
        }

        String token = extractToken(exchange);

        if (token == null || !validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange);
    }

    private String extractToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }

    private boolean validateToken(String token) {
        try {
            SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
            Jws<Claims> claimsJws = Jwts.parser()
                    .verifyWith(key)
                    .build().parseSignedClaims(token);
            log.info("#####payload :: " + claimsJws.getPayload().toString());

            // 추가적인 검증 로직 (예: 토큰 만료 여부 확인 등)을 여기에 추가할 수 있습니다.
            return true;
        } catch (Exception e) {
            return false;
        }
    }



}

그리고 필터를 추가적으로 작성하여 JWT 토큰을 검증할 수 있도록 한다.

 

유레카 서버 ⇒ 게이트웨이⇒ 인증 ⇒ 상품 순으로 어플리케이션을 실행한다.

게이트웨이에 로그인(헤더에 토큰) 없이 상품을 요청할 경우 401 에러가 발생한다.

게이트웨이에 로그인을 요청하여 토큰을 발급 받은 뒤 헤더에 넣어 요청하면 정상적으로 응답이 오는 것을 볼 수 있다.

 

 

`Bearer`는 OAuth 2.0 프로토콜에서 사용하는 인증 토큰 유형 중 하나로 보호된 리소스에 접근할 수 있도록 한다.

`Bearer` 토큰은 요청 헤더에 포함되어 전달된다.

 


 

로그인은 정말 항상 어려운 것 같다.

그래도 반복적으로 학습하니 한결 익숙해지긴 했다..

 

 

반응형