> **本文档属于 Cloud WAF — 企业 Web 防护管理平台**
> CLI 工具：`zcloud` · 5 大模块：guard / sys / analytics / cli_release / auth
> 完整 API 索引：[/api/openapi.json](/api/openapi.json) · 文档地图：[/sitemap.xml](/sitemap.xml) · AI 速读：[/llms.txt](/llms.txt)

---

# 认证说明

## 概述

Cloud WAF 当前支持两种认证方式。所有受保护接口都要求请求头携带 `Authorization`，同一请求只能选择其中一种通道：

| 场景 | 请求头 | 说明 |
|------|--------|------|
| 人工登录 / Web 控制台 / CLI 交互登录 | `Authorization: Bearer <token>` | token 由 `POST /api/auth/login` 颁发，通常有效期为数小时（受系统安全策略约束） |
| 脚本 / CI / 第三方系统集成 | `Authorization: ApiKey zck_<prefix>.<secret>` | API Key 是机器调用凭证，明文仅签发时返回一次，适合长期自动化对接 |

> **推荐**：人工操作使用 `Bearer` 会话；机器对接优先使用 API Key。历史的“账号密码换短期 token + 401 自动重登”仍可用，但只建议作为兼容方案。

## API Key 机器调用凭证

API Key 是发给脚本、CI、第三方系统使用的机器调用凭证。它代表某个用户在某个 OEM 下发起请求，并受到两层限制：

1. 签发用户当前拥有的 RBAC 权限；
2. API Key 自身的 `scopes` 列表。

最终有效权限为：`effective_perms = user.RBAC ∩ key.scope`。`scope` 只能收窄，不能放大；如果 `scopes` 为空数组，则完整继承签发用户当前 RBAC。

### 签发 API Key

```bash
curl -sS -X POST https://waf.example.com/api/sys/api-keys \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "生产对接",
        "scopes": ["guard.domain.list", "guard.domain.view"],
        "expires_in_days": 90
      }'
```

响应中的 `data.api_key` 是完整明文：

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "key_id": "8f21c0c5-55ae-4cbd-a60a-8e64a6e2b1d0",
    "api_key": "zck_abc12345.A1b2C3d4E5f6G7h8I9j0K1L2M3n4O5p6Q7r8S9t0",
    "prefix": "zck_abc12345",
    "last4": "5t0",
    "expires_at": 1732982400000
  }
}
```

> ⚠ `api_key` 明文仅此一次返回。后续列表接口只返回 `prefix` 和 `last4`，无法找回完整 secret；丢失后请吊销并重新签发。

### 使用 API Key 调接口

```bash
curl -sS https://waf.example.com/api/guard/domains \
  -H "Authorization: ApiKey zck_abc12345.A1b2C3d4..." \
  -H 'Accept-Language: zh-CN'
```

### API Key 与 Bearer token 的区别

| 维度 | Bearer token | API Key |
|------|--------------|---------|
| 主要用途 | 人登录后的短期会话 | 机器、脚本、CI、第三方系统长期调用 |
| 获取方式 | `POST /api/auth/login` | `POST /api/sys/api-keys` |
| 请求头 | `Authorization: Bearer <token>` | `Authorization: ApiKey zck_<prefix>.<secret>` |
| 生命周期 | 通常数小时，受自动登出策略影响 | 默认 90 天，最大 365 天，可主动吊销 |
| 权限边界 | 当前用户 RBAC | 当前用户 RBAC ∩ Key scopes |
| 明文保存 | 客户端保存 token | 只在签发响应返回一次，服务端不保存明文 |

### Scope 匹配规则（重要）

API Key 的 `scopes` 校验语义与用户角色 RBAC **刻意分离**，请务必先理解后再签发：

- **精确字符串相等**（大小写敏感）：`scope` 列表中的元素必须与接口要求的权限 key 字面量完全一致才算命中。
- **不展开通配符**：写 `guard.*` 或 `guard.domain.*` 进 `scopes` **不会命中任何接口**——服务端不做前缀 / glob / 正则展开。例如接口要求 `guard.domain.list`，scope 里只有 `guard.domain.*` 时仍然返回 `code=1053`。
- **与用户 RBAC 独立判定**：用户角色权限可以是 `guard.domain.*` 简化分配，但 API Key scope 必须列举到叶子 perm key。两者按 `effective_perms = user.RBAC ∩ key.scope` 串行判断，user 通配符匹配的接口若不在 scope 列表里仍然被拦截。
- **签发时如需 guard 全模块权限**：请逐条列举具体 perm key（例如 `guard.domain.list`、`guard.domain.view`、`guard.domain.create` …），可通过 `GET /api/sys/permissions/tree` 获取叶子权限清单。
- **失败错码**：scope 不命中本次接口返回 `code=1053`（HTTP 403）；与 1056 (`apikey forbidden`，越 OEM 边界) 是不同语义，排查时可按错码 1:1 区分。

### 安全建议

- 给 CI / 第三方系统创建专用低权限用户，再由该用户签发 API Key。
- `scopes` 只授予必需权限，例如只读域名列表时只给 `guard.domain.list` / `guard.domain.view`。
- 不要把完整 API Key 写进 git、日志或工单；只展示 `prefix` / `last4`。
- 定期查询 `GET /api/sys/api-keys`，审计 `last_used_at` / `last_used_ip`。
- 发现泄露或不再使用时，立即调用 `DELETE /api/sys/api-keys/:id` 吊销。

## 登录链路图（文字版）

```
┌─────────────┐                                  ┌────────────────┐
│ 客户端 / CLI │ ──── POST /api/auth/login ───▶ │ Cloud WAF 后端 │
└─────────────┘    {username, password}          └────────────────┘
       │                                                  │
       │ ◀────── 200 OK {data: {token, user}} ───────────│
       │                                                  │
       │ ──── GET /api/sys/users (Bearer token) ────────▶│
       │ ◀────── 200 OK {code:0, data:[...]} ────────────│
       │                                                  │
       │ ──── (token 过期) GET /api/... ────────────────▶│
       │ ◀────── 401 Unauthorized ───────────────────────│
       │                                                  │
       │ ──── POST /api/auth/login (重新登录) ──────────▶│
       │ ◀────── 200 OK {data: {token: <新>}} ──────────│
```

## 完整 curl 示例

### 1) 登录

```bash
curl -sS -X POST https://waf.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{
        "username": "admin",
        "password": "YOUR_PASSWORD"
      }'
```

返回：

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "token": "eyJhbGciOi...",
    "user": { "uuid": "abc-123", "username": "admin", "role_id": 1 }
  }
}
```

### 2) 携带 token 调用接口

```bash
TOKEN='eyJhbGciOi...'
curl -sS https://waf.example.com/api/sys/users \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept-Language: zh-CN'
```

### 3) 注销

```bash
curl -sS -X POST https://waf.example.com/api/auth/logout \
  -H "Authorization: Bearer $TOKEN"
```

注销后该 token 立刻失效，后续使用返回 401。

## CLI 多 profile 机制

CLI 把凭据写到 `~/.zcloud/credentials.toml`，结构如下：

```toml
active = "default"

[profiles.default]
api_url = "https://waf.example.com"
username = "admin"
token = "eyJhbGciOi..."
oem_id = "default"

[profiles.prod]
api_url = "https://prod.example.com"
username = "ops"
token = "eyJhbGciOi..."
```

切换 profile：

```bash
zcloud config profiles list
zcloud config profiles activate prod

# 注意：profiles create 只接受位置参数 <name>，不支持任何 flag
zcloud config profiles create staging
zcloud --profile staging config set api_url https://staging.example.com
```

每次 `zcloud auth login` 会更新当前 profile 的 token；不同 profile 互不干扰。

## 401 自动重试模式

token 过期是常态，正确做法是把"401 → 重登 → 重试"封装成 wrapper。

### Python 伪代码

```python
import requests

class CloudWAF:
    def __init__(self, base_url, username, password):
        self.base = base_url.rstrip('/')
        self.username = username
        self.password = password
        self.token = None
        self._login()

    def _login(self):
        r = requests.post(f"{self.base}/api/auth/login", json={
            "username": self.username,
            "password": self.password,
        })
        r.raise_for_status()
        self.token = r.json()["data"]["token"]

    def call(self, method, path, **kwargs):
        headers = kwargs.pop("headers", {})
        headers["Authorization"] = f"Bearer {self.token}"
        url = f"{self.base}{path}"
        r = requests.request(method, url, headers=headers, **kwargs)
        if r.status_code == 401:
            self._login()  # 重登
            headers["Authorization"] = f"Bearer {self.token}"
            r = requests.request(method, url, headers=headers, **kwargs)
        r.raise_for_status()
        return r.json()
```

### Go 伪代码

```go
type Client struct {
    BaseURL  string
    User     string
    Password string
    Token    string
    HTTP     *http.Client
}

func (c *Client) login() error {
    body, _ := json.Marshal(map[string]string{"username": c.User, "password": c.Password})
    resp, err := c.HTTP.Post(c.BaseURL+"/api/auth/login", "application/json", bytes.NewReader(body))
    if err != nil { return err }
    defer resp.Body.Close()
    var out struct{ Data struct{ Token string } }
    json.NewDecoder(resp.Body).Decode(&out)
    c.Token = out.Data.Token
    return nil
}

func (c *Client) Do(req *http.Request) (*http.Response, error) {
    req.Header.Set("Authorization", "Bearer "+c.Token)
    resp, err := c.HTTP.Do(req)
    if err != nil { return nil, err }
    if resp.StatusCode == 401 {
        resp.Body.Close()
        if err := c.login(); err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+c.Token)
        return c.HTTP.Do(req)
    }
    return resp, nil
}
```

## 机器对接模式（兼容方案）

机器对接推荐使用上文 API Key。只有在暂时无法签发 API Key、或需要兼容旧流水线时，才使用“账号密码换短期 token + 401 自动重登”模式。

`zcloud auth login` 是交互式命令（用 `util.ReadPassword` 读不回显的密码），不支持 `--username/--password` 之类的 flag。CI Pipeline / 第三方系统应直接调用登录 REST API 拿到 token，然后通过 `ZCLOUD_TOKEN` 环境变量注入 CLI。

推荐做法：

1. **优先评估 API Key**：如果只是调用 REST API，直接签发 API Key；如果必须复用旧 CLI 流程，再继续下面步骤
2. **创建专用账号**：在 Web 控制台为 CI 创建 `ci-bot` 用户，分配最小必要权限
3. **凭据保存到 secrets**：`CLOUDWAF_USERNAME` / `CLOUDWAF_PASSWORD` 存到 GitHub Actions / GitLab CI secrets
4. **任务开始时调 REST 拿 token**：`POST /api/auth/login`，密码用 base64 编码后传
5. **后续 CLI 命令读取 `ZCLOUD_TOKEN` 环境变量**，无需写入本地 credentials 文件
6. **永远启用 401 重试 wrapper**：长任务中 token 可能过期，wrapper 拿到 401 时重新登录刷新 token

```yaml
# Gitea Actions 示例
jobs:
  publish-cert:
    steps:
      - run: |
          curl -fsSL ${{ vars.WAF_URL }}/api/cli/install.sh | sh
          zcloud config set api_url ${{ vars.WAF_URL }}

          # 1) 通过 REST 接口换 token（密码 base64 编码）
          PWD_B64=$(printf '%s' "${{ secrets.WAF_PASS }}" | base64 -w0)
          TOKEN=$(curl -fsS "${{ vars.WAF_URL }}/api/auth/login" \
            -H 'Content-Type: application/json' \
            -d "{\"username\":\"${{ secrets.WAF_USER }}\",\"password\":\"$PWD_B64\"}" \
            | jq -r '.data.token')

          # 2) 注入环境变量后跑业务命令；CLI 自动读取 ZCLOUD_TOKEN
          export ZCLOUD_TOKEN="$TOKEN"
          zcloud guard certs upload --name star --cert ./fullchain.pem --key ./private.key

          # 3) 任务结束撤销 token
          zcloud auth logout
```

## 安全建议

- ✅ 始终走 HTTPS；HTTP 仅限本地开发
- ✅ token 不要写进 git，不要打日志
- ✅ API Key 明文仅保存到密钥管理系统，不要写入配置仓库或普通日志
- ✅ CI 用专用账号 + 最小权限，不要复用管理员
- ✅ 定期审计 `GET /api/sys/sessions` 在线会话，发现异常立即 `kill`
- ✅ 定期审计 `GET /api/sys/api-keys`，发现异常立即吊销
- ❌ 禁止把 token 嵌入前端代码（前端拿 token 应通过登录接口）
- ❌ 禁止把 API Key 嵌入前端代码或公开客户端
- ❌ 禁止跨账号共用 profile（多人共用 = 审计无法定责）

## 常见问题

| 现象 | 处理 |
|------|------|
| 登录返回 `code` 非 0 | 检查 `message`，常见原因：账号锁定、密码错、密码到期 |
| 调接口反复 401 | 检查 token 是否粘贴完整、Authorization 头格式是否正确 |
| API Key 调接口返回 401 | 检查请求头是否为 `Authorization: ApiKey zck_<prefix>.<secret>`，确认 key 未过期或吊销 |
| API Key 调接口返回 `code=1053` (HTTP 403) | `scopes` 未覆盖接口所需权限。注意 scope 是精确字符串相等，写 `guard.*` 不会命中任何接口，需逐条列举叶子 perm key |
| API Key 调接口返回 `code=1056` (HTTP 403) | 跨 OEM 越权：请求 hostname 与签发 API Key 时绑定的 OEM 不一致，请用同 OEM hostname 重试 |
| 登录返回 200 但 `data.token` 为空 | 服务端配置异常，联系运维 |
| 多个 CLI 进程互相挤掉对方 token | 用不同 profile 隔离 |

## 相关文档

- [API 文档](/docs/api) — `/api/auth/*` 与 `/api/sys/api-keys` 完整 schema
- [示例代码](/docs/examples) — 三语言 401 重试实现
- [权限矩阵](/docs/permissions) — 给 CI 账号最小权限的依据

---

*Cloud WAF · 支持 Bearer 会话与 API Key 机器调用凭证双通道认证*
