# etcd v3 身份认证设计

> etcd v3 身份认证

---

LLMS 索引： [llms.txt](/zh/llms.txt)

---

## 为什么不重用 v2 认证系统？ {#why-not-reuse-the-v2-auth-system}

v3 协议使用 gRPC 作为传输机制，而非 v2 所采用的 RESTful 接口。这一新协议为迭代和改进 v2 设计提供了机会。例如，v3 身份认证采用基于连接的身份认证，而非 v2 每请求身份认证的较慢方式。此外，v2 身份认证在实际应用中关于一致性推理的语义往往难以处理，这一点将在后续章节中详述。对于 v3，身份认证机制具有明确定义的描述和实现，解决了 v2 身份认证系统中的缺陷。

### 功能要求 {#functionality-requirements}

* 按连接进行身份认证，而非按请求
   * 为 gRPC API 实现基于用户 ID 和密码的身份认证
   * 身份认证策略变更后必须重新刷新认证
* 功能应与 v2 版本一样简单且实用
   * v3 提供扁平键空间，不同于 v2 的目录结构。权限检查将通过区间匹配实现。
* 其一致性保证应强于 v2 版本的身份认证

### 主要必要更改 {#main-required-changes}

* 客户端必须在发送已身份认证的请求前，仅通过专用连接完成身份认证
* 将权限信息（用户 ID 和授权的修订版本）添加到 Raft 命令中（`etcdserverpb.InternalRaftRequest`）
* 每个请求均在状态机层进行权限检查，而非 API 层

### 权限元数据一致性 {#permission-metadata-consistency}

认证相关的元数据也应像 etcd 中存储的其他数据一样，由 etcd 的 Raft 协议所控制的存储系统进行存储和管理。这是确保整个 etcd 集群可用性与一致性的必要条件。若读取或写入元数据（例如权限信息）需要所有节点达成一致（超过法定人数），则单个节点故障可能导致整个集群停止运行。要求所有节点同时达成一致，意味着只要任一集群成员离线，即使集群仍拥有可用的法定人数，普通读写请求也无法完成。这种全票通过机制最终会降低集群的可用性；而基于 Raft 的法定人数共识机制已足够，因为一致性排序自然带来共识。

etcd v2 协议中的身份认证机制存在一个复杂之处：元数据一致性应如上所述工作，但实际上并非如此。每个权限检查均由接收客户端请求的 etcd 成员（server/etcdserver/api/v2http/client.go）处理，包括跟随者成员。因此，检查结果可能基于过时的元数据。


此过时状态意味着，当操作员执行 etcdctl 时，认证配置无法立即反映。因此，无法知晓过时元数据的活跃时长。实际上，配置变更在命令执行后会立即生效。然而，在高负载情况下，不一致状态可能持续较长时间，从而导致用户和开发者遇到反直觉的情况。此时需采用如 [this](https://github.com/etcd-io/etcd/pull/4317#issuecomment-179037582) 的变通方案。

### 线性化请求存在不一致的权限是不安全的 {#inconsistent-permissions-are-unsafe-for-linearized-requests}

对写操作而言，身份认证状态不一致是最严重的问题。即使管理员已禁用某个用户的写权限，如果写操作仅相对于键值存储有序，而未相对于身份认证系统有序，仍可能导致写操作成功完成。若身份认证存储与键值存储之间缺乏有序性，系统将容易受到过期权限攻击。

因此，权限检查逻辑应添加到 etcd 的状态机中。每个状态机应在应用阶段（apply phase）根据其权限信息检查请求（因此权限信息不得过期）。

## 设计与实现 {#design-and-implementation}

### 身份认证 {#authentication}

首先，客户端必须仅通过 gRPC 连接来完成身份认证，以验证其用户 ID 和密码。etcd 服务器将返回身份认证响应。认证成功时，响应中包含身份认证令牌；认证失败时，返回错误信息。客户端可在发起 API 请求时，使用该身份认证令牌向 etcd 提交其凭证。

用于请求身份认证令牌的客户端连接通常会被丢弃；该连接无法携带新令牌的凭据。这是因为 gRPC 不提供在连接创建后为每个 RPC 添加凭据的方式（调用 `grpc.Dial()`）。因此，客户端无法将其通过连接获取的令牌分配给该连接。客户端需要建立新的连接以使用该令牌。

#### `Authenticate()` RPC 的实现说明 {#notes-on-the-implementation-of-authenticate-rpc}

`Authenticate()` RPC 根据给定的用户名和密码生成身份认证令牌。etcd 使用 Go 的 `bcrypt` 包来保存和校验配置的密码与提供的密码。按照设计，`bcrypt` 的密码校验机制计算开销较大，在普通 x64 服务器上耗时接近 100 毫秒。因此，在状态机应用阶段执行此校验会导致性能问题：整个 etcd 集群每秒仅能处理约 10 `Authenticate()` 个请求。

为保证良好性能，v3 认证机制在 etcd 的 API 层检查密码，该层可在 Raft 之外并行处理。然而，这可能导致潜在的检查时间与使用时间（TOCTOU）权限漏洞：
1. 客户端 A 发送请求 `Authenticate()`
1. API 层处理 `Authenticate()` 的密码检查部分
1. 另一个客户端 B 发送请求 `ChangePassword()`，服务器完成该请求
1. 状态机层处理从 `Authenticate()` 获取修订版本号的部分
1. 服务器向 A 返回成功
1. 此时 A 已使用过期的密码完成认证

为避免此类情况，API 层基于认证存储的修订版本执行 *版本号验证*。在检查密码时，API 层会保存认证存储的修订版本号。密码检查成功后，API 层将保存的修订版本号与最新的修订版本号进行比较。若两者不同，说明其他用户已更新认证元数据，因此会重试检查。通过该机制，可避免基于过时密码的密码检查成功。

### 解析 API 层中的令牌 {#resolving-a-token-in-the-api-layer}

在使用 `Authenticate()` 完成认证后，客户端可像未启用认证时一样建立 gRPC 连接。除原有的初始化流程外，客户端必须将令牌与新创建的连接关联。`grpc.WithPerRPCCredentials()` 提供了实现此目的的功能。

每个来自客户端的已认证请求均包含一个令牌。该令牌可通过服务器端的 `grpc.metadata.FromIncomingContext()` 获取。服务器可获取请求的发起者信息以及用户授权时间。相关信息将由 API 层填充至 Raft 日志条目（`etcdserverpb.InternalRaftRequest`）的头（`etcdserverpb.RequestHeader.Username` 和 `etcdserverpb.RequestHeader.AuthRevision`）。

### 检查状态机中的权限 {#checking-permission-in-the-state-machine}

在状态机的 apply 阶段检查 `etcdserverpb.RequestHeader` 中的认证信息。此步骤验证用户是否被授予对认证存储最新修订版本下所请求键的访问权限。

### 两种令牌类型：简单令牌和 JWT {#two-types-of-tokens-simple-and-jwt}

有两种类型的令牌：简单令牌和 JWT 令牌。简单令牌不适用于生产环境。其令牌未经过加密签名，服务器必须有状态地维护令牌与用户之间的对应关系；该类型令牌仅用于开发测试。生产部署应使用 JWT 令牌，因其经过加密签名并可验证。从实现角度看，JWT 是无状态的。其令牌可包含元数据，例如用户名和修订版本，因此服务器无需记忆令牌与元数据之间的对应关系。

> [!WARNING]
> 存在一个已知问题 [#18437](https://github.com/etcd-io/etcd/issues/18437)，与简单令牌相关。在 etcd 服务器中，令牌在 API 层进行解析，而简单令牌是带状态的。该过程未受到线性一致性检查的保护，这意味着某个 etcd 成员可能在完成前一次身份认证请求处理之前就接收到了下一次请求。在此情况下，成员可能会向客户端返回“无效的身份认证令牌”错误。该问题在网络状况良好的节点上通常很少发生，但如果存在显著延迟则可能发生。作为临时解决方案，应用程序应实现重试机制以处理此错误。

### 直接设置 JWT 令牌 {#directly-setting-jwt-tokens}

除了标准的 `Authenticate()` RPC 流程外，etcd 还支持在客户端级别直接设置 JWT 令牌。这使得应用程序能够在 etcd 之外管理 JWT 令牌的完整生命周期，包括令牌生成、验证和轮换。

#### 使用案例与工作流 {#use-case-and-workflow}

此方法在以下情况中非常有用：

* 独立的令牌管理系统（位于 etcd 之外）负责 JWT 令牌的生成和生命周期管理
* 应用程序通过外部机制（例如，环境变量、配置服务）接收预先签名的 JWT 令牌
* 令牌生命周期必须完全由客户端应用程序管理，而非由 etcd 的自动令牌生成机制管理

典型的使用流程如下：

1. 由外部权威机构（非 etcd）生成包含用户名及其他声明的已签名 JWT 令牌
2. 应用程序接收预签名令牌，并使用该令牌配置 etcd 客户端
3. 客户端将 JWT 令牌直接随请求发送（无需调用 `Authenticate()`）
4. etcd 服务器使用其配置的公钥验证令牌签名，并根据令牌中的用户名授予访问权限
5. 在令牌到期前，应用程序从外部权威机构获取新的令牌
6. 应用程序使用更新后的令牌创建新的客户端（令牌更新需要重新创建客户端）

#### 与标准身份认证的区别 {#how-it-differs-from-standard-authentication}

使用标准 `Authenticate()` 流程时：

* 客户端调用 `Authenticate()` 并提供用户名和密码
* etcd 生成并返回一个令牌
* 客户端自动在后续请求中使用该令牌
* 令牌刷新需再次调用 `Authenticate()`

直接设置 JWT 令牌时：

* 客户端使用预签名的 JWT 令牌进行初始化
* 客户端 **不** 调用 `Authenticate()`
* 令牌在所有请求中直接使用
* 客户端应用程序负责在令牌到期前获取新令牌，并管理客户端生命周期

#### 认证状态无有效令牌 {#authstatus-without-valid-token}

为支持自行管理 JWT 令牌的应用程序，`AuthStatus` RPC 旨在允许客户端判断身份认证是否启用，并获取当前的 `authRevision`。在令牌已过期的恢复场景中，客户端需要最新的修订版本，以便从外部令牌提供者获取新的有效令牌。

若缺少此功能，过期的令牌可能导致客户端无法获取当前 `authRevision`，从而引发死锁，致使无法生成新令牌。

## 关于 KVS 模型与文件系统模型的差异说明 {#notes-on-the-difference-between-kvs-models-and-file-system-models}

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

etcd v3 模型需要多次查找元数据，这与类似文件系统的设计不同。最坏情况下的查找开销将等于用户所有已授予键和区间总数的总和。该开销无法避免，因为 v3 的扁平键空间与 Unix 文件系统模型（每个 inode 均包含权限元数据）存在本质差异。实际上，该开销通常不会成为严重问题，因为元数据足够小，可以充分受益于缓存。
