《kubernetes 系列》5. etcd 是如何通过鉴权实现数据安全的?详解 etcd 的认证、授权与权限

发布时间 2023-05-31 00:37:23作者: 古明地盆

楔子

前面我们已经知道了如何使用 etcd 存储数据,但 etcd 作为云原生的基石,也大量应用在微服务上面。而提到微服务,你应该知道多租户的概念,多个用户使用同一个集群。那么这个时候如何实现隔离呢?因为如果不隔离,那么 A 用户可能会将 B 用户的数据覆盖掉,或者越权访问。

etcd 鉴权模块就是为了解决以上痛点而生。

那么 etcd 是如何实现多种鉴权机制和细粒度的权限控制的?在实现鉴权模块的过程中,最核心的挑战是什么?又该如何确保鉴权的安全性以及提升鉴权性能呢?下面我们就来介绍 etcd 的鉴权模块,深入剖析 etcd 如何解决上面的这些痛点和挑战。争取掌握 etcd 鉴权模块的设计、实现精要,了解各种鉴权方案的优缺点。并在实际应用中,根据自己的业务场景、安全诉求,选择合适的方案保护 etcd 数据安全。同时,我们也可以参考其设计、实现思想,然后应用到自己业务的鉴权系统上。

先来解释一下什么是认证和授权。

认证和授权

什么是认证?

通俗地讲,认证就是验证当前用户的身份是否合法的过程。比如指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功。

像用户名密码登录、邮箱发送登录链接、手机接收验证码等等都属于互联网中的常见认证方式,只要你能收到验证码,就默认你是账号的主人。认证主要是为了保护系统的隐私数据与资源。

而一旦认证通过,那么一定时间内就不用再认证了(银行转账等高度敏感操作除外),这时就可以将用户信息保存在会话中。会话是系统为了保存当前用户的登录状态所提供的机制,实现方式通常是 Session 或 Token。

什么是授权?

授权,简单来讲就是谁(who)对什么(what)进行了什么操作(how),和认证不同,认证是确认用户的合法性,以及让服务端知道你是谁,而授权则是为了更细粒度地对资源进行权限上的划分。所以授权是在认证通过后,控制不同的用户访问不同的资源

并且授权是双向的,可以是用户给服务端授权,也可以是服务端给用户授权。

  • 用户给服务端授权:比如你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限);你在登录微信小程序时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)。
  • 服务端给用户授权:比如你想追一个很火的剧,但被告知必须是 VIP 才能观看,于是你充钱成为了 VIP,那么服务端便会给你授予观看该剧(访问该资源)的权限。

而实现授权的方式业界通常使用 RBAC,这个 R 有两种解读:第一种是基于角色的访问控制,即 Role-Based Access Control;第二种是基于资源(权限)的访问控制,系统设计时定义好某项操作的权限标识,即 Resource-Based Access Control。

认证和授权,这两个单词的英文比较像,因此注意别混淆了。

etcd 的访问安全

用户权限功能是在 etcd 2.1 版本中新增加的功能,在 2.1 版本之前,etcd 是一个完全开放的系统,任何用户都可以通过 REST API 修改 etcd 存储的数据。etcd 2.1 版本中增加了用户(User)和角色(Role)的概念,引入了用户认证和鉴权的功能,但为了保持向后的兼容性和可升级性,etcd 的用户权限功能默认是关闭的。

用户 和 角色,很好理解,当你认证成功,服务端就知道你是哪一个用户。而用户扮演的角色不同,那么相应的权限也不同,所以 etcd 是采用了基于角色的访问控制。

无论数据信道是否经过加密(SSL / TLS,后续会讨论),etcd 都支持安全认证以及权限管理。etcd 的权限管理借鉴了操作系统的权限管理思想,存在用户和角色两种权限管理方法。在操作系统中,默认存在一个超级管理员 root(需要你自己创建),拥有最高权限,其余所有的用户权限都派生自 root。

etcd 认证体系分为 User 和 Role,Role 被授予给 User,代表 User 拥有某种权利,至于权利有多大,则取决于 Role 到底是什么角色,是超级管理员、普通用户,还是其它的什么。而 etcd 的认证体系中有一个特殊的用户和角色,那就是 root。

root 用户拥有对 etcd 访问的全部权限,并且必须在启动认证之前预先创建。而设置 root 用户的初衷是为了方便管理:管理角色 和 普通用户,并且 root 用户必须是 root 角色。所以 root 可以是一个用户,并且有一种角色也叫 root,拥有最高的权限。root 角色可以授予任何用户,一旦某个用户被授予了 root 角色,它就拥有了全局的读写权限以及修改集群认证配置的权限。

当然 root 角色权限过大,我们一般会根据业务给用户授予别的角色。但如果用户是 root,那么授予它的角色也必须是 root,至于其它用户,除了可以赋予 root 角色,也可以赋予别的角色。一般情况下,root 角色所赋予的特权用于集群维护,例如修改集群 member 关系,存储碎片整理,做数据快照等。

然后 etcd 包含三种类型的资源,具体如下:

  • 权限资源(permission resources): 表示用户和角色信息;
  • 键值资源(key-value resources): 表示键值对数据信息;
  • 配置资源(settings resources): 安全配置信息、权限配置信息和 etcd 集群动态配置信息(选举/心跳等等);

权限资源

1)User

User(用户)是一个被授予权限的身份,每一个用户都可以拥有多个角色(Role),用户操作资源的权限(例如读资源或写资源)是根据用户所具有的角色来确定的,而用户分为 root 用户和非 root 用户。

root 用户是 etcd 提供的一个特殊用户,在安全功能被激活之前必须创建 root 用户,否则会无法启动身份认证功能。root 用户具有 root 角色功能并允许对 etcd 内部进行任何操作,root 用户的主要目的是为了进行恢复:会生成一个密码并存储在某个地方,并且被授予 root 角色来承担系统管理员的功能。root 用户在我们对etcd集群进行故障排除和恢复时非常有用。

2)Role

Role(角色)用来关联权限,etcd 中每个角色都具有相应的权限列表,这个权限列表定义了角色对键值资源的访问权限。

root 角色具有对所有键值资源的完整权限,而且只有 root 角色具有管理用户资源和配置资源的权限(例如修改 etcd 集群的成员信息)。root 角色是内置的,不需要被创建而且不能被修改,但是可以授予任何用户相同的权限。也就是说 root 既是一个用户也是一个角色,其它的用户也可以具有 root 角色,而一旦具备 root 角色,那么和 root 用户就是差不多等价的了。

3)权限

etcd 提供了两种类型的权限(permission):读和写,对权限的所有管理和设置都需要通过具有 root 角色的用户来实现。权限列表是一个许可的特定权限的列表,后面会说。

键值资源

键值资源是指存储在 etcd 中的键值对信息,给定一个用于匹配的模式(pattern)列表,当用户请求的 key 值匹配到模式列表中的某项时,相应的权限便会被授予。

配置资源

配置资源存放着整个集群的特定配置信息,包括添加 / 删除集群成员、启动 / 禁用认证功能、替换证书和其它由管理员(root 角色持有者)维护的动态配置信息等。

etcd 访问控制实践

下面我们来看一下用户、角色相关的命令。

User 相关命令

可使用 etcdctl user 子命令来处理与用户相关的操作,比如:

1. 获取所有的 User

[root@satori-003 ~]# etcdctl user list
[root@satori-003 ~]# 

当前没有任何的User。

2. 创建一个 User

# 创建一个 hanzo 用户,会提示输入密码并二次确认
[root@satori-003 ~]# etcdctl user add hanzo
Password of hanzo: 
Type password of hanzo again for confirmation: 
User hanzo created
[root@satori-003 ~]# etcdctl user list
hanzo
[root@satori-003 ~]# 

3. 授予用户对应的 Role 和撤销用户所拥有的 Role(允许部分撤销)

# 给用户 hanzo 添加 super 角色, 但是 super 显然不存在, 这里只是演示命令
[root@satori-003 ~]# etcdctl user grant-role hanzo super
Error: etcdserver: role name not found
# 显然是失败的, 因为没有 super 这个角色, 也没有授予用户 hanzo
[root@satori-003 ~]# etcdctl user revoke-role hanzo super
Error: etcdserver: role is not granted to the user
[root@satori-003 ~]# 

4. 查看用户被授予的角色

# 角色目前为空
[root@satori-003 ~]# etcdctl user get hanzo
User: hanzo
Roles:
[root@satori-003 ~]# 

5. 修改密码

[root@satori-003 ~]# etcdctl user passwd hanzo
Password of hanzo: 
Type password of hanzo again for confirmation: 
Password updated
[root@satori-003 ~]# 

6. 删除用户

[root@satori-003 ~]# etcdctl user delete hanzo
User hanzo deleted
[root@satori-003 ~]# 

Role 相关命令

与 user 子命令类似,role 子命令可用来处理与角色相关的操作。可使用 etcdctl 子命令 etcdctl role 来为对应的 Role 角色指定相应的权限,然后将 Role 角色授予相应的 User,从而使 User 具有相应的权限。

1. 列出所有的 Role

[root@satori-003 ~]# etcdctl role list
[root@satori-003 ~]# 

2. 创建一个 Role

[root@satori-003 ~]# etcdctl role add common
Role common created
[root@satori-003 ~]# 

角色创建出来了,那么权限要如何指定呢?

3. 授予对某个 key 只读权限

# 授予 name 的只读权限
[root@satori-003 ~]# etcdctl role grant-permission common read name
Role common updated
[root@satori-003 ~]# 

4. 授予对一个范围的 key 只写权限

# 对字典序大于等于 a、小于 c 的 key 具有写权限
[root@satori-003 ~]# etcdctl role grant-permission common write a c
Role common updated
[root@satori-003 ~]# 

5. 授予对一组 key 可读可写权限

# 对 ka 开头的 key 授予只写权限
[root@satori-003 ~]# etcdctl role grant-permission common readwrite ka --prefix
Role common updated
[root@satori-003 ~]# 

6. 查看一个角色具有的权限

[root@satori-003 ~]# etcdctl role get common
Role common
KV Read:
        [ka, kb) (prefix ka)
        name
KV Write:
        [a, c)
        [ka, kb) (prefix ka)
[root@satori-003 ~]# 

除了显示地这些 key,其他 key 不能操作。而对于一个刚创建出来的角色,则是任何 key 都不能操作。

7. 收回一个角色的某个权限

# 收回对 ka 开头的 key 进行操作的权限, 这里不需要指定读或写, 显然是读写都收回
[root@satori-003 ~]# etcdctl role revoke-permission common ka --prefix
Permission of range [ka, kb) is revoked from role common

# 相关权限已经没了
[root@satori-003 ~]# etcdctl role get common
Role common
KV Read:
        name
KV Write:
        [a, c)
        
# 收回一个本来就没有权限操作的 key 会报错        
[root@satori-003 ~]# etcdctl role revoke-permission common abc
Error: etcdserver: permission is not granted to the role

# 只有读或写一种权限也可以, 只要有权限, 在收回的时候就不会报错
[root@satori-003 ~]# etcdctl role revoke-permission common name
Permission of key name is revoked from role common

# 可以看到只剩下对 [a, b) 的写权限了
[root@satori-003 ~]# etcdctl role get common
Role common
KV Read:
KV Write:
        [a, c)
[root@satori-003 ~]# 

8. 移除某个角色

# 此时整个角色就被删除了
[root@satori-003 ~]# etcdctl role delete common
Role common deleted
[root@satori-003 ~]# etcdctl role get common
Error: etcdserver: role name not found
[root@satori-003 ~]# 

启用用户权限功能

虽然我们介绍了权限相关,但是我们之前貌似并不需要权限就可以操作啊,这是因为没有开启权限。而开始权限可以通过 etcdctl auth 子命令开启。

1. 确认 root 用户已经创建

[root@satori-003 ~]# etcdctl user list
[root@satori-003 ~]# etcdctl user add root
Password of root: 
Type password of root again for confirmation: 
User root created
[root@satori-003 ~]# 

2. 启用权限认证功能

# 开启认证,开启之前必须确保 root 用户已创建
[root@satori-003 ~]# etcdctl auth enable
Authentication Enabled

# 认证一旦开启,写入的时候就需要指定具体用户了,因为要基于用户判断权限
[root@satori-003 ~]# etcdctl put name satori
Error: etcdserver: user name is empty

# 指定用户, 会提示输入密码
[root@satori-003 ~]# etcdctl put name satori --user root
Password: 
OK

# 也可以直接指定, 通过 user:password 方式
[root@satori-003 ~]# etcdctl put age 17 --user root:123321
OK

# 读也是同理
[root@satori-003 ~]# etcdctl get name --user root
Password: 
name
satori

# 直接指定密码
[root@satori-003 ~]# etcdctl get age --user root:123321
age
17
[root@satori-003 ~]# 

3. 关闭权限认证功能

[root@satori-003 ~]# etcdctl auth disable
Error: etcdserver: user name not found

# 即便是关闭权限, 也依旧需要指定一个用户
[root@satori-003 ~]# etcdctl auth disable --user root:123321
Authentication Disabled

这个时候可能有人好奇了,要是没有用户怎么办?答案是如果没有用户,etcd 是不会允许你开启认证的,我们举个栗子。

[root@satori-003 ~]# etcdctl user delete root
User root deleted
[root@satori-003 ~]# etcdctl auth enable
Error: etcdserver: root user does not exist

注意:我们说角色会被授予用户,而当我们开启认证的时候,会自动创建 root 角色并授予 root 用户。

# 此时用户和角色都没有
[root@satori-003 ~]# etcdctl user list
[root@satori-003 ~]# etcdctl role list

# 创建一个 root 用户, 否则无法开启认证
[root@satori-003 ~]# etcdctl user add root
Password of root: 
Type password of root again for confirmation: 
User root created

# 用户 root 已经创建好了, 但角色还不存在
[root@satori-003 ~]# etcdctl user list
root
[root@satori-003 ~]# etcdctl role list

# 显示 root 还不具备任何角色
[root@satori-003 ~]# etcdctl user get root
User: root
Roles:

# 开启认证
[root@satori-003 ~]# etcdctl auth enable
Authentication Enabled

# 由于认证开启, 因此执行时需要指定 root
[root@satori-003 ~]# etcdctl role list
Error: etcdserver: user name not found
# 显示 root 角色已经创建
[root@satori-003 ~]# etcdctl role list --user root:123321
root

# 查看 root 信息, 发现自动被授予 root 角色
[root@satori-003 ~]# etcdctl user get root --user root:123321
User: root
Roles: root
[root@satori-003 ~]# 

而我们说 root 用户和 root 角色都是可以被删除的,但那是在没有开启认证的情况下,如果开启了认证呢?

# 再创建一个用户 hanzo
[root@satori-003 ~]# etcdctl role add hanzo --user root:123321
Role hanzo created
# 用户 hanzo 是可以删除的, 当然权限也可以
[root@satori-003 ~]# etcdctl role delete hanzo --user root:123321
Role hanzo deleted

# 但是 root 用户和 root 权限无法删除
[root@satori-003 ~]# etcdctl user delete root --user root:123321
Error: etcdserver: invalid auth management
[root@satori-003 ~]# etcdctl role delete root --user root:123321
Error: etcdserver: invalid auth management

# 如果想删除, 那么需要先把认证给关掉
[root@satori-003 ~]# etcdctl auth disable --user root:123321
Authentication Disabled
# 此时 root 就可以删除了
[root@satori-003 ~]# etcdctl user delete root
User root deleted

# 但是这里还有一个容易忽略的地方, 如果我们想再次启动认证呢? 
# 显然再创建一个 root 启动不就行了吗? 我们来试试
[root@satori-003 ~]# etcdctl user add root 
Password of root: 
Type password of root again for confirmation: 
User root created
# 开启认证, 但是报错了: 告诉我们角色已存在, 相信你肯定想到了
# 因为我们刚才只把 root 用户删掉了, 但是没有删 root角色, 而我们说开启认证的时候会自动创建 root 角色
# 但是 root 角色已经存在了, 所以就报错了
[root@satori-003 ~]# etcdctl auth enable
Error: etcdserver: role name already exists

# 此时认证是没有开启的
[root@satori-003 ~]# etcdctl put name KOISHI
OK
[root@satori-003 ~]# etcdctl get name
name
KOISHI
# 而解决办法也很简单, 直接把 root 角色给删掉就可以了
[root@satori-003 ~]# etcdctl role delete root
Role root deleted
# 此时成功开启认证
[root@satori-003 ~]# etcdctl auth enable
Authentication Enabled
[root@satori-003 ~]# 

所以 root 用户在开启认证前必须手动创建,而 root 角色会在开启认证时自动创建,创建完毕后并自动授予 root 用户。

注意:root 角色的权利是最大的,具有该角色的用户可以操作任意的键值对。

# 创建一个 hanzo 用户
[root@satori-003 ~]# etcdctl user add hanzo
Password of hanzo: 
Type password of hanzo again for confirmation: 
User hanzo created

# 此时有两个用户:hanzo、root
[root@satori-003 ~]# etcdctl user list
hanzo
root
# 此时有两个角色:common、root
[root@satori-003 ~]# etcdctl role list
common
root

# hanzo 用户还不具备任何角色,我们授予它 common 角色
[root@satori-003 ~]# etcdctl user grant-role hanzo common
Role common is granted to user hanzo
# 然后开启认证
[root@satori-003 ~]# etcdctl auth enable
Authentication Enabled

# 显然 common 角色不具备操作 name 这个 key 的权限
[root@satori-003 ~]# etcdctl get name --user hanzo:123321
Error: etcdserver: permission denied
# 授予它 root 角色
[root@satori-003 ~]# etcdctl user grant-role hanzo root --user root:123321
Role root is granted to user hanzo
# 此时操作就不受限制了
[root@satori-003 ~]# etcdctl get name --user hanzo:123321
name
KOISHI

# 如果将角色从用户移除,那么等价于没有任何权限
[root@satori-003 ~]# etcdctl user revoke-role hanzo root --user root:123321
Role root is revoked from user hanzo
# 注意:一个用户可以具备多种角色
[root@satori-003 ~]# etcdctl user revoke-role hanzo common --user root:123321
Role common is revoked from user hanzo
[root@satori-003 ~]# etcdctl get name --user hanzo:123321
Error: etcdserver: permission denied

因此在使用时,要根据实际情况赋予用户相应的权限,并且开启认证。如果不开认证,那么所有客户端都可以自由操作;而开启认证,那么每一步操作都要指定用户,然后服务端基于用户(被授予的角色)判断是否具有执行该操作的权限。

然后补充一点,到目前位置我们的 key 都是一个普通的 ASCII 字符串,但生产上更多是目录的结构。举个例子:

[root@satori-003 ~]# etcdctl put /A/name satori
OK
[root@satori-003 ~]# etcdctl put /B/name koishi
OK
[root@satori-003 ~]# 

将 key 设置成这种形式可以更清晰地实现隔离,因为如果两个用户都具备修改某个 key 的权限,那么就会发生冲突。

# 开启认证
[root@satori-003 ~]# etcdctl auth enable
Authentication Enabled
# 授予 hanzo 用户 root 角色
[root@satori-003 ~]# etcdctl user grant-role hanzo root --user root:123321
Role root is granted to user hanzo

# 此时 hanzo 用户和 root 用户都具有操作相关 key 的权限
# 通过 root 用户设置 name2 为 xxx
[root@satori-003 ~]# etcdctl put name2 xxx --user root:123321
OK
[root@satori-003 ~]# etcdctl get name2 --user root:123321
name2
xxx
# 通过 hanzo 用户修改 name2 为 yyy
[root@satori-003 ~]# etcdctl put name2 yyy --user hanzo:123321
OK

# 此时两个用户获取 name2 的结果都是 yyy
[root@satori-003 ~]# etcdctl get name2 --user hanzo:123321
name2
yyy
[root@satori-003 ~]# etcdctl get name2 --user root:123321
name2
yyy

因为你不知道用户会设置什么 key,所以最直接的做法就是针对不同的用户给一个不同的前缀,比如 A 用户设置的 key 必须以/A/开头,B 用户设置的 key 必须以/B/开头。并且/A/开头的 key 只有 A 用户有权限操作,/B/开头的 key 只有 B 用户有权限操作,这样就不用担心 key 会冲突了。

以上就是权限相关的内容,下面我们来用一张图总结一下:

以上就是 etcd 安全部分的相关命令,不难理解,下面我们来介绍更深入的内容。

etcd 鉴权功能整体架构

etcd 鉴权体系架构由控制面和数据面组成。

上图是 etcd 鉴权体系控制面,你可以通过客户端工具 etcdctl 和鉴权 API 动态调整认证、鉴权规则,首选 AuthServer 收到请求后,为了确保各节点间鉴权元数据一致性,会通过 Raft 模块进行数据同步。当对应的 Raft 日志条目被集群半数以上节点确认后,Apply 模块通过鉴权存储 (AuthStore) 模块,执行日志条目的内容,将规则存储到 boltdb 的一系列"鉴权表"里面。

下图是数据面鉴权流程,由认证和授权流程组成。认证的目的是检查 client 的身份是否合法、防止匿名用户访问等。目前 etcd 实现了两种认证机制,分别是密码认证和证书认证。

认证通过后,为了提高密码认证性能,会分配一个 Token 给 client。client 后续其它请求携带此 Token,server 即可快速完成 client 的身份校验工作。而实现分配 Token 的服务也有多种,这是 TokenProvider 所负责的,目前支持 SimpleToken 和 JWT 两种。

虽然认证通过了,但在访问 MVCC 模块之前,还需要通过授权流程。授权的目的是检查 client 是否有权限操作请求的数据路径,etcd 实现了 RBAC 机制,支持为每个用户分配一个角色,为每个角色授予最小化的权限。

好了,etcd 鉴权体系的整个流程就是这样,下面我们就以 etcdctl put name 命令为例,深入分析一下以上鉴权体系是如何进行身份认证来防止匿名访问的,又是如何实现细粒度的权限控制以防止越权访问的。

etcd 的用户认证

首先我们来看第一个问题,如何防止匿名用户访问你的 etcd 数据呢?解决方案当然是认证用户身份。etcd 提供了两种机制来验证 client 身份,分别是用户密码认证和证书认证,下面说一下这两种机制在 etcd 中如何实现,以及这两种机制各自的优缺点。

密码认证

首先我们来讲讲用户密码认证,etcd 支持为每个用户分配一个密码。密码认证在我们生活中无处不在,从银行卡取款到微信、微博 app 登录,再到核武器发射,密码认证应用及其广泛,是最基础的鉴权方式。

但密码认证存在两大难点:

  • 如何保障密码安全性
  • 提升密码认证性能

我们首先来看第一个难点:如何保障密码安全性。如果你没有什么经验的话,你可能会说直接明文存储,当收到用户鉴权请求的时候,检查用户请求中的密码与存储中的是否一样,不就可以了吗?这种方案的确够简单,但不安全,如果存储密码的文件被黑客脱库了,那么所有用户的密码都将被泄露,进而可能会导致重大数据泄露事故。

也许你又会说,自己可以奇思妙想地构建一个加密算法,然后将密码翻译下,比如将密码中的每个字符按照字母表序替换成字母后的第 XX 个字母。然而这种加密算法,它是可逆的,一旦被黑客识别到规律,还原出你的密码后,脱库后也将导致全部账号数据泄密。

那如果我们用一种不可逆的加密算法是不是就行了呢?比如常见的 MD5,SHA-1,这方案听起来似乎有点道理,然而还是不严谨,因为它们的计算速度非常快,黑客可以通过暴力枚举、字典、彩虹表等手段,快速将你的密码全部破解。

LinkedIn 在 2012 年的时候 650 万用户密码被泄露,黑客 3 天就暴力破解出 90% 用户的密码,原因就是 LinkedIn 仅仅使用了 SHA-1 加密算法。

那应该如何进一步增强不可逆 hash 算法的破解难度呢?

一方面我们可以使用安全性更高的 hash 算法,比如 SHA-256,它输出位数更多、计算更加复杂且耗 CPU。另一方面我们可以在每个用户密码 hash 值的计算过程中,引入一个随机、较长的加盐 (salt) 参数,它可以让相同的密码输出不同的结果,这就会使彩虹表破解直接失效。

彩虹表是黑客破解密码的方法之一,它预加载了常用密码使用 MD5/SHA-1 计算的 hash 值,可通过 hash 值匹配快速破解你的密码。

最后我们还可以增加密码 hash 值计算过程中的开销,比如循环迭代更多次,增加破解的时间成本。

etcd 的鉴权模块如何安全存储用户密码?

etcd 的用户密码存储正是融合了以上讨论的高安全性 hash 函数(Blowfish encryption algorithm)、随机的加盐 salt、可自定义的 hash 值计算迭代次数。

下面我将通过上面介绍过的几个简单 etcd 鉴权 API,来介绍密码认证的原理。

[root@satori-003 ~]# etcdctl put name satori
Error: etcdserver: user name is empty
[root@satori-003 ~]# 

etcd server 收到 put name 请求的时候,在提交到 Raft 模块前,它会从你请求的上下文中获取用户身份信息。如果你未通过认证,那么在状态机应用 put 命令的时候,检查身份权限的时候发现是空,就会返回此错误给 client。

[root@satori-003 ~]# etcdctl user add alice:123321 --user root:123321
User alice created
[root@satori-003 ~]# 

创建一个 alice 用户,密码 123321,鉴权模块收到此命令后,它会使用 bcrpt 库的 blowfish 算法,基于明文密码、随机分配的 salt、自定义的 cost、迭代多次计算得到一个 hash 值。并将加密算法版本、salt 值、 cost、hash 值组成一个字符串,作为加密后的密码。最后,鉴权模块将用户名 alice 作为 key,用户名、加密后的密码作为 value,存储到 boltdb 的 authUsers bucket 里面,完成一个账号创建。

当你使用 alice 账号访问 etcd 的时候,会先调用鉴权模块的 Authenticate 接口,验证你的身份合法性。至于过程也很简单,鉴权模块首先会根据你请求的用户名 alice,从 boltdb 获取加密后的密码,里面包含了算法版本、salt、cost 等信息。然后再根据你请求中的明文密码,按照同样的规则重新计算一遍,若计算结果与存储一致,那么身份校验通过。

如何提升密码认证性能

通过以上的鉴权安全性的深入分析,我们知道身份验证这个过程开销还是很昂贵的,并且每次请求都要认证。那么问题来了,如何避免频繁、昂贵的密码计算匹配,提升密码认证的性能呢?这就是密码认证的第二个难点,如何保证性能。

如果你办理过港澳通行证,你会发现流程特别复杂,需要各种身份证明、照片、指纹信息。但办理成功后,会下发通信证,每次过关你只需要刷下通信证即可,高效而便捷。那么在软件系统领域如果身份验证通过了后,我们是否也可以返回一个类似通信证的凭据给 client,后续请求携带通信证,只要通行证合法且在有效期内,就无需再次鉴权了呢?

是的,etcd 也有类似这样的凭据,当 etcd server 验证用户密码成功后,它就会返回一个 Token 字符串给 client,用于表示用户的身份。后续请求携带此 Token,就无需再次进行密码校验,实现了通信证的效果。

etcd 目前支持两种 Token,分别为 Simple Token 和 JWT Token。

先来看 Simple Token,正如名字所言,它很简单,核心原理是当一个用户身份验证通过后,生成一个随机的字符串值 Token 返回给 client,并在内存中使用 map 存储用户和 Token 映射关系。当收到用户的请求时, etcd 会从请求中获取 Token 值,转换成对应的用户名信息,返回给下层模块使用。

但这种方式有个弊端,因为 Token 身份的象征,若 Token 泄露了,那数据就可能存在泄露的风险。而 etcd 是如何应对这种潜在的安全风险呢?很简单,方式就是过期时间,etcd 生成的每个 Token,都有一个过期时间 TTL 属性,Token 过期后 client 需再次验证身份,因此可显著缩小数据泄露的时间窗口,在性能和安全性上实现平衡。

在 etcd v3.4.9 版本中,Token 默认有效期是 5 分钟,etcd server 会定时检查你的 Token 是否过期,若过期则从 map 数据结构中删除此 Token。不过需要注意的是,Simple Token 字符串本身并未含任何有价值信息,因此 client 无法及时、准确获取到 Token 过期时间。所以 client 不容易提前去规避因 Token 失效导致的请求报错。

所以 Simple Token 存在严重的不足,因为它是有状态的,etcd server 需要使用内存存储 Token 和用户名的映射关系。其次,它的可描述性很弱,client 无法通过 Token 获取到过期时间、用户名、签发者等信息。

于是 etcd 鉴权模块实现的另外一个 Token Provider 方案 JWT,正是为了解决这些不足之处而生。

关于 JWT 相信不需要做过多解释了,因为除了 etcd,它在很多场景中都广泛使用。总之它是无状态的,JWT Token 自带用户名、版本号、过期时间等描述信息,etcd server 不需要保存它,client 可方便、高效地获取到 Token 的过期时间、用户名等信息。它解决了 Simple Token 的若干不足之处,安全性更高,etcd 社区建议大家在生产环境若使用了密码认证,应使用 JWT Token,而不是默认的 Simple Token。

如果你对密码认证的安全性和性能还有所担忧,那么 etcd 还提供了另一种高性能、更安全的鉴权方案:x509 证书认证。

证书认证

密码认证一般使用在 client 和 server 基于 HTTP 协议通信的内网场景中,当对安全有更高要求的时候,你需要使用 HTTPS 协议加密通信数据,防止中间人攻击和数据被篡改等安全风险。

HTTPS 是利用非对称加密实现身份认证和密钥协商,因此使用 HTTPS 协议的时候,你需要使用 CA 证书给 client 生成证书才能访问。

那么一个 client 证书包含哪些信息呢?使用证书认证的时候,etcd server 如何知道你发送的请求对应的用户名称呢?

我们首先创建一个 x509 证书,通过 openssl 即可创建。

# 使用 RSA 算法创建一个私钥,并保存到 client.key 中
openssl genpkey -algorithm RSA -out client.key
# 使用上一步创建的私钥,来创建一个证书签名请求 (CSR)
# 并将生成的 CSR 保存到 client.csr 文件中
# 注:这个命令会提示你输入一些信息,如国家名、组织名等,这些信息将包含在证书中
openssl req -new -key client.key -out client.csr
# 最后,使用你的私钥和 CSR 来创建一个自签名的 X509 证书
openssl x509 -req -days 365 -in client.csr -signkey client.key -out client.crt

此时证书就生成完毕,我们可以使用下面的 openssl 命令查看 client 证书的内容,如下图所示。它含有证书版本、序列号、签名算法、签发者、有效期、主体名等信息,我们重点要关注的是主体名中的 CN 字段。

在 etcd 中,如果你使用了 HTTPS 协议并启用了 client 证书认证 (将 ETCD_CLIENT_CERT_AUTH 配置为 true),它会取 CN 字段作为用户名,在我们的案例中,alice 就是 client 发送请求的用户名。

证书认证在稳定性、性能上都优于密码认证。稳定性上它不存在 Token 过期、使用更加方便、会让你少踩坑,避免了不少 Token 失 效而触发的 Bug。性能上,证书认证无需像密码认证一样调用昂贵的密码认证操作 (Authenticate 请求),此接口耗费的性能极低,后面还会深入讨论。

etcd 的用户授权

如果使用上面创建的 alice 账号执行 put name 操作,会发生什么呢?毫无疑问会返回 permission denied,表示没有权限。

[root@satori-003 ~]# etcdctl put name satori --user alice:123321
Error: etcdserver: permission denied
[root@satori-003 ~]# 

这是因为开启鉴权后,put 请求命令在应用到状态机前,etcd 还会对发出此请求的用户进行权限检查, 判断其是否有权限操作请求的数据。常用的权限控制方法有 ACL(Access Control List)、ABAC(Attribute-based access control)、RBAC(Role-based access control),etcd 实现的是 RBAC 机制。

那什么是基于角色权限的控制系统 (RBAC) 呢?

RBAC 由下图中的三部分组成,User、Role、Permission。User 表示用户,如 alice、root。Role 表示角色,它是权限的赋予对象。Permission 表示具体权限明细,比如赋予 Role 对字典序在 [keyStart, KeyEnd) 的 key 拥有什么权限。目前支持三种权限,分别是 READ、WRITE、 READWRITE。

下面我们通过 etcd 的 RBAC 机制,给 alice 用户赋予一个可操作指定数据的权限,相信你肯定知道怎么做。

# 创建一个 admin 角色
[root@satori-003 ~]# etcdctl role add admin --user root:123321
Role admin created

# 赋予相关权限
[root@satori-003 ~]# etcdctl role grant-permission admin READ name --user root:123321
Role admin updated
[root@satori-003 ~]# etcdctl role grant-permission admin READ age --user root:123321
Role admin updated
[root@satori-003 ~]# etcdctl role grant-permission admin WRITE gender --user root:123321
Role admin updated
[root@satori-003 ~]# etcdctl role grant-permission admin WRITE height --user root:123321
Role admin updated
[root@satori-003 ~]# etcdctl role grant-permission admin READWRITE salary --user root:123321
Role admin updated
[root@satori-003 ~]# etcdctl role grant-permission admin READWRITE hobby --user root:123321
Role admin updated

# 将用户 alice 和角色 admin 关联起来,赋予 admin 权限给 user
[root@satori-003 ~]# etcdctl user grant-role alice admin --user root:123321
Role admin is granted to user alice

然后当你再次使用 etcdctl 执行 put name 命令时,鉴权模块会从 boltdb 查询 alice 用户对应的权限列表。

补充:因为有可能一个用户拥有成百上千个权限列表,etcd 为了提升权限检查的性能,引入了区间树,检查用户操作的 key 是否在已授权的区间,时间复杂度仅为 O(logN)。

此时执行 put name 仍然报错,因为赋予 admin 的只有 name 的读权限,所以虽然不能写,但读是没问题的。

[root@satori-003 ~]# etcdctl put name satori --user root:123321
OK
[root@satori-003 ~]# etcdctl get name --user alice:123321
name
satori

# height 是可写的,因为赋予了对它的写权限
[root@satori-003 ~]# etcdctl put height 155 --user alice:123321
OK
# 但是没有赋予读权限
[root@satori-003 ~]# etcdctl get height --user alice:123321
Error: etcdserver: permission denied

因此基于 RBAC,etcd 便实现了细粒度的权限控制。

小结

从 etcd 鉴权模块核心原理分析过程中,我们发现设计实现一个鉴权模块最关键的目标和挑战应该是安全、性能以及一致性。

首先鉴权目的是为了保证安全,必须防止恶意用户绕过鉴权系统、伪造、篡改、越权等行为,同时设计上要有前瞻性,做到即使被拖库也影响可控。etcd 的解决方案是通过密码安全加密存储、证书认证、RBAC 等机制保证其安全性。

然后鉴权作为了一个核心的前置模块,性能上不能拖后腿,不能成为影响业务性能的一 个核心瓶颈。etcd 的解决方案是通过 Token 降低频繁、昂贵的密码验证开销,可应用在内网、小规模业务场景,同时支持使用证书认证,不存在 Token 过期,巧妙的取 CN 字段作为用户名,可满足较大规模的业务场景鉴权诉求。

接着鉴权系统面临的业务场景是复杂的,因此权限控制系统应当具备良好的扩展性,业务可根据自己实际场景选择合适的鉴权方法。etcd 的 Token Provider 和 RBAC 扩展机制,都具备较好的扩展性、灵活性。尤其是 RBAC 机制,让你可以精细化的控制每个用户权限,实现权限最小化分配。

最后鉴权系统元数据的存储应当是可靠的,各个节点鉴权数据应确保一致,确保鉴权行为一致性。早期 etcd v2 版本时,因鉴权命令未经过 Raft 模块,存在数据不一致的问题,在 etcd v3 中通过 Raft 模块同步鉴权指令日志指令,实现鉴权数据一致性。