Files
wmailhub/docs/02_CLOUDFLARE_OAUTH.md
bigcode ecce90dfab v2.4.0 修 Email Routing 假成功 + 加 API Token 接入与邮箱/域名页交互改进
问题 6(收不到邮件、Dashboard 邮件路由里看不到域名)的根因:
  上一版把「开启 Email Routing」改成了 PUT /zones/{id}/email/routing,
  但实测该接口(以及 PATCH)是**纯回显** —— 返回 success:true 但 enabled 纹丝不动,
  域名在 Cloudflare 侧仍是 unconfigured、连 MX 记录都没有。Console 却照它的返回值
  把域名标成「已配置 Email Workers」,于是「显示配好了却收不到邮件」。
  实测只有 POST /zones/{id}/email/routing/enable 真的开启(线上对 adx9.com 与
  galaxyemail.com 逐一验证)。

后端
- cfapi: 改用 /enable,并在开启后回读 GET 校验 enabled===true,不满足即判失败;
  新增 CFApiError(带 Cloudflare 错误码)与 getEmailRoutingSettings
- cfapi: 错误码 2008(Non-Cloudflare MX records exist)翻译成中文并点名冲突的 MX 记录
- provision: Email Worker 统一命名 wmailhub-email(原先按账号 id 拼名),
  部署走 PUT,同名即覆盖更新
- db: 新增 decryptAccountToken —— 按账号取 token,不再取「最近一条连接」,
  并跳过已过期的 OAuth token;oauth_connections.account_id 真正写入
- resources: 新增 POST /api/accounts/token(API Token 方式添加账号):
  先校验 token 与账户 ID,确认可用才建账号并自动接入

前端
- 账号:「添加账号」改为模态窗口,OAuth / API Token 两个 tab,各自带说明
- 域名: 删除「Email Worker」列;「设置 Email Workers / 重新配置」先弹确认框
  (说明会改 MX 与路由),执行期间按钮 loading 且互相禁用;error 状态改成可读的 Alert + 重试
- 邮箱: 删除「状态」列(已由「启用」开关承担);地址列加复制按钮(带弹跳+变绿动画);
  「添加邮箱」窗口的域名只列已装 Email Workers 的、支持搜索、默认选中最近使用的域名;
  地址框可一键从 namedb 取随机用户名;勾选 catch-all 后地址框禁用
- 收件箱: 域名筛选只显示已装 Email Workers 的域名

测试
- cf-mock 复刻「PUT/PATCH 回 success 但不改 enabled」与 2008,锁住本次修复
- oauth.test 新增 4 例:2008 冲突、回读校验、API Token 接入、Token/账户 ID 校验失败
- ui-smoke 覆盖新增的添加账号/添加邮箱/确认框等交互(28/28 通过)

文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
2026-09-30 18:20:44 +08:00

186 lines
10 KiB
Markdown

# Cloudflare OAuth 与自动接入(docs/11 §14)
旧「Add Node」概念已删除,改为 **Connect Cloudflare Account**。
## 流程
```text
Console
|
v
Cloudflare OAuth
|
v
用户授权 Account
|
v
Console
+-- 保存 Account
+-- Account / Zone 发现
+-- 配置 Domain(同步账号下全部域名,pending)
+-- 创建 catch-all Mailbox
+-- 部署账号级 Email Worker
+-- 签发 WorkerCredential(secret 注入 Worker 脚本)
```
用户不需要执行安装命令,也不手工创建 API Token。
## 两种接入方式(Console「添加账号」)
| 方式 | token 来源 | 适用场景 |
| --- | --- | --- |
| **Cloudflare OAuth 授权** | 用户在 Cloudflare 授权页同意后由 Console 兑换 | 默认推荐;本系统不保存任何 Cloudflare 密钥 |
| **API Token 授权** | 用户自己粘贴一个 Cloudflare API Token | 不方便走 OAuth 跳转;或已有现成 token |
两条路走的是**同一套**自动接入流程(发现域名 → 同步域名 → 部署 Email Worker → 签发 Worker 凭据),
区别只在 token 从哪来。API Token 方式会先拿 token 探一次 Cloudflare,并与用户填写的账户 ID 比对,
确认可用后才建账号 —— 填错当场报错,不会留下一个永远配不成功的僵尸账号。
对应接口:`POST /api/oauth/cloudflare/start`(OAuth)与 `POST /api/accounts/token`(API Token)。
### 每条连接绑定到具体账号(v2.4.0)
`oauth_connections.account_id` 记录这条连接属于哪个账号,`decryptAccountToken()` **按账号取 token**,
不再取"最近一条连接"。多账号接入后,拿 A 账号的 token 去配 B 账号的域名会失败(zone 根本不在那个
token 的可见范围内)。对早期 `account_id` 为空的数据保留回退:该账号没有绑定连接时回退到最近一条
**未过期**的连接(OAuth access token 一小时就过期,过期的不再被取用,避免拿一个死 token 去调 API)。
## 安全
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
- OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。
- **授权回调不校验会话**:浏览器从 `dash.cloudflare.com` 跳回 Console 属于跨站顶级导航,`SameSite=Strict` 的会话 cookie 不会携带;该端点由一次性 state 承担 CSRF 防护,其余 `/api/oauth/*` 仍要求登录。
- Account ownership 以 token 实际可访问的 accounts 为准。
- 安装过程幂等,重复执行不产生重复资源。
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
## 前置条件:OAuth 客户端凭据
授权所需的 Client ID / Secret 有两个来源,**settings 表优先、env 兜底**:
| 来源 | 配置位置 | 说明 |
| --- | --- | --- |
| settings(优先) | Console「设置 → Cloudflare 接入」 | Secret 以 AES-GCM 密文存 `settings.cf_oauth_client_secret_enc`,只回显末 4 位;保存后立即生效,无需重新部署。清除走 `DELETE /api/settings/cf-oauth` |
| env(兜底) | `CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET` | 部署时注入;settings 未配置或密文损坏时使用 |
两个来源都没有时,`/api/oauth/cloudflare/start` 与 `/api/oauth/cloudflare/callback` 一律返回 503
—— 系统不提供任何模拟/降级通道,不会出现"看起来接入成功"的假账号与假资源。
`GET /api/settings` 会返回 `cf_oauth.source`(settings / env / none)与 `redirect_uri`(需在
Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source` 供账号页判断。
## Cloudflare OAuth App 配置清单(docs 依据:Cloudflare "Create OAuth Client" API)
在 <https://dash.cloudflare.com/profile/oauth-apps> 新建应用后,**必须**完成下面几项,否则授权端点
会直接拒绝(此时 Console 收不到 code,只有 `error=invalid_scope` / `invalid_request`):
| 字段 | 填什么 | 不对时的报错 |
| --- | --- | --- |
| Redirect URL(s) | `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(本地联调另加 `http://127.0.0.1:18787/api/oauth/cloudflare/callback`) | `invalid_request`: redirect_uri does not match |
| **Client URI** | Console 站点首页,如 `https://wmailhub.adx9.com` | `promotion to public requires a verified client_uri host` |
| 域名校验 | 按 Cloudflare 提示在 client_uri 域下加 TXT 记录(形如 `cloudflare_oauth_client_publisher=...`) | 同上,一直卡在 not verified |
| Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 |
| Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' |
### scope 词表与选择器的对应关系
Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`。`account:read` 这种冒号写法
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
| scope id | 选择器里的名称 | 用途 |
| --- | --- | --- |
| `zone.read` | Zone Read | 发现该账号下的域名,并顺带拿到账号 id 与账号名 |
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
| `email-routing-rule.write` | Email Routing Rules Edit | 配置 catch-all 规则指向 Worker |
| `zone-settings.write` | Zone Settings Write | **开启 Email Routing**(见下) |
**「开启 Email Routing」不归 Email Routing 权限管**(2026-09-30 实测):它在选择器里对应
Zone Settings Write。Email Routing 那一族权限全勾上也不够。
### 开启 Email Routing:三个接口里只有一个真的干活(2026-09-30 线上踩坑)
Cloudflare 对同一个设置对象给了三个「看起来能改」的方法,实测行为:
| 调用 | 返回 | `enabled` 是否变化 |
| --- | --- | --- |
| `PUT /zones/{id}/email/routing` | `success: true` | **否**(纯回显) |
| `PATCH /zones/{id}/email/routing` | `success: true` | **否**(纯回显) |
| `POST /zones/{id}/email/routing/enable` | `success: true` | **是** |
前两个是**假成功**:照它们的返回值把域名标成「已配置 Email Workers」,而 Cloudflare 侧其实
`status: unconfigured`、连 MX 记录都没有 —— 表现为「域名显示配好了却收不到邮件,Dashboard 的
邮件路由里也找不到这个域名」。(`POST` 到 `/email/routing` 这个路径本身不是有效路由,
返回 `10405 Method not allowed for this authentication scheme`。)
因此 `console/src/services/cfapi.ts` 的 `enableEmailRouting()`:
1. 只调 `POST /zones/{id}/email/routing/enable`(文档标 Deprecated,但至今仍可用且是唯一有效入口);
2. **开启后回读 `GET /zones/{id}/email/routing` 校验 `enabled === true`**,不满足即判失败;
3. 失败原因写进 `domains.setup_error`、域名标 `error` —— 绝不谎报「已配置」。
`tests/cf-mock.ts` 复刻了这个「PUT/PATCH 回 success 但不改状态」的行为,锁住修复。
### 开启失败的最常见原因:2008 Non-Cloudflare MX records exist
域名上已有别的邮件服务的 MX 记录时 Cloudflare 拒绝接管,`/enable` 返回错误码 **2008**。
`describeMxConflict()` 会再读一次 `GET /zones/{id}/dns_records?type=MX`,把**非 Cloudflare 的那条
记录点名**写进中文错误提示。注意:删掉冲突 MX 意味着原邮件服务不再收信,不可逆,由用户自己决定。
顺带一个接口层面的坑:
- catch-all 规则的请求体里,指向 Worker 的 action 是 `{ "type": "worker", "value": ["<脚本名>"] }`,
matchers 固定 `[{"type":"all"}]`。
两个容易踩的点:
- **Email Routing 在选择器里是四个独立权限**(`Email Routing Account Rules` / `Addresses` /
`Rules` / `Suppressions`),不是笼统的"email routing 写"。改 catch-all 规则对应上面表里的
`email-routing-rule.write`。
- **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效;
反过来请求里不写某个 scope,即使 App 勾了也不会被授予。
### 账号级读权限:不需要(2026-09-30 实测)
`GET /zones` 的每个 zone 自带 `account: {id, name}`,而该接口的 Accepted Permission 只有 Zone Read。
所以账号 id 和账号名都能从 zone 上直接读到:
- **不需要 `account-settings.read`** —— 它只覆盖 `GET /accounts`(列账号),本应用不再调这个接口;
- **不需要 `user-details.read`** —— 那是读「授权的那个人」(`/user`),跟 Cloudflare 账号无关。
实测该账号的 `GET /zones` 响应:
```json
{ "id": "...", "name": "adx9.com",
"account": { "id": "bf8cd983e6dfd171c1881b00b512ed5a", "name": "Phptits@msn.com's Account" } }
```
注意 `account_id` 这个旧字段在当前响应里已经是 `null`,读账号必须走 `account.id`。zone 上没有 `account`
时(非标响应)才回退:用 `account_id` + 第一个域名展示(如 `example.com 等 3 个域名`)。
## 授权配置自检(不用开浏览器)
`GET /api/oauth/cloudflare/preflight` 用当前配置请求一次 Cloudflare 授权端点但**不跟随跳转**,据跳转目标判定:
- 跳到登录/同意页 → `ok: true`,配置可用;
- 弹回我们的回调地址,或跳 `dash.cloudflare.com/oauth/error` → `ok: false` 并带回 `error` /
`error_description` 与一条处置建议。
设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。
### scope 探测:直接算出"哪些已授权、哪些还要勾"
授权端点在**显示同意页之前**就校验 scope,拒绝时通过 `error_description` **点名**是哪一个
(`... not allowed to request scope 'xxx'`)。于是可以逐轮「请求剩余候选 → 剔除被点名的那一个」,
收敛出该 App 实际持有的 scope 集合——每轮只是一个未认证请求,无需登录、无需同意、不产生任何授权。
`preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回:
```json
"scopes": { "allowed": ["zone.read", "workers-scripts.write", "email-routing-rule.write"],
"denied": ["zone-settings.read"], "probed": true, "note": null }
```
设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。