实现一个Kubernetes的身份认证代理服务

最近接到一个需求:把K8s的认证和授权体系,整合到我们内部的系统中,使得我们内部系统的用户,可以无缝的直接访问K8s集群,同时也需要限制好用户对应namespace的权限。

对于需求的用户授权也就是authorization (authz)部分,实现思路还是比较简单的,毕竟K8s的RBAC实现相对来说还是非常完善的,而且RBAC对于我们目前的用户和组织权限管理理念十分的接近。所以只需要将目前系统里的用户权限和组织关系,对应到一系列的RBAC Role和RoleBinding里,就可以实现对于用户权限的精细化控制。

而对于用户的认证authentication (authn)部分,K8s提供了非常多的身份认证策略。但是如文档里明确的一点:

Kubernetes 假定普通用户是由一个与集群无关的服务通过以下方式之一进行管理的:

  • 负责分发私钥的管理员
  • 类似 Keystone 或者 Google Accounts 这类用户数据库
  • 包含用户名和密码列表的文件
    有鉴于此,Kubernetes 并不包含用来代表普通用户账号的对象。 普通用户的信息无法通过API调用添加到集群中。

K8s并不自己管理用户实体,所以是没有办法像RBAC那样,通过创建一个“User”资源,来把某个用户添加到集群里的。

其实这个特点,对于系统集成来说,可能更是一个优点,因为这直接避免了第三方系统的用户属性和K8s“用户”属性可能存在的不兼容问题。

而对于目前的需求而言,需要做到以下几点:

  1. 最好是基于Token实现,并且这个Token由我们自己的系统生成,同一个Token,既可以调用原有的API,也可以调用K8s的API。
  2. 尽可能保证K8s兼容性,最好用户可以无缝的,不需要经过复杂的配置,直接使用kubectl访问到集群。
  3. 记录所有用户的访问记录以便于各种审计工作。

针对这几个需求,又通读了一遍文档之后,最终决定使用身份认证代理这个方式,怎么理解呢:

K8s APIServer可以获取HTTP请求中的某些头部字段,根据头部字段的值来判断当前操作的用户。也就是说,如果实现一个反向代理服务器,由这个反向代理服务器实现Token的认证工作,确认用户请求的有效性,若用户请求有效,直接把用户的信息添加到HTTP请求头中,并代理到K8s Server,最终再由K8s中的RBAC规则,判断用户能否调用对应API。

这么做刚好能满足目前的需求,首先,Token的发放和验证完全和K8s没有关系,所以Token可以保持和原有系统保持不变;同样的,代理只是根据HTTP头进行验证并转发,也不会修改任何K8s API的调用方式和格式,所以也能保持很好的兼容性;又因为所有的用户请求都会经过代理服务器,所以代理服务器可以记录所有请求的详细信息,从而方便实现各种审计工作。

那么问题来了,K8s通过哪个HTTP Header获取用户信息呢?

APIServer提供了几个命令行参数:--requestheader-username-headers--requestheader-group-headers--requestheader-extra-headers-prefix,通过这几个参数来配置HTTP头的字段名称。
其中,只有--requestheader-username-headers这个参数是必须的,由于目前场景下只需要配置这一个参数就可以了。比如:添加--requestheader-username-headers=X-Remote-User到APIServer启动参数,APIServer就会从请求中获取X-Remote-User这个头,并用对应的值作为当前操作的用户。

事情还没有结束,既然APIServer会从请求头中获取用户名,那么问题来了,如何确保这个请求是可信的?如何防止恶意用户,伪造请求,绕过身份认证代理服务器,直接用假冒的请求访问APIServer怎么办?这样是不是就可以模拟任何用户访问了?那一定不行,得需要有个办法来验证代理服务器的身份。不过K8s的开发者们显然考虑到了这个问题,所以APIServer提供了--requestheader-client-ca-file--requestheader-allowed-names两个额外的参数,其中--requestheader-client-ca-file是必须的,用来指定认证代理服务器证书的CA位置,如果同时指定--requestheader-allowed-names,则在验证客户端证书发行者的同时,还会验证客户端证书的CN字段,确保不会有人用其他证书模仿代理服务器。

说到这里,整个解决方案的思路就已经比较清楚了:1.让用户带上token访问身份代理服务器;2.身份代理服务器解析token,确认用户身份后将用户名带入到请求X-Remote-User头,并转发给K8s,这里需要注意带上预先签好的客户端证书访问;3.K8s通过请求头部信息确认用户,并基于RBAC规则确认用户权限。

针对上面的方案,这里简单的使用openresty搭建了一个测试方案,主要也是因为目前的Token是jwt格式的,解析和验证也比较方便,这里贴一个比较简单的配置例子:

server {
	listen       80;
	server_name  test.k8sproxy.ichenfu.com;
	location / {
		access_by_lua '
			-- 因为token格式是jwt,且用户名是在jwt payload里的,所以需要依赖resty.jwt这个库
			-- 具体的安装方式这里不详细说明,可以查找其他资料
			local cjson = require("cjson")
			local jwt = require("resty.jwt")
			-- 拿到用户请求的Authorization头
			local auth_header = ngx.var.http_Authorization

			if auth_header == nil then
				-- 禁止没有认证信息的请求
				ngx.exit(ngx.HTTP_UNAUTHORIZED)
			end

			local _, _, jwt_token = string.find(auth_header, "Bearer%s+(.+)")
			if jwt_token == nil then
				-- 禁止认证信息有误的请求
				ngx.exit(ngx.HTTP_UNAUTHORIZED)
			end

			-- secret,需要保密!
			local secret = "ichenfu-jwt-secret"
			local jwt_obj = jwt:verify(secret, jwt_token)
			if jwt_obj.verified == false then
				-- 如果验证失败,说明Token有问题,禁止
				ngx.exit(ngx.HTTP_UNAUTHORIZED)
			else
				-- 验证成功,设置X-Remote-User头为用户名(假设用户名存储在payload中的user字段)
				ngx.req.set_header("X-Remote-User", jwt_obj.user)
			end
		';
		proxy_ssl_certificate /usr/local/openresty/nginx/conf/ssl/auth-proxy.pem;
		proxy_ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/auth-proxy-key.pem;
		proxy_pass https://test.k8scluster.ichenfu.com:6443;
	}
}

说起来openresty确实很方便,十几行代码就搞定了一个K8s的认证代理服务器。不过在后续测试过程中,遇到了一个问题。基于上面的逻辑,用户可以拿着Token,使用kubectl访问集群,但是在实际测试过程中,发现即使在kubeconfig文件中添加了Token,甚至使用kubectl --token="xxxxxxxxx" get pods这种在命令行里,指定Token的方式,都会提示请求失败,找不到认证信息。一开始,以为是自己lua程序写的有问题,最后通过kubectl --token="xxxxxxxxx" get pods --v=10 2>&1把请求过程打印出来才发现,kubectl根本不会把Token带入到请求头中!

经过一番查找,找到了kubectl does not send Authorization header (or use specified auth plugin) over plain HTTP #744这个Issue。才发现原来kubectl在默认情况下,如果访问一个HTTP协议的API地址,就认为这个服务是不需要认证的,如果需要认证,那API地址必须是HTTPS协议。

所以,为了实现预期的结果,还需要修改一下nginx配置文件,把监听换成HTTPS:

server {
		listen       443 ssl;
		server_name  test.k8sproxy.ichenfu.com;
		ssl_certificate      /usr/local/openresty/nginx/conf/ssl/kubernetes.pem;
		ssl_certificate_key  /usr/local/openresty/nginx/conf/ssl/kubernetes-key.pem;

		#localtion配置保持不变
		#...
}

最终,所有需求都完美实现!当然需求的实现方式肯定不止这一种,而且最终即使使用这种方式,可能也不太会选择openresty,但是整体实现和测试的过程还是非常有意思的,特别是“意外”地知道了kubectl对于服务器认证的相关处理,收获还是不少的。