etcd v3 认证设计

etcd v3 认证

为什么不复用 v2 认证系统?

v3 协议使用 gRPC 作为传输层,而不是像 v2 那样的 RESTful 接口。这一新协议为迭代和改进 v2 的设计提供了机会。例如,v3 认证采用基于连接的认证,而非 v2 中较慢的每次请求都认证的方式。此外,v2 认证的语义在实际使用中往往难以处理一致性推理问题,这将在接下来的章节中详细描述。对于 v3,我们提供了一个明确定义并实现的认证机制,以修复 v2 认证系统中的缺陷。

功能需求

  • 基于连接的认证,而非每次请求都认证
    • 为 gRPC API 实现了基于用户 ID 和密码的认证
    • 认证必须在认证策略更改后刷新
  • 其功能应尽可能像 v2 一样简单且实用
    • v3 提供扁平化的键空间,不像 v2 那样具有目录结构。权限检查将通过区间匹配来实现。
  • 它应比 v2 认证具有更强的一致性保证

主要需要的变更

  • 客户端在发送已认证请求之前,必须创建一个专门用于认证的独立连接
  • 将权限信息(用户 ID 和授权版本)添加到 Raft 命令中(etcdserverpb.InternalRaftRequest
  • 每个请求都在状态机层进行权限检查,而不是在 API 层

权限元数据的一致性

认证元数据也应像 etcd 中存储的其他数据一样,存储并管理在由 etcd 的 Raft 协议控制的存储中。这是为了不牺牲整个 etcd 集群的可用性和一致性。如果读取或写入元数据(例如权限信息)需要每个节点都达成一致(超过法定人数),那么单个节点的故障就可能导致整个集群停止运行。要求所有节点同时达成一致意味着,即使集群拥有可用的法定人数,只要有一个成员宕机,普通的读写请求就无法完成。这种全票通过机制最终会降低集群的可用性;而基于法定人数的 Raft 共识已经足够,因为一致性来源于有序的操作序列。

etcd v2 协议中的认证机制存在一个棘手的问题:元数据一致性本应如上所述工作,但实际上并非如此:每次权限检查都由接收到客户端请求的 etcd 成员处理(server/etcdserver/api/v2http/client.go),包括 follower 节点。因此,检查可能基于过时的元数据。

这种过时意味着认证配置无法在操作员执行 etcdctl 命令后立即生效。因此,无法知道过时的元数据会持续多长时间。通常情况下,配置更改会在命令执行后立即反映出来。然而,在某些高负载情况下,不一致的状态可能会持续更久,从而导致用户和开发者遇到反直觉的情况。这就需要像这样的变通方案:https://github.com/etcd-io/etcd/pull/4317#issuecomment-179037582

不一致的权限对线性化请求是不安全的

不一致的认证状态对写操作影响最为严重。即使管理员禁用了某个用户的写权限,但如果写操作仅相对于键值存储进行了排序,而未与认证系统同步,则该写操作仍可能成功执行。如果未能同时在认证存储和键值存储上保证顺序一致性,系统将容易受到过期权限攻击。

因此,权限检查逻辑应当被加入到 etcd 的状态机中。每个状态机在应用阶段都应基于其权限信息对请求进行检查(从而确保认证信息不会过时)。

设计与实现

认证

最初,客户端必须创建一个 gRPC 连接,仅用于认证其用户 ID 和密码。etcd 服务器将返回一个认证响应:成功时为一个认证令牌,失败时为一个错误。客户端可在发起 API 请求时使用该认证令牌向 etcd 提供凭据。

用于请求认证令牌的客户端连接通常会被丢弃;它无法携带新生成的令牌凭据。这是因为 gRPC 在连接创建后(即调用 grpc.Dial() 后)并未提供添加每次 RPC 调用凭据的方法。因此,客户端无法将其通过该连接获取的令牌赋给自身连接。客户端需要新建一个连接来使用该令牌。

Authenticate() RPC 实现说明

Authenticate() RPC 根据给定的用户名和密码生成一个认证令牌。etcd 使用 Go 语言的 bcrypt 包来保存并比对配置的密码与提供的密码。由于设计原因,bcrypt 的密码验证机制计算开销较大,在普通 x64 服务器上耗时接近 100 毫秒。因此,若在状态机的应用阶段执行此检查,会导致性能问题:整个 etcd 集群每秒只能处理约 10 个 Authenticate() 请求。

为了获得良好的性能,v3 认证机制在 etcd 的 API 层进行密码验证,这样可以在 Raft 之外并行处理。然而,这可能导致潜在的“检查时刻/使用时刻”(TOCTOU)权限漏洞:

  1. 客户端 A 发送了一个 Authenticate() 请求
  2. API 层处理 Authenticate() 中的密码验证部分
  3. 另一个客户端 B 发送了 ChangePassword() 请求,服务器已完成该请求
  4. 状态机层处理为来自 A 的 Authenticate() 请求获取修订号的部分
  5. 服务器向 A 返回成功响应
  6. 此时 A 已使用一个已过期的密码完成了认证

为了避免这种情况,API 层基于认证存储的修订号执行版本号验证。在密码验证过程中,API 层会保存认证存储当时的修订号。密码验证成功后,API 层会比较保存的修订号与最新的修订号。如果两者不同,说明其他方已更新了认证元数据,此时将重试验证过程。通过这一机制,可避免基于过期密码的错误认证成功。

在 API 层解析令牌

通过 Authenticate() 完成认证后,客户端可以像未启用认证时一样创建 gRPC 连接。除了原有的初始化流程外,客户端还必须将令牌关联到新创建的连接上。grpc.WithPerRPCCredentials() 提供了实现此功能的机制。

来自客户端的每个经过身份验证的请求都包含一个令牌。服务器端可以通过 grpc.metadata.FromIncomingContext() 获取该令牌。服务器可以获知是谁发起了请求,以及用户是在何时被授权的。这些信息将由 API 层填充到 raft 日志条目(etcdserverpb.InternalRaftRequest)的头部中(etcdserverpb.RequestHeader.Usernameetcdserverpb.RequestHeader.AuthRevision)。

在状态机中检查权限

状态机应用阶段会检查 etcdserverpb.RequestHeader 中的身份验证信息。此步骤会验证用户在权限存储的最新版本中是否被授予了对所请求键的访问权限。

两种类型的令牌:简单令牌和 JWT

令牌类型有两种:simple 和 JWT。Simple 令牌并非为生产环境设计。其令牌未进行加密签名,服务器必须以有状态的方式跟踪令牌与用户的对应关系;它仅适用于开发测试。生产部署应使用 JWT 令牌,因为 JWT 是经过加密签名和验证的。从实现角度看,JWT 是无状态的,其令牌可包含用户名和版本等元数据,因此服务器无需记住令牌与元数据之间的对应关系。

注意: Simple 令牌存在一个已知问题 #18437。在 etcd 服务器内部,令牌在 API 层被解析,而 simple 令牌是有状态的。该过程未受到线性一致性检查的保护,这意味着某个 etcd 成员可能在尚未完成前一个身份验证请求处理时,就接收到了下一个请求。在这种情况下,该成员可能会向客户端返回“无效的身份验证令牌”错误。在网络状况良好的节点上,此问题通常较为罕见,但在网络延迟较大时可能发生。作为变通方案,应用程序可实现重试机制来处理此类错误。

关于 KVS 模型与文件系统模型差异的说明

etcd v3 是一个键值存储系统(KVS),而非文件系统。因此,权限可以以精确的键名或键范围(如 ["起始键", "结束键"))的形式授予用户。这意味着即使是对不存在的键也可以授予权限。用户应注意避免意外授予权限。而在类似文件系统的系统(例如 Chubby 或 ZooKeeper)中,inode 类的数据结构可以包含权限信息,因此无法对不存在的键授予权限(粘滞位情况除外)。

与类文件系统不同,etcd v3 模型需要多次查找元数据。最坏情况下的查找开销等于用户被授予的所有键和区间的总数之和。这种开销无法避免,因为 v3 的扁平键空间与 Unix 文件系统模型(每个 inode 都包含权限元数据)完全不同。但实际上,这种开销通常不会成为严重问题,因为元数据足够小,能够从缓存中受益。


最后更新于 2025 年 6 月 3 日:递归地将 v3.6 的内容复制到 v3.7(a90b2a6)