260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# 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 两端可用。
|