kafka使用mysql進行認證管理

背景

    因爲公司其他業務方使用的語言多種多樣,以C和C++爲主,還有python、php、go、等語言,java只佔很少一部分,所以在公司想推行kerberos認證很困難,總是遇到各種各樣的阻礙和業務方的不配合,或者乾脆以業務緊急爲理由,走非認證端口,所以我們想用一種相對簡單的認證方式來在公司進行推廣。想要的效果就是可以像kerberos一樣,動態的增刪用戶,然後對於用戶來說,只需要多幾行配置就可以認證,避免像kerberos一樣,要安裝java環境或者librdkafka依賴。

SASL/SCRAM

    SCRAM的全稱是Salted Challenge Response Authentication Mechanism ,谷歌翻譯是鹽化挑戰響應身份認證機制。感覺不太通順,也不好理解,算了,不管他了名字不是重點,和我們明文的用戶名和密碼或者md5認證的機制類似。kafka是支持SCRAM-SHA-256和SCRAM-SHA-512來認證的,並且將這些憑據存儲在zookeeper裏。
    這種方式的好處呢是可以動態的增刪用戶,用自帶的kafka-config.sh命令行工具就可以做到,但遺憾的是我們有多個kafka集羣,每個集羣都有自己的zookeeper集羣,要在每個集羣都維護同一套用戶體系實在有點心累。如果改用一個集中的zookeeper集羣來維護所有的kafka集羣元信息又有跨機房的問題,怕會帶來不穩定因素,所以就有了後面我們的第二種方案。

SASL/PLAIN

    原生的SASL/PLAIN的弊端在於,所有的用戶名密碼都必須事先在server端的jaas文件裏寫死,想要新增或者修改的話需要重啓集羣,這點很傷。於是我們就想到修改一下kafka的源碼,看下它是從哪裏讀到我們在jaas裏寫死的用戶名密碼,我們把這塊兒代碼改成從一個mysql庫裏讀取用戶名密碼信息不就好了,於是乎就有了後面的改造。

改造

新建mysql 元信息庫

    這裏除了我們需要的用戶名密碼信息之外,還有一些我們業務緯度的信息,可以根據需要增刪字段就可以。

CREATE TABLE `kafkaUser` (
  `username` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `business` varchar(255) DEFAULT NULL,
  `owner` varchar(255) DEFAULT NULL,
  `department` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 

新增mysql 連接池工具類

package org.apache.kafka.common.security.mysqlAuth;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

public class ConnectionPool {
  private static HikariDataSource dataSource;
  
  public void HikariDataSource() {}
  
  public static synchronized HikariDataSource getConnetion(String dbUrl, String username, String password, int size) {
    if (dataSource == null) {
      HikariConfig config = new HikariConfig();
      config.setJdbcUrl(dbUrl);
      config.setUsername(username);
      config.setPassword(password);
      config.setMaximumPoolSize(size);
      dataSource = new HikariDataSource(config);
    } 
    return dataSource;
  }
}

修改PlainSaslServer類

    負責plain認證的類在client這個模塊裏,開始我想當然的以爲它應該在core模塊裏,找了好半天,浪費了半天力氣。最後找到路徑是
clients/src/main/java/org/apache/kafka/common/security/plain/PlainSaslServer.java
就可以看到原生代碼對這塊兒的認證邏輯了,其實很簡單粗暴,就是將客戶端傳來的用戶名密碼,根據用戶名在jaas文件裏找我們期望的密碼,密碼匹配則認證通過,否則認證不通過。

 @Override
    public byte[] evaluateResponse(byte[] response) throws SaslException {
        /*
         * Message format (from https://tools.ietf.org/html/rfc4616):
         *
         * message   = [authzid] UTF8NUL authcid UTF8NUL passwd
         * authcid   = 1*SAFE ; MUST accept up to 255 octets
         * authzid   = 1*SAFE ; MUST accept up to 255 octets
         * passwd    = 1*SAFE ; MUST accept up to 255 octets
         * UTF8NUL   = %x00 ; UTF-8 encoded NUL character
         *
         * SAFE      = UTF1 / UTF2 / UTF3 / UTF4
         *                ;; any UTF-8 encoded Unicode character except NUL
         */

        String[] tokens;
        try {
            tokens = new String(response, "UTF-8").split("\u0000");
        } catch (UnsupportedEncodingException e) {
            throw new SaslException("UTF-8 encoding not supported", e);
        }
        if (tokens.length != 3)
            throw new SaslException("Invalid SASL/PLAIN response: expected 3 tokens, got " + tokens.length);
        String authorizationIdFromClient = tokens[0];
        String username = tokens[1];
        String password = tokens[2];

        if (username.isEmpty()) {
            throw new SaslException("Authentication failed: username not specified");
        }
        if (password.isEmpty()) {
            throw new SaslException("Authentication failed: password not specified");
        }

        String expectedPassword = jaasContext.configEntryOption(JAAS_USER_PREFIX + username,
                PlainLoginModule.class.getName());
        if (!password.equals(expectedPassword)) {
            throw new SaslException("Authentication failed: Invalid username or password");
        }

        if (!authorizationIdFromClient.isEmpty() && !authorizationIdFromClient.equals(username))
            throw new SaslException("Authentication failed: Client requested an authorization id that is different from username");

        this.authorizationId = username;

        complete = true;
        return new byte[0];
    }

官方都這麼粗暴了,我們改起來也很簡單,一樣的部分就省掉了,只貼我改的部分。

public byte[] evaluateResponse(byte[] response) throws SaslException {
 ......
    String expectedPassword = "";
    //這裏本來是想從kafka 本身的配置裏讀取server.properties的配置的,但是運行了一段時間就發現老是會出現File not Found 的Exception,所以就暫時寫死了,後續再修改把
    Properties serverProperties = new Properties();
    try (BufferedReader bufferedReader = new BufferedReader(new FileReader("/usr/local/kafka/config/server.properties"))) {
      serverProperties.load(bufferedReader);
    } catch (IOException e) {
      this.log.error("server.properties is not exist!");
    } 
    //從配置文件裏讀取數據庫的連接信息
    String dbUrl = serverProperties.getProperty("sasl.plain.dbUrl");
    String dbUserName = serverProperties.getProperty("sasl.plain.dbUserName");
    String dbPassWord = serverProperties.getProperty("sasl.plain.dbPassWord");
    try {
      maxPoolSize = Integer.parseInt(serverProperties.getProperty("sasl.plain.dbMaxPoolSize"));
    } catch (Exception e) {
      maxPoolSize = 10;
    } 
    HikariDataSource ds = ConnectionPool.getConnetion(dbUrl, dbUserName, dbPassWord, maxPoolSize);
    this.log.debug("KAFKA username = " + username);
    //從庫裏查詢期望的密碼
    try {
      Connection conn = ds.getConnection();
      PreparedStatement ps = conn.prepareStatement("select username,password from kafkaUser where username=?");
      ps.setString(1, username);
      ResultSet rs = ps.executeQuery();
      this.log.debug("the query has been execute!");
      while (rs.next()) {
        expectedPassword = rs.getString("password");
        this.log.debug("KAFKA expect password is = " + expectedPassword);
      } 
      rs.close();
      ps.close();
      conn.close();
    } catch (SQLException e) {
      e.printStackTrace();
    } 
    //和之前的邏輯一樣,判斷密碼是否匹配
    if ("".equals(expectedPassword)){
      throw new SaslException("Cant find the User :" + username + " in database");
    }
    ......
  }

打包測試

    打包就比較簡單了,cd 到目錄中運行./gradlew clients 即可,或者直接在idea裏編譯也可以,編譯完了之後替換掉libs目錄的kafka-clients-0.11.0.2.jar,依次滾動重啓broker。再我們的數據庫裏添加用戶,再將該用戶的用戶名密碼寫到客戶端的jaas文件,或者它們支持的配置裏,這裏各個語言的kafka庫都略微有所不同就不一一列舉了。在java裏甚至可以不用jaas文件了,直接把用戶密碼寫到consumer或者producer的properties中。

properties.put("security.protocol", "SASL_PLAINTEXT");
properties.put("sasl.kerberos.service.name","kafka");
properties.put("sasl.mechanism", "PLAIN");
properties.put("sasl.jaas.config",
                        "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"username\" password=\"password\";");

兼容上線

    ·因爲之前已經有一些業務在使用kerberos在線上運行了,所以不能直接把kerberos認證下掉,這裏參考了半獸人博客裏的方式,同時啓用兩種認證方式,在server.properties裏這樣配置,讓broker同時支持兩種協議的認證方式,broker內部之間的認證還是維持kerberos。

authorizer.class.name = kafka.security.auth.SimpleAclAuthorizer
security.inter.broker.protocol=SASL_PLAINTEXT
sasl.mechanism.inter.broker.protocol=GSSAPI
sasl.enabled.mechanisms=GSSAPI,PLAIN
sasl.kerberos.service.name=kafka

    ·同時jaas文件改成這樣,也是同時支持兩種認證方式

KafkaServer {
      com.sun.security.auth.module.Krb5LoginModule required
      useKeyTab=true
      storeKey=true
      serviceName="kafka"
      keyTab="/etc/keytab/kafka_120.keytab"
      principal="kafka/[email protected]";

      org.apache.kafka.common.security.plain.PlainLoginModule required
      username="kafka"
      password="superuser";
  };

後續

    加上這個功能之後,我們向業務方推廣kafka的認證果然輕鬆了許多,大家也不會找藉口訪問非認證端口了。因爲kafka現在還將權限信息也存放在zk裏面,我們也把kafka存放權限的地方從zk改成了mysql,這個後續再有一篇內容來記一下這塊兒應該怎麼改把。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章