[마이크로서비스] 보안 OAuth2, 키클록 (8)

hyeokjin·2022년 8월 15일
1

microservice

목록 보기
8/13
post-thumbnail

OAuth2

OAuth2의 주요 목적은 사용자 요청을 수행하기 위해 여러 서비스를 호출할 때, 요청을 처리하는 모든 서비스에 자격증명을 제시하지 않고도 각 서비스에서 사용자를 인증하는 것이다. OAuth2를 사용하면 그랜트 라는 인증 체계를 통해 REST 기반의 서비스를 보호할 수 있다

네 가지 그랜트 타입이 있다.

  • 패스워드
  • 클라이언트 자격증명
  • 인가 코드
  • 암시적

🎈 네 가지 그랜트 타입 알아보기

여기서는 비교적 단순한 패스워드 그랜드 타입으로 마이크로 서비스에서 OAuth2 사용방법을 본다.
JWT를 사용하여 더욱 안전한 OAuth2 솔루션을 제공하고 토큰 정보를 인코딩하는 표준을 만들어 본다.

아래는 진행하고자하는 서비스들의 소스를 참고할 깃헙 주소이다.

https://github.com/hyeokjinON/microservice_study/tree/master/chapter9

키클록

키클록은 서비스와 애플리케이션을 위한 ID 및 액세스 관리용 오픈 소스 솔루션이다. 코딩을 전혀 또는 거의 하지 않고 서비스와 애플리케이션을 쉽게 보호할 수 있다.

키클록 보안은 보호자원, 자원 소유자, 애플리케이션, 인증 및 인가 서버 등 네 가지 구성 요소로 구분 할수 있다.

스프링과 키클록으로 시작하기

인증과 인가 부분을 설정하는 방법을 이해하고자 다음을 수행해본다.

  • 키클록 서비스를 도커에 추가
  • 키클록 서비스를 설정하고 애플리케이션을 사용자 신원을 인증하고 권한을 부여 할 수 있는 인가된 애플리케이션으로 등록한다.

docker-compose.yml

우리가 docker-compose로 실행할 어플리케이션에 키클록을 추가시킨다


 keycloak:
    image: alemairebe/keycloak:14.0.0
    restart: always
    environment:
      KEYCLOAK_VERSION: 6.0.1
      # 키클록 관리자 콘솔의 사용자 이름
      KEYCLOAK_USER: admin
      # 키클록 관리자 콘솔의 패스워드
      KEYCLOAK_PASSWORD: pass # admin
    volumes:
        - ./realm-export.json:/opt/alemairebe/keycloak/realm-export.json
    command:
      - "-b 0.0.0.0"
      - "-Dkeycloak.import=/opt/alemairebe/keycloak/realm-export.json"
      - "-Dkeycloak.profile.feature.scripts=enabled"
      - "-Dkeycloak.profile.feature.upload_scripts=enabled"
    ports:
      - "8080:8080"
    networks:
      backend:
        aliases:
          - "keycloak"

코드를 보면 8080 포트로 접속한다. 기존에 라이선싱 서비스 포트와 겹치기 때문에 라이선싱 포트를 8180으로 바꾸자.

키클록 설정

http://keyclock:8080/auth 에 접속한다.

Administration Console 콘솔을 클릭하자

사용자 이름과 패스워드는 docker-compose.yml 파일에 정의된 것을 사용한다.

Add realm 항목을 선택한다 - Add realm 입력란에 이름을 입력한다 - Create 버튼을 누른다.

다음과 같이 Clients 메뉴에서 오른쪽 상단에 Create 버튼을 눌러
ostock 클라이언트 애플리케이션을 등록한다

클라이언트 등록 정보를 확인한다. Save 버튼을 눌러 저장한다.

ostock 클라이언트 등록 정보 탭 옆에 Roles 탭이 있다.
해당 탭을 이용해 클라이언트 역할을 생성해야 한다.
Admin 과 User 목록이 표시가 된다.

기본 클라이언트 구성을 완료 했으니
이제 Credentials(자격 증명) 탭을 클릭해서 애플리케이션 시크릿 키를 살펴본다

다음 구성 단계는 realm roles(영역 역할)를 생성하는 것이다.
각 사용자에 대한 역할을 더 잘 통제할 수 있다.

왼쪽 메뉴 탭의 Roles를 들어가서 Add Role 버튼을 클릭하여 realm 역할을 생성한다

여기서 ostock-admin, ostock-user를 생성했다.

지금까지 ostock 애플리케이션 및 realm의 역할, 이름, 시크릿을 정의했으며 개별 사용자의 자격 증명과 사용자 역할을 설정할 준비를 완료 했다.

사용자를 생성하려면 키클록 관리자 콘솔의 왼쪽 메뉴에서 Users 항목을 클릭하고 생성해 준다.

여기서 illaty.huaylupo(ostock-admin), john.carnell(ostock-user) 두 개의 사용자 계정을 정의한다

아래 사진은 illaty.huaylupo(ostock-admin)을 설정하는 모습을 보여준다

Users - Credentials 탭에서 비밀번호를 설정한다

Users - Role Mapping 탭에서 사용자 특정 역할을 지정한다 (ostock-admin)

이제 패스워드 그랜트 플로에 대한 애플리케이션 및 사용자 인증을 수행할 수 있는 키클록 서버 기능이 준비되었다.

왼쪽 메뉴의 Realm Settings 항목에 OpenID Endpoint Configuration 링크를 클릭하여 realm에 대한 가용한 엔드 포인트 목록을 살펴보자

OpenID Endpoint Configuration를 클릭한 화면이다

그 중 토큰 엔드포인트를 찾을 수 있다

"token_endpoint":"http://localhost:8080/auth/realms/spmia-realm/protocol/openid-connect/token"

이 주소를 가지고 액세스 토큰을 획득하려는 사용자를 시물레이션한다.
postman에서 토큰 엔드포인트 POST 호출을 할 때 애플리케이션 ID, 시크릿 키, 사용자 ID, 패스워드를 전달한다.

Authoruzation 의 Basic Auth 타입으로 설정 후
애플리케이션 ID와 시크릿 키를 셋팅한다

다음 body 에서 그랜트 타입과 사용자 ID, 패스워드를 설정하고
POST 호출을 시도한다. 이 매개변수들은 JSON 본문으로 전달되지 않는다. 인증 표준은 토큰 생성 엔드 포인트에 전달된 모든 매개변수가 HTTP 양식 매개변수로 되어야 한다고 명시한다.

해당 호출로 반환된 JSON 페이로드를 볼 수 있다

핵심 필드만 살펴보자 엑세스 토큰은 모든 호출을 할 때 제출하는 인증용 토큰이다.

"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjdWNka3FDSWlGZ1VBWWd0VzM1UFF0NjZ6QkRORkkzR1pIbFBybnB6aDRNIn0.eyJleHAiOjE2NjA1NjM0NTEsImlhdCI6MTY2MDU2MzE1MSwianRpIjoiNGE1MzMwODQtODU0Zi00MTdjLWJmYzMtYzI3MjM5YjQzOWY1IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL3NwbWlhLXJlYWxtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjcwMzBkNjcyLTgzNjctNGFhNi05OGNkLTczYzE1NDVlOTNmOSIsInR5cCI6IkJlYXJlciIsImF6cCI6Im9zdG9jayIsInNlc3Npb25fc3RhdGUiOiJlM2M5MzY4ZC0xMDVhLTRlNWQtYjkyOC01MGVkMDA2MzQ1MmEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiZGVmYXVsdC1yb2xlcy1zcG1pYS1yZWFsbSIsInVtYV9hdXRob3JpemF0aW9uIiwib3N0b2NrLXVzZXIiXX0sInJlc291cmNlX2FjY2VzcyI6eyJvc3RvY2siOnsicm9sZXMiOlsiVVNFUiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByZWZlcnJlZF91c2VybmFtZSI6ImpvaG4uY2FybmVsbCJ9.ZuGclShoJF3DHv6T_QSEf9q1io_bZIwtlZsFQH65F-Nj-JD4y9uQQC5NHJNCi4SX6B7NPt7IJk0T2meHAjdDg_phOeUYLaH9-VLGovceUJNujRacyaCHaXW-p-rn0EFqCY_Bk5YV5Lspq5luNrjWg-ybWWpdiyHTc_EdK_sZxSZfucYnSsOyrydxq_F0JZTQQYMKNpsi-CGwny3FTVS6-TJXsVhwmCpIe2yVoAXmytf2S_LbsQvZbDRZIYlwn86XODp09q_zxNY3xP_doCgpFGYAzLrbKvXb1HniMztNlfDDodc_5eGF6UBBhlokbJ7iQHOEg04tvbLon0NdXb_QQg",

키클록으로 조직 서비스 보호하기

키클록 서버에 클라이언트를 등록하고 역할과 함께 사용자 계정을 설정했다.
이제 스프링 시큐리티와 키클록 스프링 부트 어댑터로 자원을 보호하는 방법을 적용해보자.

  • 보호할 서비스에 적절한 스프링 스큐리티와 키클록 JAR를 추가한다
  • 키클록 서버에 접속하도록 서비스 구성 정보를 설정한다.
  • 서비스에 액세스 할 수 있는 대상과 사용자를 정의한다

조직 서비스에 먼저 구성을 해본다.

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.2.5.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.optimagrowth</groupId>
	<artifactId>organization-service</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>Organization Service</name>
	<description>Ostock Organization Service</description>

	<properties>
		<java.version>11</java.version>
		<docker.image.prefix>ostock</docker.image.prefix>
		<spring-cloud.version>Hoxton.SR1</spring-cloud.version>
	</properties>

	<dependencies>
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-hateoas</artifactId>
        </dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-actuator</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
		    <groupId>org.projectlombok</groupId>
		    <artifactId>lombok</artifactId>
		    <scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-config</artifactId>
		</dependency>
		<dependency>
		  <groupId>org.springframework.boot</groupId>
		  <artifactId>spring-boot-starter-data-jpa</artifactId>  
		</dependency>
		<dependency>
		    <groupId>org.postgresql</groupId>
		    <artifactId>postgresql</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
			<exclusions>
				<exclusion>
                    <groupId>org.springframework.cloud</groupId>
                    <artifactId>spring-cloud-starter-ribbon</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>com.netflix.ribbon</groupId>
                    <artifactId>ribbon-eureka</artifactId>
                </exclusion>
            </exclusions>
		</dependency>
		<dependency>
		    <groupId>org.keycloak</groupId>
			<!-- 키클록 스프링 부트 의존성 -->
		    <artifactId>keycloak-spring-boot-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<!-- 스프링 시큐리티 스타터 의존성 -->
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
	</dependencies>
	
	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.springframework.cloud</groupId>
				<artifactId>spring-cloud-dependencies</artifactId>
				<version>${spring-cloud.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
			<dependency>
	            <groupId>org.keycloak.bom</groupId>
				<!-- 키클록 스프링 부트 의존성 관리-->
	            <artifactId>keycloak-adapter-bom</artifactId>
	            <version>11.0.2</version>
	            <type>pom</type>
	            <scope>import</scope>
	        </dependency>
		</dependencies>
	</dependencyManagement>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
			<!-- This plugin is used to create a docker image and publish the image to docker hub-->
			<plugin>
				<groupId>com.spotify</groupId>
				<artifactId>dockerfile-maven-plugin</artifactId>
				<version>1.4.13</version>
				<configuration>
					<repository>${docker.image.prefix}/${project.artifactId}</repository>
	                <tag>${project.version}</tag>
					<buildArgs>
						<JAR_FILE>target/${project.build.finalName}.jar</JAR_FILE>
					</buildArgs>
				</configuration>
				<executions>
					<execution>
						<id>default</id>
						<phase>install</phase>
						<goals>
							<goal>build</goal>
							<goal>push</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>


</project>

조직 서비스를 보호 자원으로 설정하면 호출자는 이 서비스를 호출할 때 마다 해당 서비스에 대한 베어러 토큰이 있는 인증 HTTP 헤더를 포함해야 한다.
호출 받은 보호 자원은 키클록 서버를 다시 호출해서 토큰이 유효한지 확인해야 한다.

필요한 키클록 구성을 위해 configServer에서 조직 서비스의 구성 서버 저장소에 있는 organization-service.properties 파일을 수정한다

organization-service.properties

# 생성된 realm 이름
keycloak.realm = spmia-realm
# 키클록 서버 URL 인증 엔드포인트
keycloak.auth-server-url = http://keycloak:8080/auth
keycloak.ssl-required = external
# 생성된 클라이언트 ID
keycloak.resource = ostock
# 생성된 클라이언트 시크릿
keycloak.credentials.secret = fdd844ec-473c-4c6f-b2e1-64656fb281f1
keycloak.use-resource-role-mappings = true
keycloak.bearer-only = true

서비스에 접근할 수 있는 사용자 및 대상 정의를 한다.
조직 서비스에 SecurityConfig.java를 생성한다.

SecurityConfig.java


package com.optimagrowth.organization.config;

import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

@Configuration
// 전역 WebSecurity 구성을 적용한다.
@EnableWebSecurity
// @RoleAllowed를 활성화 한다.
@EnableGlobalMethodSecurity(jsr250Enabled = true)
// KeycloakWebSecurityConfigurerAdapter를 확장하여 접근 제어 규칙을 재정의한다.
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    // 키클록 인증 제공자를 등록한다.
    // 메서드에 전달된 HttpSecurity 객체로 모든 접근 규칙을 구성하고 설정한다
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.authorizeRequests()
            .anyRequest().authenticated();
        http.csrf().disable();
    }

    // AuthenticationProvider를 정의한다.
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    // 세션 인증 전략을 정의한다
    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    // 기본적으로 스프링 시큐리티 어댑터는 keyclock.json 파일을 찾는다.
    @Bean
    public KeycloakConfigResolver KeycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }
}

그리고 @RolesAllowed를 사용하여 특정 역할을 이용한 서비스 보호도 가능하다
다음 코드를 살펴보자

OrganizationController.java

package com.optimagrowth.organization.controller;

import javax.annotation.security.RolesAllowed;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.optimagrowth.organization.model.Organization;
import com.optimagrowth.organization.service.OrganizationService;

@RestController
@RequestMapping(value="v1/organization")
public class OrganizationController {
    @Autowired
    private OrganizationService service;

    // ADMIN 과 USER 역할이 있는 사용자만 이 작업을 수행가능하다
    @RolesAllowed({ "ADMIN", "USER" })  
    @RequestMapping(value="/{organizationId}",method = RequestMethod.GET)
    public ResponseEntity<Organization> getOrganization( @PathVariable("organizationId") String organizationId) {
        return ResponseEntity.ok(service.findById(organizationId));
    }

    // ADMIN 과 USER 역할이 있는 사용자만 이 작업을 수행가능하다
    @RolesAllowed({ "ADMIN", "USER" }) 
    @RequestMapping(value="/{organizationId}",method = RequestMethod.PUT)
    public void updateOrganization( @PathVariable("organizationId") String id, @RequestBody Organization organization) {
        service.update(organization);
    }

    // ADMIN 과 USER 역할이 있는 사용자만 이 작업을 수행가능하다
    @RolesAllowed({ "ADMIN", "USER" }) 
    @PostMapping
    public ResponseEntity<Organization>  saveOrganization(@RequestBody Organization organization) {
    	return ResponseEntity.ok(service.create(organization));
    }

    // ADMIN 역할이 있는 사용자만 이 작업을 수행가능하다
    @RolesAllowed("ADMIN")    
    @DeleteMapping(value="/{organizationId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
	public void deleteLicense(@PathVariable("organizationId") String organizationId) {
		service.delete(organizationId);
	}

}

모든 준비는 다 되었다. 현재까지의 구성된 어플리케이션을 모든 서비스에서 docker-compose up 시킨다

docker-compose.yml 코드는 아래와 같다

docker-compose.yml

version: '2.1'
services:
  database:
    image: postgres:latest
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: "postgres"
      POSTGRES_PASSWORD: "postgres"
      POSTGRES_DB:       "ostock_dev"
    volumes:
        - ./init.sql:/docker-entrypoint-initdb.d/1-init.sql
        - ./data.sql:/docker-entrypoint-initdb.d/2-data.sql
    networks:
      backend:
        aliases:
          - "database"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  configserver:
    image: ostock/configserver:0.0.1-SNAPSHOT
    ports:
       - "8071:8071"
    environment:
      ENCRYPT_KEY: "fje83Ki8403Iod87dne7Yjsl3THueh48jfuO9j4U2hf64Lo"
    networks:
      backend:
        aliases:
          - "configserver"
  eurekaserver:
    image: ostock/eurekaserver:0.0.1-SNAPSHOT
    ports:
      - "8070:8070"
    depends_on:
      database:
        condition: service_healthy
      configserver:
        condition: service_started  
    networks:
      backend:
        aliases:
          - "eurekaserver"
  gatewayserver:
    image: ostock/gatewayserver:0.0.1-SNAPSHOT
    ports:
      - "8072:8072"
    environment:
      PROFILE: "default"
      SERVER_PORT: "8072"
      CONFIGSERVER_URI: "http://configserver:8071"
      EUREKASERVER_URI: "http://eurekaserver:8070/eureka/"
      EUREKASERVER_PORT: "8070"
      CONFIGSERVER_PORT: "8071"
    depends_on:
      database:
        condition: service_healthy
      configserver:
        condition: service_started
      eurekaserver:
        condition: service_started
    networks:
      backend:
        aliases:
          - "gateway"
  licensingservice:
    image: ostock/licensing-service:0.0.3-SNAPSHOT
    environment:
      PROFILE: "dev"
      CONFIGSERVER_URI: "http://configserver:8071"
      CONFIGSERVER_PORT:   "8071"
      DATABASESERVER_PORT: "5432"
      ENCRYPT_KEY:       "IMSYMMETRIC"
    depends_on:
      database:
        condition: service_healthy
      configserver:
        condition: service_started
    ports:
      - "8180:8080"
    networks:
      - backend
  organizationservice:
    image: ostock/organization-service:0.0.1-SNAPSHOT
    environment:
      PROFILE: "dev"
      CONFIGSERVER_URI: "http://configserver:8071"
      CONFIGSERVER_PORT:   "8071"
      DATABASESERVER_PORT: "5432"
      ENCRYPT_KEY:       "IMSYMMETRIC"
    depends_on:
      database:
        condition: service_healthy
      configserver:
        condition: service_started
    ports:
      - "8081:8081"
    networks:
      - backend

  keycloak:
    image: alemairebe/keycloak:14.0.0
    restart: always
    environment:
      KEYCLOAK_VERSION: 6.0.1
      # 키클록 관리자 콘솔의 사용자 이름
      KEYCLOAK_USER: admin
      # 키클록 관리자 콘솔의 패스워드
      KEYCLOAK_PASSWORD: pass # admin
    volumes:
        - ./realm-export.json:/opt/alemairebe/keycloak/realm-export.json
    command:
      - "-b 0.0.0.0"
      - "-Dkeycloak.import=/opt/alemairebe/keycloak/realm-export.json"
      - "-Dkeycloak.profile.feature.scripts=enabled"
      - "-Dkeycloak.profile.feature.upload_scripts=enabled"
    ports:
      - "8080:8080"
    networks:
      backend:
        aliases:
          - "keycloak"

networks:
  backend:
    driver: bridge

postman에서 테스트 해보자 엑세스 토큰 없이 조직 서비스에 액세스 한다고 가정해 보자

http://localhost:8072/organization/v1/organization/e839ee96-28de-4f67-bb79-870ca89743a0

{
  "timestamp": "2022-08-15T12:08:46.485+0000",
  "status": 401,
  "error": "Unauthorized",
  "message": "Unauthorized",
  "path": "/v1/organization/e839ee96-28de-4f67-bb79-870ca89743a0"
}

401 HTTP 응답 코드와 서비스에 대한 완전한 인증이 필요하다는 메시지를 받게 된다.

다음으로 액세스 토큰을 포함하여 조직 서비스를 호출해 보자.
조직 서비스 호출시 Authorization 타입을 Bearer Token으로 설정을 해줘야 한다.

{
  "id" : "e839ee96-28de-4f67-bb79-870ca89743a0",
  "name" : "Ostock",
  "contactName" : "Illary Huaylupo",
  "contactEmail" : "llaryhs@gmail.com",
  "contactPhone" : "888888888"
}

200 HTTP 응답 코드와 정상 응답을 받은 것을 확인할 수 있다.


액세스 토큰 전파

서비스 간 토큰 전파를 보여 주고자 라이선싱 서비스도 키클록으로 보호할 것이다.

라이선싱 서비스가 정보를 조회하려고 조직서비스를 호출한다는 것을 기억하자

인증된 사용자 토큰이 게이트웨이와 라이선싱 서비스를 거쳐 조직 서비스로 어떻게 흘러가는지 그림으로 설명한다.

라이선싱 서비스를 구현해보자 (pom.xml과 licensing-service.properties 구성정보 는 생략하겠다.)

인증된 사용자만 접근하도록 만들기

SecurityConfig.java

package com.optimagrowth.license.config;

import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory;
import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

	@Autowired
	public KeycloakClientRequestFactory keycloakClientRequestFactory;

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		super.configure(http);
		http.authorizeRequests()
		.anyRequest().authenticated();
		http.csrf().disable();
	}

	@Autowired
	public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
		KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
		keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
		auth.authenticationProvider(keycloakAuthenticationProvider);
	}

	@Bean
	@Override
	protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
		return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
	}

	@Bean
	public KeycloakConfigResolver KeycloakConfigResolver() {
		return new KeycloakSpringBootConfigResolver();
	}

	@Bean
	@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
	public KeycloakRestTemplate keycloakRestTemplate() {
		return new KeycloakRestTemplate(keycloakClientRequestFactory);
	}
}

KeyclockRestTemplate로 액세스 토큰 전파하기

OrganizationRestTemplateClient.java

package com.optimagrowth.license.service.client;

import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

import com.optimagrowth.license.model.Organization;

@Component
public class OrganizationRestTemplateClient {
    
    // KeycloakRestTemplate은 표준 RestTemplate에 대한 호환성을 유지하는 대체 구성품(드롭인)이며 액세스 토큰의 전파를 처리한다.
    @Autowired
    private KeycloakRestTemplate restTemplate;

    public Organization getOrganization(String organizationId){
        // 조직 서비스 호출은 표준 RestTemplate와 같은 방식으로 수행된다. 여기서는 게이트웨이 서버를 접속한다
        ResponseEntity<Organization> restExchange = 
                restTemplate.exchange(
                   "http://gateway:8072/organization/v1/organization/{organizationId}",
                   HttpMethod.GET,
                   null, Organization.class, organizationId);

        return restExchange.getBody();
    }

    
}

postman으로 특정 라이선스 데이터를 조회하고 조직 서비스와 연관된 정보를 얻어오도록 테스트 해본다.

http://localhost:8072/license/v1/organization/e839ee96-28de-4f67-bb79-870ca89743a0/license/279709ff-e6d5-4a54-8b55-a5c37542025b

액세스 토큰이 라이선싱 서비스에서 조직 서비스에 전파되며 조회가 잘 됨을 확인 할 수있다.

JWT의 사용자 정의 필드 파싱

JWT의 사용자 정의 필드를 파싱하는 방법이다
이전 챕터에 게이트웨이를 설명하면서 TrackingFilter 필터 클래스를 수정하여 게이트웨이를 통과하는 JWT에서 preferrend_username 필드를 디코딩 해볼 것이다.

이 작업을 위해 JWT 파서 라이브러리를 게이트웨이 서버의 pom.xml에 추가한다

pom.xml

		<dependency>
		    <groupId>commons-codec</groupId>
		    <artifactId>commons-codec</artifactId>
		</dependency>
		<dependency>
		    <groupId>org.json</groupId>
		    <artifactId>json</artifactId>
		    <version>20190722</version>
		</dependency>

TrackingFilter에 getUsername() 이라는 새로운 메서드를 추가할 수 있다.

TrackingFilter.java

	private String getUsername(HttpHeaders requestHeaders){
		String username = "";
		if (filterUtils.getAuthToken(requestHeaders)!=null){
			// Authorization HTTP 헤더에서 토큰을 파싱한다.
			String authToken = filterUtils.getAuthToken(requestHeaders).replace("Bearer ","");
	        JSONObject jsonObj = decodeJWT(authToken);
	        try {
				// JWT 에서 preferred_username(로그인 ID)을 가져온다.
	        	username = jsonObj.getString("preferred_username");
	        }catch(Exception e) {logger.debug(e.getMessage());}
		}
		return username;
	}


	private JSONObject decodeJWT(String JWTToken) {
		String[] split_string = JWTToken.split("\\.");
		// Base64 인코딩을 사용하여 토큰을 파싱하고 토큰을 서명하는 키를 전달한다
		String base64EncodedBody = split_string[1];
		Base64 base64Url = new Base64(true);
		String body = new String(base64Url.decode(base64EncodedBody));
		// preferred_username을 조회하고자 JWT 본문을 json 객체로 파싱한다.
		JSONObject jsonObj = new JSONObject(body);
		return jsonObj;
	}
    
    // System.out.println을 filter() 메서드에 추가하자
	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		HttpHeaders requestHeaders = exchange.getRequest().getHeaders();
		if (isCorrelationIdPresent(requestHeaders)) {
			logger.debug("tmx-correlation-id found in tracking filter: {}. ", 
					filterUtils.getCorrelationId(requestHeaders));
		} else {
			String correlationID = generateCorrelationId();
			exchange = filterUtils.setCorrelationId(exchange, correlationID);
			logger.debug("tmx-correlation-id generated in tracking filter: {}.", correlationID);
		}
		
        // 게이트웨이를 통과하는 JWT에서 preferrd_username을 파싱해서 출력한다.
		System.out.println("The authentication name from the token is : " + getUsername(requestHeaders));
		
		
		
		return chain.filter(exchange);
	}

액세스 토큰을 가지고 호출을 해보면 아래와 같이 JWT에서 파싱한 illary.huaylupo을 출력하는 것을 볼 수 있다.

gatewayserver_1        | The authentication name from the token is : illary.huaylupo

이것으로 키클록과 함께 스프링 시큐리티 인증 및 인가 서비스를 구현방법을 해봤다.

하지만 마이크로서비스 보안을 구축하는데 있어서 아주 일부분이다.

실제 사용하도록 마이크로 서비스를 구성하려면 아래와 같은 사항을 중심으로 보안을 구축해야한다.

  • 모든 서비스 통신에 HTTPS/SSL을 사용한다
  • 서비스 게이트웨이를 사용하여 마이크로서비스에 접근한다
  • 공개 API 및 비공개 API 영역을 지정한다
  • 불필요한 네트워크 포트를 차단해서 마이크로서비스 공격 지점을 제한한다.

🧨 다음 챕터에는 스프링 클라우드 스트림을 사용한 이벤트 아키택처에 대해 알아보겠다.

profile
노옵스를향해

0개의 댓글