背景
因为公司其他业务方使用的语言多种多样,以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,这个后续再有一篇内容来记一下这块儿应该怎么改把。