보안 구성 (OAuth2 + JWT)

ayboori·2024년 8월 5일
0

Spring

목록 보기
15/24

보안의 중요성

  • 마이크로서비스 아키텍처에서는 각 서비스가 독립적으로 배포되고 통신하기 때문에 보안이 매우 중요합니다.
  • 데이터 보호, 인증 및 권한 부여, 통신 암호화 등을 통해 시스템의 보안성을 확보해야 합니다.

OAuth2

참고 (개념이 복잡해서 빠르게 넘어간다고 했다...) 용어, 역할, 부여 방식 등

  • OAuth2는 토큰 기반의 인증 및 권한 부여 프로토콜입니다.
  • 클라이언트 애플리케이션 (third-party 애플리케이션)이 리소스 소유자의 권한을 얻어 보호된 리소스에 접근할 수 있도록 합니다.

OAuth2의 네 가지 역할

  • 리소스 소유자(Resource Owner)
    OAuth 2.0 프로토콜을 사용하여 보호되는 리소스에 대한 액세스 권한을 부여하는 사용자(엔티티)입니다. 클라이언트를 인증(Authorize)하는 역할을 수행합니다. 예를 들어 네이버 로그인에서 네이버 아이디를 소유하고 third-party 애플리케이션(클라이언트)에 네이버 아이디로 소셜 로그인 인증을 하는 사용자를 의미합니다.

  • 클라이언트(Client)
    OAuth 2.0을 사용하여 리소스에 접근하려는 third-party 애플리케이션이나 서비스입니다.

  • 권한 서버(Authorization Server)
    권한 서버는 클라이언트가 리소스 소유자의 권한을 얻을 수 있도록 도와주는 서버입니다. 권한 서버는 사용자 인증, 권한 부여 및 토큰 발급을 관리합니다.

  • 리소스 서버(Resource Server)
    리소스 서버는 보호되는 리소스를 호스팅하는 서버로, 액세스를 허용하거나 거부합니다. 이 서버는 OAuth 2.0 토큰을 사용하여 클라이언트에게 리소스에 액세스할 권한을 부여하고 실제 데이터를 제공합니다.

OAuth2의 주요 개념

엑세스 토큰을 발급하는 주체에 따른 특징

  • Authorization Code Grant: 인증 코드를 사용하여 액세스 토큰을 얻는 방식
  • Implicit Grant: 클라이언트 애플리케이션에서 직접 액세스 토큰을 얻는 방식
  • Resource Owner Password Credentials Grant: 사용자 이름과 비밀번호를 사용하여 액세스 토큰을 얻는 방식
  • Client Credentials Grant: 클라이언트 애플리케이션이 자신의 자격 증명을 사용하여 액세스 토큰을 얻는 방식

JWT 개요

참고

JWT란?

  • JWT(JSON Web Token)는 JSON 형식(Key-Value 형식의 데이터)의 자가 포함된 토큰으로, 클레임(claim)을 포함하여 사용자에 대한 정보를 전달합니다.
  • JWT는 세 부분으로 구성됩니다: 헤더, 페이로드, 서명
  • JWT는 암호화를 통해 데이터의 무결성과 출처를 보장합니다.
  • 토큰에 대한 데이터를 복호화 하여 볼 수 있는 사이트 : https://jwt.io/

JWT의 주요 특징

  • 자가 포함: 토큰 자체에 모든 정보를 포함하고 있어 별도의 상태 저장이 필요 없습니다.
  • 간결성: 짧고 간결한 문자열로, URL, 헤더 등에 쉽게 포함될 수 있습니다.
  • 서명 및 암호화: 데이터의 무결성과 인증을 보장합니다.

    데이터가 전송 중에 변조가 되지는 않았는지 확인하는 것이 중요하다.
    페이로드 등의 데이터 자체의 보안이 중요한 것은 아니다.

실습

❗ 이번 강의에서는 실습을 통해 클라우드 게이트웨이의 Pre 필터에서 JWT 인증을 진행해봅니다.
우선 “스프링 클라우드 게이트웨이”에서 학습한 모든 프로젝트를 복사하여 사용하겠습니다.

여기에 Auth Service 를 생성하여 로그인 기능을 아주 간단하게 구현하겠습니다.
클라우드 게이트웨이에 Pre 필터를 하나 더 생성하여 로그인을 체크 하겠습니다.

PostMan 또는 크롬 익스텐션 중 Talend API Tester를 설치합니다.
강의에서는 Talend를 사용하겠습니다.
PostMan : https://www.postman.com/
Talend : https://chromewebstore.google.com/detail/talend-api-tester-free-ed/aejoelaoggembcahagimdiliamlcdmfm?hl=ko


이미지의 점선만 실행하여 확인하겠습니다.

1) 사용자가 gateway로 요청 시 auth에서 토큰 생성 후 사용자에게 부여
2) 사용자는 토큰을 가진 채로 product 호출
3) gateway에서 토큰 체크 후 product 호출
3-1) 토큰 체크를 auth에서 확인할 수도 있다 (권한 확인을 위해 gateway에서 auth로 보냄)
3-2) 권한 체크만 각 app이 auth를 호출하여 체크할 수도 있다

Auth Service

  • 로그인을 담당하는 서비스 어플리케이션을 생성합니다. 로그인을 진행하면 토큰을 발급받고 이 토큰을 사용하여 Gateway를 호출합니다.

  • build.gradle 파일의 디펜던시를 아래와 같이 수정합니다. ( jwt 추가 )
    dependencies {
    	implementation 'io.jsonwebtoken:jjwt:0.12.6'
    	implementation 'org.springframework.boot:spring-boot-starter-actuator'
    	implementation 'org.springframework.boot:spring-boot-starter-security'
    	implementation 'org.springframework.boot:spring-boot-starter-web'
    	implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    	testImplementation 'org.springframework.security:spring-security-test'
    	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    	testImplementation 'io.projectreactor:reactor-test'
    	
    }
  • application.yml
    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
  • AuthConfig.java
    
    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();
        }
    }
  • AuthService.java
    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;
    
   		//SecretKey 타입의 객체를 아래 함수에서 디코딩하여 생성
        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()
                    // 사용자 ID를 클레임으로 설정
                    .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();
        }
    }
  • AuthController.java
    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")
        // 어떤 타입이건 상관 없이 ResponseEntity로 리턴
        public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
        	// user_id를 가지고 아래 함수로 액세스 토큰 생성 후 response
            return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id))); 
            
        }
    
        /**
         * JWT 액세스 토큰을 포함하는 응답 객체입니다.
         */
        @Data
        @AllArgsConstructor
        @NoArgsConstructor
        static class AuthResponse {
            private String access_token;
    
        }
    }


실행 시 웹에 토큰 값을 띄우는 것을 알 수 있다.
위의 사이트에 토큰 값을 넣으면 payload에서 우리가 넘긴 데이터를 알 수 있다.

Cloud Gateway

  • 기존 게이트웨이 코드에 JWT인증 및 auth-service 라우팅 정보를 추가합니다.
  • build.gradle 파일에 필요한 의존성을 추가합니다. ( jwt 추가 )
    dependencies {
    	**implementation 'io.jsonwebtoken:jjwt:0.12.6'**
    	implementation 'org.springframework.boot:spring-boot-starter-actuator'
    	implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    	implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    	testImplementation 'io.projectreactor:reactor-test'
    	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    }
  • application.yml 을 다음과 같이 수정합니다.
    server:
      port: 19091  # 게이트웨이 서비스가 실행될 포트 번호
    
    spring:
      main:
        web-application-type: reactive  # Spring 애플리케이션이 리액티브 웹 애플리케이션으로 설정됨
      application:
        name: gateway-service  # 애플리케이션 이름을 'gateway-service'로 설정
      cloud:
        gateway:
          routes:  # Spring Cloud Gateway의 라우팅 설정
            - id: order-service  # 라우트 식별자
              uri: lb://order-service  # 'order-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
              predicates:
                - Path=/order/**  # /order/** 경로로 들어오는 요청을 이 라우트로 처리
            - id: product-service  # 라우트 식별자
              uri: lb://product-service  # 'product-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
              predicates:
                - Path=/product/**  # /product/** 경로로 들어오는 요청을 이 라우트로 처리
            - id: auth-service  # 라우트 식별자
              uri: lb://auth-service  # 'auth-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
              predicates:
                - Path=/auth/signIn  # /auth/signIn 경로로 들어오는 요청을 이 라우트로 처리
          discovery:
            locator:
              enabled: true  # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정
    
    eureka:
      client:
        service-url:
          defaultZone: http://localhost:19090/eureka/  # Eureka 서버의 URL을 지정
          
    
    service:
      jwt:
        secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"
        
    
  • LocalJwtAuthenticationFilter.java

    헤더에 Authentication | Bearer 토큰값 이 담겨서 로그인 체크하게 된다.
    ServerWebExchange 참고

    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();
            
            // /signIn 경로는 필터를 적용하지 않고 다음 필터로 넘김
            if (path.equals("/auth/signIn")) {
                return chain.filter(exchange);  
            }
    
            String token = extractToken(exchange);
    		
            // 토큰이 없거나 유효하지 않을 경우
            if (token == null || !validateToken(token)) {
            	// 인증되지 않음 (401) 코드 전달
                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()); // payload 값 확인
    
                // 추가적인 검증 로직 (예: 토큰 만료 여부 확인 등)을 여기에 추가할 수 있습니다.
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    
    }

7.4.3 RUN

  • 유레카 서버 ⇒ 게이트웨이⇒ 인증 ⇒ 상품 순으로 어플리케이션을 실행합니다.
  • http://localhost:19090에 접속하여 각 인스턴스를 확인합니다.

gateway를 통하지 않은 접근은 로그인 되지 않은 사용도 접근이 가능하다 (방화벽 처리를 통해 막아야 한다)

  • 게이트웨이에서 상품을 요청해 봅니다. 401 에러가 발생하는 것을 볼 수 있습니다.
  • 게이트웨이에서 로그인을 요청하여 토큰을 발급받아봅니다. Untitled
  • 해당 토큰을 상품요청에 헤더에 넣어서 요청합니다. 이미지를 확인해주세요. 요청을 통해 정상적으로 응답이 오는 것을 볼 수 있습니다.
    • Bearer 란?
      - Bearer는 OAuth 2.0 프로토콜에서 사용하는 인증 토큰 유형 중 하나로, 액세스 토큰을 통해 보호된 리소스에 접근할 수 있도록 합니다. Bearer 토큰은 요청 헤더에 포함되어 서버에 전달되며, 서버는 이를 검증하여 요청이 유효한지 확인합니다.
      - 간단한 사용법: 클라이언트는 서버에서 받은 Bearer 토큰을 HTTP 요청 헤더에 포함시키기만 하면 됩니다.
      - 서버 측 검증: 서버는 이 토큰을 검증하여 요청이 인증된 사용자의 요청인지 확인합니다. 일반적으로 토큰의 유효성, 만료 시간 등을 확인합니다.
      - 보안: Bearer 토큰은 HTTPS를 통해 전달되어야 합니다. 이를 통해 토큰이 전송 중에 도난당하지 않도록 보호할 수 있습니다

      Untitled

profile
프로 개발자가 되기 위해 뚜벅뚜벅.. 뚜벅초

0개의 댓글