Skip to content

1.鉴权:如何保护你的数据安全?

不知道你有没有过这样的困惑,当你使用 etcd 存储业务敏感数据、多租户共享使用同 etcd 集群的时候,应该如何防止匿名用户访问你的 etcd 数据呢?多租户场景又如何最小化用户权限分配,防止越权访问的?

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

那么 etcd 是如何实现多种鉴权机制和细粒度的权限控制的?在实现鉴权模块的过程中最核心的挑战是什么?又该如何确保鉴权的安全性以及提升鉴权性能呢?

1.1整体架构

在详细介绍 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 数据呢?

解决方案当然是认证用户身份。那 etcd 提供了哪些机制来验证 client 身份呢?

正如我整体架构中给你介绍的,etcd 目前实现了两种机制,分别是用户密码认证和证书认证,下面我分别给你介绍这两种机制在 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 值计算迭代次数 cost

密码认证的原理

首先你可以通过如下的 auth enable 命令开启鉴权,注意 etcd 会先要求你创建一个 root 账号,它拥有集群的最高读写权限


$ etcdctl user add root:root
User root created
$ etcdctl auth enable
Authentication Enabled

$ etcdctl user add root:root
User root created
$ etcdctl auth enable
Authentication Enabled

启用鉴权后,这时 client 发起如下 put hello 操作时, etcd server 会返回"user name is empty"错误给 client,就初步达到了防止匿名用户访问你的 etcd 数据目的。 那么 etcd server 是在哪里做的鉴权的呢?


$ etcdctl put hello world
Error: etcdserver: user name is empty

$ etcdctl put hello world
Error: etcdserver: user name is empty

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

下面我通过鉴权模块的 user 命令,给 etcd 增加一个 alice 账号。我们一起来看看 etcd 鉴权模块是如何基于我上面介绍的技术方案,来安全存储 alice 账号信息


$ etcdctl user add alice:alice --user root:root
User alice created

$ etcdctl user add alice:alice --user root:root
User alice created

鉴权模块收到此命令后,它会使用 bcrpt 库的 blowfish 算法,基于明文密码、随机分配的 salt、自定义的 cost、迭代多次计算得到一个 hash 值,并将加密算法版本、salt 值、cost、hash 值组成一个字符串,作为加密后的密码

最后,鉴权模块将用户名 alice 作为 key,用户名、加密后的密码作为 value,存储到 boltdb 的 authUsers bucket 里面,完成一个账号创建

当你使用 alice 账号访问 etcd 的时候,你需要先调用鉴权模块的 Authenticate 接口,它会验证你的身份合法性

那么 etcd 如何验证你密码正确性的呢

鉴权模块首先会根据你请求的用户名 alice,从 boltdb 获取加密后的密码,因此 hash 值包含了算法版本、salt、cost 等信息,因此可以根据你请求中的明文密码,计算出最终的 hash 值,若计算结果与存储一致,那么身份校验通过

如何提升密码认证性能

通过以上的鉴权安全性的深入分析,我们知道身份验证这个过程开销极其昂贵,那么问题来了,如何避免频繁、昂贵的密码计算匹配,提升密码认证的性能呢

这就是密码认证的第二个难点,如何保证性能

想想我们办理港澳通行证的时候,流程特别复杂,需要各种身份证明、照片、指纹信息,办理成功后,下发通信证,每次过关你只需要刷下通信证即可,高效而便捷

那么,在软件系统领域如果身份验证通过了后,我们是否也可以返回一个类似通信证的凭据给 client,后续请求携带通信证,只要通行证合法且在有效期内,就无需再次鉴权了呢

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

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

  • Simple Token

Simple 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 社区仅建议在开发、测试环境中使用 Simple Token 呢?

首先它是有状态的,etcd server 需要使用内存存储 Token 和用户名的映射关系

其次,它的可描述性很弱,client 无法通过 Token 获取到过期时间、用户名、签发者等信息

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

  • JWT Token

JWT 是 Json Web Token 缩写, 它是一个基于 JSON 的开放标准(RFC 7519)定义的一种紧凑、独立的格式,可用于在身份提供者和服务提供者间,传递被认证的用户身份信息。它由 Header、Payload、Signature 三个对象组成, 每个对象都是一个 JSON 结构体

第一个对象是 Header,它包含 alg 和 typ 两个字段,alg 表示签名的算法,etcd 支持 RSA、ESA、PS 系列,typ 表示类型就是 JWT


{
"alg": "RS256",
"typ": "JWT"
}

{
"alg": "RS256",
"typ": "JWT"
}

第二对象是 Payload,它表示载荷,包含用户名、过期时间等信息,可以自定义添加字段


{
"username": username,
"revision": revision,
"exp":      time.Now().Add(t.ttl).Unix(),
}

{
"username": username,
"revision": revision,
"exp":      time.Now().Add(t.ttl).Unix(),
}

第三个对象是签名,首先它将 header、payload 使用 base64 url 编码,然后将编码后的

字符串用"."连接在一起,最后用我们选择的签名算法比如 RSA 系列的私钥对其计算签名,输出结果即是 Signature


signature=RSA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
key)

signature=RSA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
key)

WT 就是由 base64UrlEncode(header).base64UrlEncode(payload).signature 组成

为什么说 JWT 是独立、紧凑的格式呢

从以上原理介绍中我们知道,它是无状态的。JWT Token 自带用户名、版本号、过期时间等描述信息,etcd server 不需要保存它,client 可方便、高效的获取到 Token 的过期时间、用户名等信息。它解决了 Simple Token 的若干不足之处,安全性更高,etcd 社区建议大家在生产环境若使用了密码认证,应使用 JWT Token( --auth-token 'jwt'),而不是默认的 Simple Token

在给你介绍完密码认证实现过程中的两个核心挑战,密码存储安全和性能的解决方案之后,你是否对密码认证的安全性、性能还有所担忧呢?

接下来我给你介绍 etcd 的另外一种高性能、更安全的鉴权方案,x509 证书认证

证书认证

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

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

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

我们可以使用下面的 openssl 命令查看 client 证书的内容,下图是一个 x509 client 证书的内容,它含有证书版本、序列号、签名算法、签发者、有效期、主体名等信息,我们重点要关注的是主体名中的 CN 字段

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


openssl x509 -noout -text -in client.pem

openssl x509 -noout -text -in client.pem

21

证书认证在稳定性、性能上都优于密码认证

授权

当我们使用如上创建的 alice 账号执行 put hello 操作的时候,etcd 却会返回如下的"etcdserver: permission denied"无权限错误,这是为什么呢?


$ etcdctl put hello world --user alice:alice
Error: etcdserver: permission denied

$ etcdctl put hello world --user alice:alice
Error: etcdserver: permission denied

这是因为开启鉴权后,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。Role 表示角色,它是权限的赋予对象。Permission 表示具体权限明细,比如赋予 Role 对 key 范围在[key,KeyEnd]数据拥有什么权限。目前支持三种权限,分别是 READ、WRITE、READWRITE

下面我们通过 etcd 的 RBAC 机制,给 alice 用户赋予一个可读写[hello,helly]数据范围的读写权限, 如何操作呢

按照上面介绍的 RBAC 原理,首先你需要创建一个 role,这里我们命名为 admin,然后新增了一个可读写[hello,helly]数据范围的权限给 admin 角色,并将 admin 的角色的权限授予了用户 alice。详细如下

shell

$ #创建一个admin role 
etcdctl role add admin  --user root:root
Role admin created
# #分配一个可读写[hello,helly]范围数据的权限给admin role
$ etcdctl role grant-permission admin readwrite hello helly --user root:root
Role admin updated
# 将用户alice和admin role关联起来,赋予admin权限给user
$ etcdctl user grant-role alice admin --user root:root
Role admin is granted to user alice

$ #创建一个admin role 
etcdctl role add admin  --user root:root
Role admin created
# #分配一个可读写[hello,helly]范围数据的权限给admin role
$ etcdctl role grant-permission admin readwrite hello helly --user root:root
Role admin updated
# 将用户alice和admin role关联起来,赋予admin权限给user
$ etcdctl user grant-role alice admin --user root:root
Role admin is granted to user alice

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

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

在我们的这个案例中,很明显 hello 在 admin 角色可读写的[hello,helly) 数据范围内,因此它有权限更新 key hello,执行成功。你也可以尝试更新 key hey,因为此 key 未在鉴权的数据区间内,因此 etcd server 会返回"etcdserver: permission denied"错误给 client,如下所示


$ etcdctl put hello world --user alice:alice
OK
$ etcdctl put hey hey --user alice:alice
Error: etcdserver: permission denied

$ etcdctl put hello world --user alice:alice
OK
$ etcdctl put hey hey --user alice:alice
Error: etcdserver: permission denied
  1. 哪些场景会出现 Follower 日志与 Leader 冲突?

leader 崩溃的情况下可能 (如老的 leader 可能还没有完全复制所有的日志条目),如果 leader 和 follower 出现持续崩溃会加剧这个现象。follower 可能会丢失一些在新的 leader 中有的日志条目,他也可能拥有一些 leader 没有的日志条目,或者两者都发生。

2.follower 如何删除无效日志?

leader 处理不一致是通过强制 follower 直接复制自己的日志来解决。因此在 follower 中的冲突的日志条目会被 leader 的日志覆盖。leader 会记录 follower 的日志复制进度 nextIndex,如果 follower 在追加日志时一致性检查失败,就会拒绝请求,此时 leader 就会减小 nextIndex 值并进行重试,最终在某个位置让 follower 跟 leader 一致。这里我补充下为什么 WAL 日志模块只通过追加,也能删除已持久化冲突的日志条目呢? 其实这里 etcd 在实现上采用了一些比较有技巧的方法,在 WAL 日志中的确没删除废弃的日志条目,你可以在其中搜索到冲突的日志条目。只是 etcd 加载 WAL 日志时,发现一个 raft log index 位置上有多个日志条目的时候,会通过覆盖的方式,将最后写入的日志条目追加到 raft log 中,实现了删除冲突日志条目效果,你如果感兴趣可以参考下我和 Google ptabor