zcbot/EMBED.md

260 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# zcbot 控制台 iframe 嵌入对接
> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 主页,需要做的事。
>
> 假设:platform 和 zcbot **不同源**(eg. `https://portal.example.com` 嵌 `https://zcbot.example.com`)。同源直接共享 localStorage 不需要这套握手。
---
## 一句话总结
```html
<iframe src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"></iframe>
```
iframe 加载完会发 `postMessage({type:"zcbot-ready"})` 给 platform。platform 后端拿 `PLATFORM_KEY``POST /v1/auth/login` 换出 JWT,通过 `postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。
embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 · "+ 新建任务"挪到任务面板。
---
## 1. URL 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `embed` | 是 | 固定 `1`,触发 embed 模式 |
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
| `wecom` | 否 | 固定 `1`,企业微信免登变体(见 §9):token 来自 `/v1/wecom/entry` 回调的 URL fragment,**不走 postMessage、不需要 `parent_origin`**。platform iframe 对接用不到这个参数 |
示例:
```
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com&task_id=01HXXXXX-...
```
---
## 2. postMessage 协议
所有消息 `data` 都是 plain object,`type` 字段标识种类。两端都必须校验 `event.origin`
### iframe → platform(子发父)
| `type` | 时机 | 字段 | platform 应做 |
|---|---|---|---|
| `zcbot-ready` | iframe 加载完成 / 已有缓存 token 但仍发一次 | (无) | 检查当前用户 → 调后端换 JWT → 回推 `zcbot-token` |
| `zcbot-401` | 用户操作触发 401(token 过期 / 被吊销) | (无) | 重新换 JWT(可能要求用户重新登录 platform)→ 回推 `zcbot-token`;或决定关闭 iframe |
### platform → iframe(父发子)
| `type` | 字段 | iframe 行为 |
|---|---|---|
| `zcbot-token` | `token` (string, JWT) <br> `user_id` (string, UUID) <br> `user_name` (string, 可选) | 写 localStorage + 进入主界面;若已在主界面(401 重签场景)只重载任务列表 |
---
## 3. platform 后端:换 JWT
zcbot 已经有 SSO 入口,**platform 后端用共享的 `PLATFORM_KEY` 代任意用户换 JWT**:
```
POST https://zcbot.example.com/v1/auth/login
Content-Type: application/json
{
"user_id": "<UUID,platform 决定>",
"platform_key": "<跟 zcbot env PLATFORM_KEY 同串>"
}
```
返回:
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user_id": "...",
"exp": 1740000000
}
```
- `PLATFORM_KEY`:platform 和 zcbot **后端共享的密钥**,**绝不能下放到浏览器**。换 token 必须 platform 后端代理。
- `user_id`:任意 UUID;首次出现 zcbot 会自动建 `users` 行(占位,无 email/密码),后续 task / message 都用这个 user_id 作为分区键。**platform 用户 ↔ zcbot user_id 映射由 platform 维护**(建议 1:1)。
- JWT 默认 7 天 TTL(可改 `ZCBOT_JWT_TTL_SECONDS` env)。token 过期前 iframe 收到 401 → 发 `zcbot-401` 给父端 → 父端再走这个换 token 流程。
### Node.js 后端示例
```js
// POST /api/zcbot-token (platform 自己的 API,由前端登录态保护)
app.post("/api/zcbot-token", async (req, res) => {
const userId = req.session.zcbotUserId; // platform user → zcbot user_id 映射
const r = await fetch("https://zcbot.example.com/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
platform_key: process.env.ZCBOT_PLATFORM_KEY,
}),
});
if (!r.ok) return res.status(502).json({ error: "zcbot login failed" });
const { token } = await r.json();
res.json({ token, user_id: userId, user_name: req.session.userName });
});
```
### Python (FastAPI) 后端示例
```python
import os, httpx
from fastapi import HTTPException
@app.post("/api/zcbot-token")
async def issue_zcbot_token(user_id: str = Depends(current_user_id)):
async with httpx.AsyncClient(timeout=5) as c:
r = await c.post(
"https://zcbot.example.com/v1/auth/login",
json={"user_id": user_id, "platform_key": os.environ["ZCBOT_PLATFORM_KEY"]},
)
if r.status_code != 200:
raise HTTPException(502, "zcbot login failed")
data = r.json()
return {"token": data["token"], "user_id": user_id}
```
---
## 4. platform 前端:iframe + postMessage
```html
<iframe
id="zcbot-frame"
src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"
style="width:100%; height:100vh; border:0;"
allow="clipboard-read; clipboard-write"
></iframe>
<script>
const ZCBOT_ORIGIN = "https://zcbot.example.com";
const frame = document.getElementById("zcbot-frame");
async function pushToken() {
// 调 platform 后端拿 zcbot JWT(后端用 PLATFORM_KEY 换)
const r = await fetch("/api/zcbot-token", { method: "POST", credentials: "include" });
if (!r.ok) {
console.error("zcbot token fetch failed");
return;
}
const { token, user_id, user_name } = await r.json();
frame.contentWindow.postMessage(
{ type: "zcbot-token", token, user_id, user_name },
ZCBOT_ORIGIN
);
}
window.addEventListener("message", (e) => {
if (e.origin !== ZCBOT_ORIGIN) return;
const t = e.data && e.data.type;
if (t === "zcbot-ready" || t === "zcbot-401") {
pushToken();
}
});
</script>
```
iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有多个全屏 `position:fixed` modal,iframe 太小会挤瘪)。
---
## 5. 安全要点
1. **`parent_origin` URL 参数必填** —— 缺失 / 非法 iframe 不工作。这是 postMessage 的 origin 白名单。
2. **`PLATFORM_KEY` 只在 platform 后端** —— 等同 JWT 签名权限,泄漏 = 任意伪造任意用户。前端代码 / Git 仓库 / 客户端日志都不能出现。
3. **iframe 收 message 强制校验 `event.origin === parent_origin`** —— 已在 dev.html 实现,父端也应对称校验 `event.origin === ZCBOT_ORIGIN`,防恶意页面伪造消息。
4. **CSP `frame-ancestors`** —— 真发布时 zcbot 这边建议加响应头限制只允许 platform 域嵌入,目前 zcbot 未默认设置(本地宽松,见 §6)。
5. **HTTPS 必须** —— `parent_origin``https://`,生产部署不要走 http(postMessage 在 http 不发警告,但中间人可改 iframe src 注入恶意 token)。
6. **TTL**:JWT 默认 7 天。若 platform 希望更短(强制频繁刷新),改 zcbot env `ZCBOT_JWT_TTL_SECONDS` 即可。
---
## 6. zcbot 部署侧需要做的事
> 这一节是 **zcbot 运维 / 我自己** 要做的,platform 工程不需要操心,列出来供参考。
1. **CORS `allow_origins`** 收紧到 platform 域名(目前 `["*"]`,见 `web/app.py:516`)
```python
allow_origins=["https://portal.example.com"],
allow_credentials=False,
```
2. **CSP `frame-ancestors`** 加响应头中间件,只放行 platform 域:
```python
@app.middleware("http")
async def csp_headers(req, call_next):
resp = await call_next(req)
resp.headers["Content-Security-Policy"] = (
"frame-ancestors https://portal.example.com"
)
return resp
```
(FastAPI 默认不发 `X-Frame-Options`,iframe 加载本身不会被浏览器拦;`frame-ancestors` 是 CSP 替代品,更细粒度。)
3. **env**:`PLATFORM_KEY` / `JWT_SECRET` 必填(已是启动 fail-fast 校验,见 `web/auth.py:67`)。
---
## 7. 调试
**本地试 iframe(同机两端口)**:
- zcbot 跑 `8765` → 浏览器开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=http://127.0.0.1:3000`
- 单独不带 platform 时 iframe 会显示"等待登录…",DevTools console 看 `postMessage` 收发。
- 手动模拟 platform 推 token(在 iframe DevTools 里):
```js
window.postMessage({type:"zcbot-token", token:"<手工 curl 换的 JWT>", user_id:"<UUID>"}, location.origin)
```
注意 origin 必须 match `parent_origin` 参数,本机调试时把 `parent_origin` 设成 iframe 自己的 origin 即可(自发自收)。
**curl 试后端换 token**:
```bash
curl -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000001","platform_key":"<env PLATFORM_KEY>"}'
```
---
## 8. 故障兜底
| 现象 | 原因 | 处置 |
|---|---|---|
| iframe 显示"embed 模式缺少 parent_origin 参数" | URL 里没传 `parent_origin` | 加上 `&parent_origin=https://...` |
| iframe 永远显"等待登录…" | platform 没收 / 没回 `zcbot-ready` | DevTools console 看 message;校验 `event.origin` 是否匹配 |
| iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin``token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 |
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |
| 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message |
---
## 9. 企业微信免登变体(`?embed=1&wecom=1`)
> 这一节是 embed 模式的**另一条 token 来路**,面向 zcbot 运维 / 企微管理员;platform iframe 对接(§1§4)不涉及。
用途:把 zcbot 配成**企业微信自建应用的「应用主页」**,成员在企微客户端里点开应用即自动登录,无需邮箱密码。
流程:
```
企微客户端点开应用
→ GET /v1/wecom/entry (302 到企微网页授权,snsapi_base 静默无感)
→ GET /v1/wecom/entry/callback (code→userid→反查绑定→签 JWT)
→ 302 /static/dev.html?embed=1&wecom=1#token=...&user_id=...
```
与 iframe embed 的差异:
| | iframe embed | wecom 免登 |
|---|---|---|
| token 来路 | 父页面 postMessage | URL fragment(前端读完立即 `history.replaceState` 清掉) |
| `parent_origin` | 必填 | 不需要 |
| 401 处理 | 发 `zcbot-401` 等父页面重签 | `location.replace("/v1/wecom/entry")` 静默重签 |
| 身份映射 | platform 维护 user_id | `channel_bindings` 里的企微绑定(**未绑定不自动建号**,提示先在控制台绑定) |
企微后台配置 + 可信域名要求见 `RUN.md` 企业微信段「应用主页免登」。外部浏览器(非企微客户端)打开 `/v1/wecom/entry` 会自动退到 wwlogin 扫码,同一 URL 两端可用。