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,这个后续再有一篇内容来记一下这块儿应该怎么改把。

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