From c1508d0df075015f0ac0e147a4f26f6d90ba619c Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 21 Jul 2026 14:59:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(web,docs):=20App=20=E5=A5=97=E5=A3=B3=20em?= =?UTF-8?q?bed=20app=20=E5=8F=98=E4=BD=93(relogin=5Furl=20+=20fragment=20?= =?UTF-8?q?=E6=B3=A8=E5=85=A5)+=20APP.md=20=E5=AF=B9=E6=8E=A5=E6=96=87?= =?UTF-8?q?=E6=A1=A3(bump=200.58.53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动 App 方案:原生壳(WebView)+ 原生登录页,登录走 platform 自有接口 (/api/login/token → /api/login/external-login,实测打通,后者服务端换 zcbot JWT)。 顶层 WebView 无父窗口,iframe 的 postMessage 协议失效,泛化企微免登为 app 变体: ?embed=1&relogin_url=<绝对地址>#token=..&user_id=.. —— fragment 注入读完即清, 401/logout 时 location.replace(relogin_url),原生壳拦自定义 scheme 换新 token 重进。 - state.js: EMBED_RELOGIN_URL 解析+消毒(拦 javascript:/data: 等可执行 scheme) - embed.js: 抽共用 readFragmentToken/gotoInitialTask,加 embedAppInit/embedAppRelogin - auth.js: logout 分支序 wecom → app → iframe - APP.md 新增(进入契约 + platform 登录链路 + 原生壳杂活清单 + H5 备选) - EMBED.md 精简 243→约 120 行,指向 APP.md Co-Authored-By: Claude Fable 5 --- APP.md | 74 ++++++++++++++ EMBED.md | 225 +++++++++-------------------------------- PROGRESS.md | 1 + core/__init__.py | 2 +- web/static/js/auth.js | 9 +- web/static/js/embed.js | 69 ++++++++----- web/static/js/state.js | 13 +++ 7 files changed, 185 insertions(+), 208 deletions(-) create mode 100644 APP.md diff --git a/APP.md b/APP.md new file mode 100644 index 0000000..6998cd7 --- /dev/null +++ b/APP.md @@ -0,0 +1,74 @@ +# zcbot 移动 App 套壳对接 + +> 给 **platform 工程**:把 zcbot 控制台打包成移动 App。 +> 架构:**原生壳(WebView)+ 原生登录页**。登录调 platform 自家接口换出 zcbot JWT,WebView 加载 zcbot 控制台时把 token 放 URL fragment 带入;token 失效时 zcbot 跳回 `relogin_url`,原生壳拦截后静默换新 token 重进。zcbot 前端不打包进 App,前端迭代不发版。 + +--- + +## 1. 登录链路(platform 自有接口,2026-07-21 实测可用) + +``` +GET /api/login/check_captcha_required/ → {require_captcha: bool} +GET /api/login/captcha?key=<随机串> → 验证码图(仅 require_captcha 时) +POST /api/login/token → platform access_token + form: username, password, key(必填,即使无验证码), grant_type=password [, captcha_text] +POST /api/login/external-login → zcbot JWT + header: Authorization: Bearer +``` + +`external-login` 返回的 `data` 字段: + +```json +{"token":"", "user_id":"", "name":"...", "user_name":"...", + "expires_at":"...", "ttl_seconds":604800} +``` + +其中 `token` / `user_id` 就是下一步进入 zcbot 所需的全部。 + +--- + +## 2. zcbot 进入契约 + +拿到 JWT 后,WebView 加载: + +``` +https:///static/dev.html?embed=1&relogin_url=<回登录入口的地址>#token=&user_id= +``` + +- **fragment**:`token` / `user_id` 两个字段都必填;zcbot 读完立即清掉,不留在地址栏和历史。 +- **`relogin_url`**:必须是带 scheme 的绝对地址。原生壳推荐自定义 scheme(如 `zcbotapp://relogin`);`javascript:` 等可执行 scheme 会被拒。 +- **401 行为**:zcbot JWT 失效时页面 `location.replace(relogin_url)`。原生壳在 `shouldOverrideUrlLoading`(Android)/ `WKNavigationDelegate`(iOS)拦截该跳转 → 用缓存的 platform token 重调 `external-login` → 带新 fragment 重新 loadUrl。 +- **token 生命周期**:zcbot JWT 7 天 TTL + 滑动续签(活跃期间自动续,页面内已处理)。App 每次冷启动都换一张新 JWT,实际只有 platform token 过期(约 30 天)才需要用户重输密码。 +- 可选 `&task_id=`:进入后自动定位到该 task。 + +**冷启动静默登录**:壳启动 → Keystore/Keychain 里有 platform token → 后台调 `external-login` → 直接 loadUrl 进控制台,用户无感;没有或已失效 → 显示原生登录页。 + +--- + +## 3. 原生壳杂活清单 + +| 项 | Android | iOS | +|---|---|---| +| 文件上传 | `WebChromeClient.onShowFileChooser` | 自带 | +| 文件下载 | `DownloadListener` → `DownloadManager` | `WKDownloadDelegate`(iOS 14.5+) | +| 返回键 | `canGoBack() ? goBack() : 退出确认` | 手势自带 | +| DOM storage | `setDomStorageEnabled(true)`(必须) | 自带 | +| 键盘顶起 | `windowSoftInputMode="adjustResize"` | 自带 | +| 外链 | 非 platform/zcbot 域交给系统浏览器 | 同左 | + +SSE 流式输出两端 WebView 原生支持,无需处理。iOS 上架提醒:纯套壳有 App Store 4.2(最小功能)被拒风险;企业分发 / TestFlight / 仅 Android 无此问题。 + +--- + +## 4. 备选:H5 登录页 + +不想写原生登录 UI 的话,可在 platform 域上放一个 `app.html`(登录表单 + 调自家接口,同源无 CORS),换到 JWT 后 `location.replace` 按 §2 契约跳进 zcbot,`relogin_url` 填 `app.html` 自己的地址(401 跳回来时若 platform token 还在缓存,静默重签再跳回,用户无感)。契约完全相同,只是登录层从原生换成网页。 + +--- + +## 5. 安全要点 + +1. **`PLATFORM_KEY` 只在 platform 后端**(`external-login` 内部用),App / 前端代码里绝不出现。 +2. **不存明文账密**。"记住登录"= 把 platform token 存 Keystore / Keychain,靠 token 生命周期免登,不缓存密码重放。 +3. **建议 platform 侧修**:`POST /api/login/token` 响应的 `user` 对象把 `hashed_password` 原样下发了,应在序列化时剔除。 +4. WebView 域名白名单:只放行 platform 与 zcbot 两个域 + 自定义 scheme,其余交系统浏览器。 diff --git a/EMBED.md b/EMBED.md index a017276..e4a3556 100644 --- a/EMBED.md +++ b/EMBED.md @@ -1,10 +1,8 @@ # zcbot 控制台 iframe 嵌入对接 -> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 主页,需要做的事。 +> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 网页。移动 App 套壳**不用 iframe**,另见 `APP.md`。 > -> 假设:platform 和 zcbot **不同源**(eg. `https://portal.example.com` 嵌 `https://zcbot.example.com`)。同源直接共享 localStorage 不需要这套握手。 - ---- +> 假设两边不同源;同源直接共享 localStorage,不需要这套握手。 ## 一句话总结 @@ -12,9 +10,9 @@ ``` -iframe 加载完会发 `postMessage({type:"zcbot-ready"})` 给 platform。platform 后端拿 `PLATFORM_KEY` 调 `POST /v1/auth/login` 换出 JWT,通过 `postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。 +iframe 加载完发 `postMessage({type:"zcbot-ready"})`;platform 后端拿 `PLATFORM_KEY` 调 `POST /v1/auth/login` 换 JWT,`postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。 -embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 · "+ 新建任务"挪到任务面板。 +embed 模式下:brand / 退出登录 / 登录页隐藏,"+ 新建任务"挪到任务面板。 --- @@ -22,222 +20,91 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 | 参数 | 必填 | 说明 | |---|---|---| -| `embed` | 是 | 固定 `1`,触发 embed 模式 | -| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** | -| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task | - -示例: -``` -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-... -``` - ---- +| `embed` | 是 | 固定 `1` | +| `parent_origin` | 是 | platform 页面 origin,postMessage 白名单;缺失 / 不匹配 → iframe 显错误、消息全丢 | +| `task_id` | 否 | 首次签发 token 后自动定位到该 task(仅初次进入生效) | ## 2. postMessage 协议 -所有消息 `data` 都是 plain object,`type` 字段标识种类。两端都必须校验 `event.origin`。 +两端都必须校验 `event.origin`。 -### iframe → platform(子发父) +iframe → platform: -| `type` | 时机 | 字段 | platform 应做 | -|---|---|---|---| -| `zcbot-ready` | iframe 加载完成 / 已有缓存 token 但仍发一次 | (无) | 检查当前用户 → 调后端换 JWT → 回推 `zcbot-token` | -| `zcbot-401` | 用户操作触发 401(token 过期 / 被吊销) | (无) | 重新换 JWT(可能要求用户重新登录 platform)→ 回推 `zcbot-token`;或决定关闭 iframe | - -### platform → iframe(父发子) - -| `type` | 字段 | iframe 行为 | +| `type` | 时机 | platform 应做 | |---|---|---| -| `zcbot-token` | `token` (string, JWT)
`user_id` (string, UUID)
`user_name` (string, 可选) | 写 localStorage + 进入主界面;若已在主界面(401 重签场景)只重载任务列表 | +| `zcbot-ready` | 加载完成 | 后端换 JWT → 回推 `zcbot-token` | +| `zcbot-401` | token 过期 / 被吊销 | 重新换 JWT 回推,或关闭 iframe | ---- +platform → iframe: -## 3. platform 后端:换 JWT +| `type` | 字段 | +|---|---| +| `zcbot-token` | `token`(JWT)、`user_id`(UUID)、`user_name`(可选) | -zcbot 已经有 SSO 入口,**platform 后端用共享的 `PLATFORM_KEY` 代任意用户换 JWT**: +## 3. platform 后端换 JWT ``` POST https://zcbot.example.com/v1/auth/login -Content-Type: application/json - -{ - "user_id": "", - "platform_key": "<跟 zcbot env PLATFORM_KEY 同串>" -} +{"user_id": "", "platform_key": ""} +→ {"token": "...", "expires_at": "...", "user_id": "...", "role": "user", "ttl_seconds": 604800} ``` -返回: -```json -{ - "token": "eyJhbGciOiJIUzI1NiIs...", - "expires_at": "2026-07-25T10:00:00", - "user_id": "...", - "name": null, - "user_name": null, - "role": "user", - "ttl_seconds": 604800 -} -``` - -- `PLATFORM_KEY`:platform 和 zcbot **后端共享的密钥**,**绝不能下放到浏览器**。换 token 必须 platform 后端代理。 -- `user_id`:任意 UUID;首次出现 zcbot 会自动建 `users` 行(占位,无 email/密码),后续 task / message 都用这个 user_id 作为分区键。**platform 用户 ↔ zcbot user_id 映射由 platform 维护**(建议 1:1)。可选在 body 里带 `name` / `user_name`,zcbot 每次登录用 `COALESCE` upsert(平台侧改名自动同步),用于控制台顶栏显示。 -- `expires_at`:该 token 到期时刻(ISO 8601);`ttl_seconds`:TTL 秒数(默 7 天,zcbot 端 `ZCBOT_JWT_TTL_SECONDS` env 可改)。 -- **滑动续签(重要,可少管过期)**:token 临近过期时,**任一** 带 `Authorization: Bearer` 的请求(iframe 内的调用、或 platform 后端 server-to-server 调 zcbot API)响应里都会带回一张新 token: - - `X-Refreshed-Token`:新 JWT - - `X-Token-Expires-At`:新到期时刻(ISO 8601) - - 见到就替换本地/缓存的 token 即可——控制台 iframe 已自动处理;platform 后端若长期缓存 token 复用,建议也读这两个响应头刷新缓存。**只要在持续调用,token 不会到期**,`zcbot-401` 只在静默超过整个 TTL 后才发生。触发阈值默认 TTL 的 1/4(zcbot 端 `ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS` 可改,设 0 关闭)。 - -### 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) 后端示例 +- `PLATFORM_KEY` 是后端间共享密钥,**绝不能出现在浏览器**;换 token 必须 platform 后端代理。 +- `user_id` 任意 UUID,首次出现自动建 users 行;platform 用户 ↔ zcbot user_id 映射由 platform 维护(建议 1:1)。可选带 `name` / `user_name`,每次登录 upsert(平台侧改名自动同步)。 +- **滑动续签**:token 临期时,任一带 Bearer 的响应会带 `X-Refreshed-Token` / `X-Token-Expires-At` 头,见到就替换本地缓存(iframe 内已自动处理)。持续使用不掉线,`zcbot-401` 只在静默超过整个 TTL(默认 7 天)后发生。 ```python -import os, httpx -from fastapi import HTTPException - -@app.post("/api/zcbot-token") +@app.post("/api/zcbot-token") # platform 自己的 API,由前端登录态保护 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"]}, - ) + 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} + return {"token": r.json()["token"], "user_id": user_id} ``` ---- - -## 4. platform 前端:iframe + postMessage +## 4. platform 前端 ```html - - + allow="clipboard-read; clipboard-write"> ``` -iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有多个全屏 `position:fixed` modal,iframe 太小会挤瘪)。 - ---- +iframe 高度必须足够(推荐 `100vh`,zcbot 内部有全屏 fixed modal,太矮会挤瘪)。 ## 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 天(`ZCBOT_JWT_TTL_SECONDS` 可改)。活跃会话由滑动续签(见 §3)自动无感续期,`zcbot-401` 只在静默超过整个 TTL 后才发生;若 platform 希望强制更频繁地重签,把 TTL 调短即可。 +1. 生产必须 HTTPS;`parent_origin` 与实际 origin 严格一致。 +2. 两端 message 处理都校验 `event.origin`,防伪造。 +3. zcbot 部署侧:CORS `allow_origins` 收紧到 platform 域(`web/app.py`,目前 `["*"]`);建议加 CSP `frame-ancestors https://portal.example.com` 响应头。 +4. env `PLATFORM_KEY` / `JWT_SECRET` 必填(启动 fail-fast 校验)。 ---- +## 6. 调试与故障 -## 6. zcbot 部署侧需要做的事 +本地:zcbot 跑 `8765`,开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=