java與es8實戰之五:SpringBoot應用中操作es8(帶安全檢查:https、賬號密碼、API Key)

2023-08-30 09:00:34

歡迎存取我的GitHub

這裡分類和彙總了欣宸的全部原創(含配套原始碼):https://github.com/zq2599/blog_demos

本篇概覽

  • 本篇是《java與es8實戰》系列的第五篇,總體目標明確:實戰在SpringBoot應用中操作elasticsearch8,今天的重點是SpringBoot應用連線帶有安全檢查的elasticsearch8伺服器端
  • 連線需要安全檢查的elasticsearch8是為了更接近真實環境,首先,連線是基於自簽證書的https協定,其次,認證方式有兩種
  1. 第一種是賬號密碼
  2. 第二種是es伺服器端授權的API Key

  • 今天的實戰總體目標可以拆解為兩個子任務
  1. 在SpringBoot中連線elasticsearch8
  2. 在SpringBoot中使用elasticsearch8官方的Java API Client
  • 接下來直接開始

部署elasticsearch叢集(需要安全檢查)

建立API Key

  • 除了賬號密碼,ES還提供了一種安全的存取方式:API Key,java應用持有es簽發的API Key也能順利傳送指令到es,接下來咱們先生成API Key,再在應用中使用此API Key
  • 《docker-compose快速部署elasticsearch-8.x叢集+kibana》一文中,的咱們將自簽證書從容器中複製出來了,現在在證書所在目錄執行以下命令,注意引數expiration代表這個ApiKey的有效期,我這裡隨意設定為10天
curl -X POST "https://localhost:9200/_security/api_key?pretty" \
--cacert es01.crt \
-u elastic:123456 \
-H 'Content-Type: application/json' \
-d'
{
  "name": "my-api-key-10d",
  "expiration": "10d"
}
'
  • 會收到以下響應,其中的encoded欄位就是API Key
{
  "id" : "eUV1V4EBucGIxpberGuJ",
  "name" : "my-api-key-10d",
  "expiration" : 1655893738633,
  "api_key" : "YyhSTh9ETz2LKBk3-Iy2ew",
  "encoded" : "ZVVWMVY0RUJ1Y0dJeHBiZXJHdUo6WXloU1RoOUVUejJMS0JrMy1JeTJldw=="
}

Java應用連線elasticsearch的核心套路

  • 不論是直連,還是帶安全檢查的連線,亦或是與SpringBoot的整合使之更方便易用,都緊緊圍繞著一個不變的核心套路,該套路由兩部分組成,掌握了它們就能在各種條件下成功連線es
  1. 首先,是builder pattern,連線es有關的程式碼,各種物件都是其builder物件的build方法建立的,建議您提前閱讀《java與es8實戰之一》一文,看完後,滿屏的builder程式碼可以從醜變成美...
  2. 其次,就是java應用能向es發請求的關鍵:ElasticsearchClient物件,該物件的建立是有套路的,如下圖,先建立RestClient,再基於RestClient建立ElasticsearchTransport,最後基於ElasticsearchTransport建立ElasticsearchClient,這是個固定的套路,咱們後面的操作都是基於此的,可能會加一點東西,但不會改變流程和圖中的物件
  • 準備完畢,開始寫程式碼

新建子工程

  • 為了便於管理依賴庫版本和原始碼,《java與es8實戰》系列的所有程式碼都以子工程的形式存放在父工程elasticsearch-tutorials

  • 《java與es8實戰之二:實戰前的準備工作》一文說明了建立父工程的詳細過程

  • 在父工程elasticsearch-tutorials中新建名為crud-with-security的子工程,其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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <!-- 請改為自己專案的parent座標 -->
    <parent>
        <artifactId>elasticsearch-tutorials</artifactId>
        <groupId>com.bolingcavalry</groupId>
        <version>1.0-SNAPSHOT</version>
        <relativePath>../pom.xml</relativePath>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.bolingcavalry</groupId>
    <!-- 請改為自己專案的artifactId -->
    <artifactId>crud-with-security</artifactId>
    <packaging>jar</packaging>
    <!-- 請改為自己專案的name -->
    <name>crud-with-security</name>
    <version>1.0-SNAPSHOT</version>
    <url>https://github.com/zq2599</url>

    <!--不用spring-boot-starter-parent作為parent時的設定-->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>

                <version>${springboot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- 不加這個,configuration類中,IDEA總會新增一些提示 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</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>

            <!-- exclude junit 4 -->
            <exclusions>
                <exclusion>
                    <groupId>junit</groupId>
                    <artifactId>junit</artifactId>
                </exclusion>
            </exclusions>

        </dependency>

        <!-- junit 5 -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- elasticsearch引入依賴  start -->
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- 使用spring boot Maven外掛時需要新增該依賴 -->
        <dependency>
            <groupId>jakarta.json</groupId>
            <artifactId>jakarta.json-api</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 需要此外掛,在執行mvn test命令時才會執行單元測試 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M4</version>
                <configuration>
                    <skipTests>false</skipTests>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>

        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <includes>
                    <include>**/*.*</include>
                </includes>
            </resource>
        </resources>
    </build>
</project>

組態檔

  • 為了成功連線es,需要兩個組態檔:SpringBoot常規的設定application.yml和es的自簽證書
  • 首先是application.yml,如下所示,因為本篇要驗證兩種授權方式,所以賬號、密碼、apiKey全部填寫在組態檔中,如下所示
elasticsearch:
  username: elastic
  passwd: 123456
  apikey: ZVVWMVY0RUJ1Y0dJeHBiZXJHdUo6WXloU1RoOUVUejJMS0JrMy1JeTJldw==
  # 多個IP逗號隔開
  hosts: 127.0.0.1:9200

編碼:啟動類

  • SpringBoot啟動類,平淡無奇的那種
@SpringBootApplication
public class SecurityApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}

編碼:組態檔

  • 接下來是全文的重點:通過Config類向Spring環境註冊服務bean,這裡有這兩處要注意的地方

  • 第一個要注意的地方:向Spring環境註冊的服務bean一共有兩個,它們都是ElasticsearchClient型別,一個基於賬號密碼認證,另一個基於apiKey認證

  • 第二個要注意的地方:SpringBoot向es伺服器端發起的是https請求,這就要求在建立連線的時候使用正確的證書,也就是剛才咱們從容器中複製出來再放入application.yml所在目錄的es01.crt檔案,使用證書的操作發生在建立ElasticsearchTransport物件的時候,屬於前面總結的套路步驟中的一步,如下圖紅框所示

  • 設定類的詳細程式碼如下,有幾處需要注意的地方稍後會說明
package com.bolingcavalry.security.config;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
import org.apache.http.message.BasicHeader;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.SSLContexts;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestClientBuilder.HttpClientConfigCallback;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StringUtils;

import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;

@ConfigurationProperties(prefix = "elasticsearch") //設定的字首
@Configuration
@Slf4j
public class ClientConfig {

    @Setter
    private String hosts;

    @Setter
    private String username;

    @Setter
    private String passwd;

    @Setter
    private String apikey;

    /**
     * 解析設定的字串,轉為HttpHost物件陣列
     * @return
     */
    private HttpHost[] toHttpHost() {
        if (!StringUtils.hasLength(hosts)) {
            throw new RuntimeException("invalid elasticsearch configuration");
        }

        String[] hostArray = hosts.split(",");
        HttpHost[] httpHosts = new HttpHost[hostArray.length];
        HttpHost httpHost;
        for (int i = 0; i < hostArray.length; i++) {
            String[] strings = hostArray[i].split(":");
            httpHost = new HttpHost(strings[0], Integer.parseInt(strings[1]), "https");
            httpHosts[i] = httpHost;
        }

        return httpHosts;
    }

    @Bean
    public ElasticsearchClient clientByPasswd() throws Exception {
        ElasticsearchTransport transport = getElasticsearchTransport(username, passwd, toHttpHost());
        return new ElasticsearchClient(transport);
    }

    private static SSLContext buildSSLContext() {
        ClassPathResource resource = new ClassPathResource("es01.crt");
        SSLContext sslContext = null;
        try {
            CertificateFactory factory = CertificateFactory.getInstance("X.509");
            Certificate trustedCa;
            try (InputStream is = resource.getInputStream()) {
                trustedCa = factory.generateCertificate(is);
            }
            KeyStore trustStore = KeyStore.getInstance("pkcs12");
            trustStore.load(null, null);
            trustStore.setCertificateEntry("ca", trustedCa);
            SSLContextBuilder sslContextBuilder = SSLContexts.custom()
                    .loadTrustMaterial(trustStore, null);
            sslContext = sslContextBuilder.build();
        } catch (CertificateException | IOException | KeyStoreException | NoSuchAlgorithmException |
                 KeyManagementException e) {
            log.error("ES連線認證失敗", e);
        }

        return sslContext;
    }

    private static ElasticsearchTransport getElasticsearchTransport(String username, String passwd, HttpHost...hosts) {
        // 賬號密碼的設定
        final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, passwd));

        // 自簽證書的設定,並且還包含了賬號密碼
        HttpClientConfigCallback callback = httpAsyncClientBuilder -> httpAsyncClientBuilder
                .setSSLContext(buildSSLContext())
                .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                .setDefaultCredentialsProvider(credentialsProvider);

        // 用builder建立RestClient物件
        RestClient client = RestClient
                           .builder(hosts)
                           .setHttpClientConfigCallback(callback)
                           .build();

        return new RestClientTransport(client, new JacksonJsonpMapper());
    }

    private static ElasticsearchTransport getElasticsearchTransport(String apiKey, HttpHost...hosts) {
        // 將ApiKey放入header中
        Header[] headers = new Header[] {new BasicHeader("Authorization", "ApiKey " + apiKey)};

        // es自簽證書的設定
        HttpClientConfigCallback callback = httpAsyncClientBuilder -> httpAsyncClientBuilder
                .setSSLContext(buildSSLContext())
                .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE);

        // 用builder建立RestClient物件
        RestClient client = RestClient
                           .builder(hosts)
                           .setHttpClientConfigCallback(callback)
                           .setDefaultHeaders(headers)
                           .build();

        return new RestClientTransport(client, new JacksonJsonpMapper());
    }

    @Bean
    public ElasticsearchClient clientByApiKey() throws Exception {
        ElasticsearchTransport transport = getElasticsearchTransport(apikey, toHttpHost());
        return new ElasticsearchClient(transport);
    }
}
  • 上述程式碼有以下幾處需要注意
  1. 這個設定類為業務程式碼提供了兩個服務bean,作用是操作es,這兩個服務bean分別由clientByPasswdclientByApiKey兩個方法負責提供
  2. 名為getElasticsearchTransport的方法有兩個,分別負責設定兩種鑑權方式:賬號密碼和apiKey
  3. 設定證書的操作被封裝在buildSSLContext方法中,在建立ElasticsearchTransport物件的時候會用到

編碼:業務類

  • 既然兩個ElasticsearchClient物件都已經註冊到Spring環境,那麼只要在業務類中注入就能用來操作es了

  • 新建業務類ESService.java,如下,可見通過Resource註解選擇了賬號密碼鑑權的ElasticsearchClient物件

package com.bolingcavalry.security.service;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.io.IOException;

@Service
public class ESService {

    @Resource(name="clientByPasswd")
    private ElasticsearchClient elasticsearchClient;

    public void addIndex(String name) throws IOException {
        elasticsearchClient.indices().create(c -> c.index(name));
    }

    public boolean indexExists(String name) throws IOException {
        return elasticsearchClient.indices().exists(b -> b.index(name)).value();
    }

    public void delIndex(String name) throws IOException {
        elasticsearchClient.indices().delete(c -> c.index(name));
    }
}
  • 至此,基本功能算是開發完成了,接下來編寫單元測試程式碼,驗證能否成功操作es8

編碼:單元測試

  • 新增單元測試類ESServiceTest.java,如下,功能是呼叫業務類ESService執行建立、刪除、查詢等索引操作
package com.bolingcavalry.security.service;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ESServiceTest {

    @Autowired
    ESService esService;

    @Test
    void addIndex() throws Exception {
        String indexName = "test_index";

        Assertions.assertFalse(esService.indexExists(indexName));
        esService.addIndex(indexName);
        Assertions.assertTrue(esService.indexExists(indexName));
        esService.delIndex(indexName);
        Assertions.assertFalse(esService.indexExists(indexName));
    }
}
  • 編碼完成,開始驗證

驗證:賬號密碼鑑權

  • 現在ESService中使用的es服務類是賬號密碼鑑權的,執行單元測試,看看是否可以成功操作ES,如下圖,符合預期

驗證:ApiKey鑑權

  • 再來試試ApiKey鑑權操作es,修改ESService.java原始碼,改動如下圖紅框所示

  • 為了檢查建立的索引是否符合預期,註釋掉單元測試類中刪除索引的程式碼,如下圖,如此一來,單元測試執行完成後,新增的索引還保留在es環境中

  • 再執行一次單元測試,依舊符合預期

  • 用eshead檢視,可見索引建立成功

  • 至此,SpringBoot操作帶有安全檢查的elasticsearch8的實戰就完成了,在SpringData提供elasticsearch8操作的庫之前,基於es官方原生client庫的操作是常見的elasticsearch8存取方式,希望本文能給您一些參考

原始碼下載

名稱 連結 備註
專案主頁 https://github.com/zq2599/blog_demos 該專案在GitHub上的主頁
git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該專案原始碼的倉庫地址,https協定
git倉庫地址(ssh) [email protected]:zq2599/blog_demos.git 該專案原始碼的倉庫地址,ssh協定
  • 這個git專案中有多個資料夾,本次實戰的原始碼在elasticsearch-tutorials資料夾下,如下圖紅框
  • elasticsearch-tutorials是個父工程,裡面有多個module,本篇實戰的module是crud-with-security,如下圖紅框

歡迎關注部落格園:程式設計師欣宸

學習路上,你不孤單,欣宸原創一路相伴...