From f7e5a80ed99390065e5e48a7af18b7a1313ebac5 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Sat, 18 Jul 2026 10:21:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20JWT=20=E6=BB=91=E5=8A=A8=E7=BB=AD?= =?UTF-8?q?=E7=AD=BE=E2=80=94=E2=80=94=E6=B4=BB=E8=B7=83=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=B4=E6=9C=9F=E6=97=A0=E6=84=9F=E6=8D=A2=20token,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=9B=A0=207d=20TTL=20=E6=8E=89=E7=BA=BF(bump=200.58.?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原状:无状态 HS256 JWT(默 7d TTL)无任何续签路径,过期即 401——platform 靠 PLATFORM_KEY 自行重换,SPA 用户只能重输密码。判断:平台层握长期 PLATFORM_KEY、 login 本就幂等换发,缺的不是 refresh token 而是"无感重换"这层;放鉴权中间件一次 改动同时惠及 platform 层与 SPA,且纯加法契约。 - web/auth.py:AuthConfig 加 refresh_threshold_seconds(env ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS,默 TTL 的 1/4,设 0 关闭);verify_token 拆出 decode_token+_uid_from_payload;require_user/admin 共用 _authenticate, 注入 Response,临期就地 mint 新 token 放响应头 X-Refreshed-Token / X-Token-Expires-At(仅续未过期的,已过期先抛 401) - web/app.py:CORS 加 expose_headers 放行两头,否则浏览器 fetch 读不到 - web/static/js/api.js:中央 api() 封装 absorbRefreshedToken——任一响应带新 token 就无感换 state.token+localStorage - 验证:4 case 全绿(fresh 不续/临期续且 exp 正确延长/已过期仍 401/阈值 0 关闭) 顺带:EMBED.md 收敛为纯 platform iframe 对接文档——删企业微信免登 §9(实质併入 RUN.md 企微段「应用主页免登」的内部链路)、校准 §3 换 token 返回体过期字段、补 滑动续签说明;新 env 进 RUN.md、用户可感知条目进 CHANGELOG。 additive,不改字段语义/URL/DB schema;WS ASR 路径 verify_token 签名不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ EMBED.md | 44 +++++++------------- PROGRESS.md | 3 +- RUN.md | 5 +++ core/__init__.py | 2 +- web/app.py | 4 ++ web/auth.py | 98 ++++++++++++++++++++++++++++++++++++++------ web/static/js/api.js | 13 +++++- 8 files changed, 127 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6381f4..ade0c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.58.39 — 2026-07-18 + +- 登录更省心:只要在持续使用,登录状态会在临近过期时自动续期,不用再每隔几天重新登录一次。长时间没打开才需要重新登录。 + ## 0.58.38 — 2026-07-17 - 修定时任务/企业微信推送的简报里「产物文件」链接点开报 404 的问题:现在无论是新推送的消息,还是历史已推送的简报,点文件链接都能正常弹出预览(txt/docx 等直接在线看)。 diff --git a/EMBED.md b/EMBED.md index c7cd78f..a017276 100644 --- a/EMBED.md +++ b/EMBED.md @@ -25,7 +25,6 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 | `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 对接用不到这个参数 | 示例: ``` @@ -72,14 +71,23 @@ Content-Type: application/json ```json { "token": "eyJhbGciOiJIUzI1NiIs...", + "expires_at": "2026-07-25T10:00:00", "user_id": "...", - "exp": 1740000000 + "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)。 -- JWT 默认 7 天 TTL(可改 `ZCBOT_JWT_TTL_SECONDS` env)。token 过期前 iframe 收到 401 → 发 `zcbot-401` 给父端 → 父端再走这个换 token 流程。 +- `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 后端示例 @@ -171,7 +179,7 @@ iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有 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. **TTL + 滑动续签**:JWT 默认 7 天(`ZCBOT_JWT_TTL_SECONDS` 可改)。活跃会话由滑动续签(见 §3)自动无感续期,`zcbot-401` 只在静默超过整个 TTL 后才发生;若 platform 希望强制更频繁地重签,把 TTL 调短即可。 --- @@ -232,28 +240,4 @@ curl -X POST http://127.0.0.1:8765/v1/auth/login \ --- -## 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 两端可用。 +> **企业微信免登**(`?embed=1&wecom=1`)是 embed 模式的另一条 token 来路,面向 zcbot 运维 / 企微管理员,**platform iframe 对接不涉及**,配置见 `RUN.md` 企业微信段「应用主页免登」。 diff --git a/PROGRESS.md b/PROGRESS.md index 1699d40..34d4bab 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-15(空响应防御:provider 吐空自动重试 + warn 自停不静默 done + 面板留痕,bump 0.58.32) +最后更新:2026-07-18(JWT 滑动续签:活跃用户临期无感换 token,不再因 7d TTL 掉线,bump 0.58.39) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-18 / 0.58.39**:**JWT 滑动续签(sliding refresh)——活跃用户永不因 7d TTL 到期掉线**。原状:token 是无状态 HS256 JWT(默 7d TTL),**无任何续签路径**——过期即 `401 token expired`,platform 服务端得靠 `PLATFORM_KEY` 自行重换、dev SPA 用户只能重输密码登录(`verify_token` 是唯一验签口,过期硬抛 401)。判断:平台层握长期 `PLATFORM_KEY`、`login` 本就是幂等换发,**缺的不是 refresh token 而是"无感重换"这层**;放中间件一次改动同时惠及 platform 层与 SPA、且是纯加法契约,故选此方案(vs 显式 `/v1/auth/refresh` 端点需调用方主动轮询;vs 平台侧自兜对 SPA 无解)。**实现**:`web/auth.py` — `AuthConfig` 加 `refresh_threshold_seconds`(env `ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS`,默 TTL 的 1/4,设 0 关闭);`verify_token` 拆出 `decode_token`(返 payload,拿得到 exp)+ `_uid_from_payload`;`require_user`/`require_admin` 共用新 `_authenticate`,注入 FastAPI `Response`,验签成功且 token 剩余寿命 `< 阈值` 时就地 `mint_token` 一张新 token 放响应头 `X-Refreshed-Token` + `X-Token-Expires-At`(仅续"临期但未过期"的,已过期走不到——`decode_token` 先抛 401)。`web/app.py` CORS 加 `expose_headers` 放行两头(否则浏览器 fetch 读不到自定义响应头);`web/static/js/api.js` 中央 `api()` 封装加 `absorbRefreshedToken` —— 任一响应带新 token 就无感换 `state.token`+localStorage。**验证**(实跑非仅单测):4 case 全绿——fresh(寿命>阈值)不续 / 临期续且新 token exp 正确延长 / 已过期仍 401 / 阈值 0 关闭续签;`ast.parse` 双文件 + `node` 侧逻辑。additive,不改既有字段语义/URL/DB schema:平台层与 SPA 只需认新响应头(不认也不影响,退回原 7d 重登行为);WS ASR 路径的 `verify_token` 签名不变。相关:`web/auth.py` §7 D' 过渡 auth。 - **07-17 / 0.58.38**:**修 task 7f904e5f 定时简报的产物文件链接点开 404**(与 0.58.37 的 chip 404 不同根因,勿混)。定时任务/企业微信推送写进 DB 的 assistant 消息里,产物文件写成**完整下载 API 链接** `产物文件:[名](/v1/files/download?path=)`(`core/wechat/service.py:_build_push_message`),web SPA 的正文链接拦截器(`chat.js` 的 `.msg .body a[href]` click 拦截)注释设定是"模型把 user_root 相对路径写成 `[rel](rel)`",于是把整个 `/v1/files/download?path=...` URL 当相对 rel,`replace(/^\.?\/+/,"")` 去前导 `/` 后 `rel="v1/files/download?path=scheduled-…txt"`,再塞进 `openFilePreview` 二次包进 download URL → 请求 `path=v1/files/download?path=…` 不存在 → 404(node 复刻拦截器坐实)。**关键调研(纠了初判)**:一度以为链接得留完整 URL 给企业微信端点下载、改推送格式会 break wecom——**错**:查 `push_wecom`(service.py:266) 企业微信端走 `send_text`(摘要)+`send_file`(**文件附件独立通道**),压根不碰这个 markdown 链接;`_build_push_message` 的链接**只进 DB 给 web 端渲染**。故焊死 `/v1/files/download?path=` 纯属把"具体 API 端点"这个渲染细节泄漏进数据层,还正好踩拦截器坑。**修(A+B 互补,用户提的分层方向)**:**A** 前端拦截器在相对路径回退前先判 `^\/?v1\/files\//`,命中则 `new URL(raw,location.origin).searchParams.get("path")` 抽真正 rel 交 `openFilePreview`(自动解码,txt/docx 复用同 modal)——救 DB 里**已存 20+ 条历史简报**(改不了)+ 健壮兜底;**B** 推送端源头改写 **user_root 相对路径** `[名]({rel})`,与 chip/正文相对链接统一契约、走拦截器已有的相对路径分支,新消息不再泄漏 API 细节。**验证**:node 复刻拦截器——B 新格式(相对 `scheduled-…txt`)与 A 历史(完整 URL)都解析成同一干净 rel;A 另测 5 组(URL 编码含空格#号 path/普通相对/`./` 前缀/外链)全对、外链仍开新标签;`node --check chat.js`+`ast.parse service.py` 通过。web 前端改动需刷新页面生效。additive,不碰对外 API/DB/企业微信推送(send_file)契约,仅 DB 内 assistant 文本的链接写法由绝对 API URL 收敛为相对 rel。相关:0.58.37(chip 粗体 404,另一路径)。 - **07-17 / 0.58.37**:**修 task d9c74a5a 复盘暴露的三处独立缺陷(chip 404 / docx emoji 缺失 / sections 文件夹被迫创建)**。一次"今日新闻 md→word"跑出三个不相干的坑,逐个定层修:**① 前端文件 chip 点开弹框 404**——`web/static/js/media.js::extractArtifactRels` 的路径 tail 字符集 `_TAIL_CLS` 没排除 `*`,助手把路径写成 markdown 粗体 `**/x.md**` 时,前导 `**` 被 lead 边界吃掉、但**尾部 `**` 被贪婪吞进 rel**(星号既不在 tail 集也不在尾部 strip 集),生成 `x.md**` 这种点开 `/v1/files/download?path=…md**` → 404 的坏 chip(用同款正则复现坐实结尾就是 `**`;反引号包裹的路径因反引号在排除集里天然无恙,偏这次用了粗体)。**修**:`*` 加进 `_TAIL_CLS` 排除集,同当初排除反引号一样在 emphasis 处干净截断;文件名含 `*` 在 Windows 非法、误伤≈0。**② 导出 word 里图标(emoji)缺失**——`rendering/docx_manuscript.py` 正文/标题字体是宋体/黑体/Times New Roman,**这些字体无 emoji 字形**,📰🔥🇨🇳📊 等直接写进宋体 run → Word 渲染成豆腐块/缺字。**修**:`rendering/common.py` 加 `EMOJI_RE`(圈主 emoji 区/区域指示符国旗/杂符+Dingbats/VS16/ZWJ,不含文本箭头等易误伤块)+ `add_fonted_runs`(按 emoji 边界拆 run,emoji 段走 `Segoe UI Emoji`、其余走原中西文字体),接进 `_emit_plain_with_chem`(正文,且不破坏化学式下标)与 `add_inline`(bold/italic/code);标题走 `add_inline` 自动覆盖。**③ 过程中冒出 sections/ 文件夹**——`render.py` docstring/argparse 都承诺 src 可为"目录**或单个 .md**",但 docx 三 profile(brief/paper/report/proposal)的 `render_sections` 全是 `is_dir()`+`glob("*.md")` **只认目录**(只有 pdf 真支持单文件),模型传单 .md → 撞 `sections dir not found` → 被迫 `mkdir sections && cp` 兜底留下垃圾目录。**修**:`common.py` 抽 `collect_md_files(src)`(文件→`[src]`,目录→glob),docx_manuscript + docx_brief 共用,figures 基准取 `src.parent`;兑现 docstring 承诺、模型不再需建 sections/。**验证**(/verify 精神,实跑非仅单测):还原 emoji 满满的今日新闻 md 跑单文件 `--profile report`,退出 0(不再报 sections 缺失),解包 docx 校 run 字体——📰🔥🇨🇳🏙️📊 全 `Segoe UI Emoji`、中文仍宋体、SiO₂/CaCO₃ 下标 `[SUB]` 未被破坏;正则修复用 4 组样本(粗体/反引号+间隔号/裸路径+中文句号/斜体)全绿。三处分属前端 chip 提取 / docx 字体 / render 契约,互不相干可独立修;均 additive,不碰对外 API/DB 契约(render.py 契约本就承诺单文件,现名副其实)。 - **07-17 / 0.58.36**:**run_python 超时返回超时前的部分输出 + 无缓冲(-u),让模型据此续跑不整批重来**(失败面板 #8:`command timed out` 19 次 / 7 task / 6 用户,累计最多)。**定性**(查 19 条超时的原始 tool_call 代码):不是死循环 / chromium 老坑,全是**真慢的批量 I/O**——① 论文检索 `skills.research.paper.search()` 循环搜多个期刊 / 关键词(最多,~11 条,每次打 OpenAlex 外部 API 累计爆超时)② pdfplumber 多 PDF `extract_text` ③ 大文件下载(ffmpeg tar.xz / MDPI PDF)④ pandas 大 Excel。超时机制本身正常,真正浪费在 **`_run_subprocess` 超时分支把已捕获的部分输出全丢了**(kill 后 `communicate()` 已续读超时前 stdout,却只回一句 `[Error] command timed out` → 模型看不到「搜到第 6 个」、整批重搜,同 task 反复重跑)。**修**:① `tools/base.py::format_timeout_result` 把超时前的 stdout/stderr 一并返回 + 续跑提示(据已完成部分续跑 / 每项落盘 / 大操作用 `background=true` / 单次别塞太多),docker(`_run_subprocess`)+ host(`run_python.py` 的 `TimeoutExpired.stdout/stderr`)两执行器共用;② **`-u` 无缓冲**——Python 管道下 stdout 块缓冲,不 flush 的 `print` 超时被 kill 时缓冲区就丢了,故 run_python 的 python 调用全加 `-u`(docker 前台 inline / script_path + host 前台 + 两处后台 `procs.launch_host`),让部分输出真正落管道被捕获(不碰 shell)。这里提示指路(background / 续跑)与语法预检不同、**恰当**:超时就是「你这段太慢」的确定信号,建议对任何超时都可靠,不像语法→渲染器那样分不清场景。**不做**:不抬默认 timeout(120s 合理,抬高只延后同问题 + 多占 loop);不单独改 prompt(提示随超时结果反应式投放更精准)。测试 `tests/test_timeout_partial.py`(format 单测 + host 端到端:print 两项后 sleep,timeout=1 → 结果带两项 + 续跑提示)+ `test_executor_docker` argv 断言更新为 `python -u`,全 34 相邻测试绿。additive(超时结果加信息,不破坏 schema),不碰对外 API/DB 契约。至此失败面板 top 全部处置(② edit streak / ③ 畸形 / ① SyntaxError 预检+渲染器 / #8 超时);char-0 畸形由既有 salvage 覆盖。相关 memory:高轮数烧 token 三根因 / 沙箱 chromium 案(本次确认非 chromium)。 diff --git a/RUN.md b/RUN.md index b42624b..7a3c9c3 100644 --- a/RUN.md +++ b/RUN.md @@ -55,6 +55,10 @@ JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造> # 可选:覆盖默认 7d JWT TTL # ZCBOT_JWT_TTL_SECONDS=604800 + # 可选:滑动续签阈值(秒)。token 剩余寿命低于此值时,任一鉴权请求都会就地重签一张 + # 新 token,经响应头 X-Refreshed-Token / X-Token-Expires-At 带回(前端 dev SPA 自动换, + # platform SDK 见头即换)→ 活跃用户永不因 TTL 到期掉线。默 TTL 的 1/4;设 0 关闭(退回过期即重登) + # ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS=151200 # 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503) # ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令> # 可选:web run 线程池大小(asyncio.to_thread 默认池),默 min(32, cpu+4)。每个活跃 @@ -135,6 +139,7 @@ - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件 + 语音**(图片/文件走 media/get 下载,落盘进会话目录 inbound/;语音下载后 ffmpeg 解码 → 讯飞 IAT 转写成文本进对话,识别结果先推一条「🎤 已识别:…」回显,需 `XFYUN_*` 三件套 + 系统 ffmpeg,缺哪个回对应提示);视频/位置等暂不处理;未绑定/空消息静默。 - **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。 + - **内部链路**(排障用):`GET /v1/wecom/entry`(302 → 企微 snsapi_base 网页授权,静默无感)→ `GET /v1/wecom/entry/callback`(code → userid → 反查 `channel_bindings` 企微绑定 → 签 JWT)→ 302 `dev.html?embed=1&wecom=1#token=…&user_id=…`(token 走 **URL fragment**,前端读完立即 `history.replaceState` 清掉,不进 access log)。身份映射走 `channel_bindings` 的企微绑定(**不同于** iframe embed 的 platform 维护 user_id);401 时前端 `location.replace("/v1/wecom/entry")` 静默重签。此变体与 platform iframe 嵌入(见 `EMBED.md`)是**两条独立 token 来路**,互不涉及。 - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。 - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。 - **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。 diff --git a/core/__init__.py b/core/__init__.py index b869ede..e4ce8c2 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.38" +__version__ = "0.58.39" diff --git a/web/app.py b/web/app.py index 2783f57..031ecb6 100644 --- a/web/app.py +++ b/web/app.py @@ -60,6 +60,8 @@ from core.storage.utils import ensure_local_task_row from core.toolfail import alert_provider_critical from .auth import ( + REFRESHED_TOKEN_HEADER, + TOKEN_EXPIRES_HEADER, AuthConfig, UserCreateError, change_password, @@ -1485,6 +1487,8 @@ def create_app() -> FastAPI: allow_credentials=False, allow_methods=["*"], allow_headers=["*"], + # 滑动续签的新 token 走响应头带回;expose 才能让浏览器 fetch 读到(默认不暴露自定义头) + expose_headers=[REFRESHED_TOKEN_HEADER, TOKEN_EXPIRES_HEADER], ) if _STATIC_DIR.is_dir(): diff --git a/web/auth.py b/web/auth.py index 7428252..29cb8da 100644 --- a/web/auth.py +++ b/web/auth.py @@ -20,12 +20,13 @@ from __future__ import annotations import os import time +from datetime import datetime from typing import Optional from uuid import UUID import bcrypt import jwt -from fastapi import Depends, HTTPException +from fastapi import Depends, HTTPException, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import func, select @@ -35,6 +36,12 @@ from core.storage.models import User _DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d +# 滑动续签:验签通过且剩余寿命 < 阈值时,就地重签一张新 token 放响应头, +# 客户端(platform SDK / dev SPA)见头即换本地 token → 活跃用户永不掉线, +# 静默超过 TTL 才需重登。响应头名见 REFRESHED_TOKEN_HEADER / TOKEN_EXPIRES_HEADER。 +REFRESHED_TOKEN_HEADER = "X-Refreshed-Token" +TOKEN_EXPIRES_HEADER = "X-Token-Expires-At" + class AuthConfig: """App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。""" @@ -44,11 +51,16 @@ class AuthConfig: platform_key: str, jwt_secret: str, ttl_seconds: int, + refresh_threshold_seconds: int, admin_token: Optional[str] = None, ): self.platform_key = platform_key self.jwt_secret = jwt_secret self.ttl_seconds = ttl_seconds + # 滑动续签阈值:token 剩余寿命低于此值时,require_user/admin 就地重签放响应头。 + # 默认 ttl 的 1/4(7d → ~1.75d);ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS 覆盖, + # 设 0 → 关闭滑动续签(退回"过期即重登")。 + self.refresh_threshold_seconds = refresh_threshold_seconds # ZCBOT_ADMIN_TOKEN 未设 → None;此时 /v1/auth/admin/create_user 返 503(功能关闭)。 # 这是个独立的共享口令,跟 PLATFORM_KEY / JWT_SECRET 分开 —— 它是"管理员发用户" # 的单一钥匙,不参与 platform 机器对机器 / token 签名路径。 @@ -78,9 +90,21 @@ class AuthConfig: ) if ttl <= 0: raise RuntimeError(f"ZCBOT_JWT_TTL_SECONDS must be > 0, got {ttl}") + refresh_raw = os.environ.get("ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS", "").strip() + try: + refresh = int(refresh_raw) if refresh_raw else ttl // 4 + except ValueError: + raise RuntimeError( + f"ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS must be int seconds, got {refresh_raw!r}" + ) + if refresh < 0: + raise RuntimeError( + f"ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS must be >= 0, got {refresh}" + ) admin = os.environ.get("ZCBOT_ADMIN_TOKEN", "").strip() or None return cls( - platform_key=key, jwt_secret=secret, ttl_seconds=ttl, admin_token=admin, + platform_key=key, jwt_secret=secret, ttl_seconds=ttl, + refresh_threshold_seconds=refresh, admin_token=admin, ) @@ -132,14 +156,18 @@ def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]: return token, exp -def verify_token(cfg: AuthConfig, token: str) -> UUID: - """验签 + 取 sub。失败抛 HTTPException 401。""" +def decode_token(cfg: AuthConfig, token: str) -> dict: + """验签 + 返回 payload dict(含 sub / iat / exp)。失败抛 HTTPException 401。""" try: - payload = jwt.decode(token, cfg.jwt_secret, algorithms=["HS256"]) + return jwt.decode(token, cfg.jwt_secret, algorithms=["HS256"]) except jwt.ExpiredSignatureError: raise HTTPException(401, "token expired") except jwt.InvalidTokenError as e: raise HTTPException(401, f"invalid token: {e}") + + +def _uid_from_payload(payload: dict) -> UUID: + """从 payload 取 sub → UUID。坏值抛 401。""" sub = payload.get("sub", "") try: return UUID(sub) @@ -147,6 +175,33 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID: raise HTTPException(401, f"invalid sub in token: {sub!r}") +def verify_token(cfg: AuthConfig, token: str) -> UUID: + """验签 + 取 sub。失败抛 HTTPException 401。""" + return _uid_from_payload(decode_token(cfg, token)) + + +def _maybe_slide_token( + cfg: AuthConfig, payload: dict, uid: UUID, response: Response +) -> None: + """滑动续签:token 剩余寿命 < 阈值时重签一张,放响应头带回。 + + - 阈值 0 → 关闭,直接返回 + - exp 缺失 / 非数值(理论不会,mint_token 恒写)→ 不续签,静默返回 + - 已过期的 token 走不到这里(decode_token 先抛 401);这里只续"临期但仍有效"的 + 响应头 CORS 放行见 create_app 的 expose_headers。 + """ + if cfg.refresh_threshold_seconds <= 0: + return + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + return + remaining = exp - int(time.time()) + if 0 < remaining < cfg.refresh_threshold_seconds: + new_token, new_exp = mint_token(cfg, uid) + response.headers[REFRESHED_TOKEN_HEADER] = new_token + response.headers[TOKEN_EXPIRES_HEADER] = datetime.fromtimestamp(new_exp).isoformat() + + class UserCreateError(Exception): """create_user 失败:`code` 是 HTTP-style 语义码('invalid_email' / 'weak_password' / 'email_taken' / 'db_error'),`message` 是面向操作者的简短描述。 @@ -310,6 +365,22 @@ def ensure_user_row( _bearer = HTTPBearer(auto_error=False) +def _authenticate( + cfg: AuthConfig, + creds: Optional[HTTPAuthorizationCredentials], + response: Response, +) -> UUID: + """验 Bearer → uid,顺带滑动续签(临期 token 重签放响应头)。require_user/admin 共用。""" + if creds is None or not creds.credentials: + raise HTTPException(401, "missing Authorization: Bearer ") + if creds.scheme.lower() != "bearer": + raise HTTPException(401, f"unsupported auth scheme: {creds.scheme!r}") + payload = decode_token(cfg, creds.credentials) + uid = _uid_from_payload(payload) + _maybe_slide_token(cfg, payload, uid, response) + return uid + + def make_require_user(cfg: AuthConfig): """工厂:返回一个 Depends 函数,闭包持有 cfg(避免 app 启动后改 env)。 @@ -319,15 +390,13 @@ def make_require_user(cfg: AuthConfig): def route(user_id: UUID = Depends(require_user)): ... 实际使用建议直接 `user_id: UUID = Depends(require_user)`,既验签又拿到 user_id。 + 验签成功且 token 临期时,通过响应头 X-Refreshed-Token 无感续签(见 _maybe_slide_token)。 """ async def require_user( + response: Response, creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), ) -> UUID: - if creds is None or not creds.credentials: - raise HTTPException(401, "missing Authorization: Bearer ") - if creds.scheme.lower() != "bearer": - raise HTTPException(401, f"unsupported auth scheme: {creds.scheme!r}") - return verify_token(cfg, creds.credentials) + return _authenticate(cfg, creds, response) return require_user @@ -340,13 +409,13 @@ def make_require_admin(cfg: AuthConfig): @app.get("/v1/admin/...", ) def route(user_id: UUID = Depends(require_admin)): ... 非 admin → 403(token 有效但无权限);DB 查 role(不放进 JWT),改 role 立即生效。 + 滑动续签同 require_user —— admin 端点也会在临期时带回 X-Refreshed-Token。 """ - _require_user = make_require_user(cfg) - async def require_admin( + response: Response, creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), ) -> UUID: - user_id = await _require_user(creds) + user_id = _authenticate(cfg, creds, response) if get_user_role(user_id) != "admin": raise HTTPException(403, "admin role required") return user_id @@ -356,9 +425,12 @@ def make_require_admin(cfg: AuthConfig): __all__ = [ "AuthConfig", + "REFRESHED_TOKEN_HEADER", + "TOKEN_EXPIRES_HEADER", "UserCreateError", "change_password", "create_user", + "decode_token", "ensure_user_row", "get_user_profile", "get_user_role", diff --git a/web/static/js/api.js b/web/static/js/api.js index d99865f..e3e7012 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -1,6 +1,16 @@ // 统一 JSON fetch 封装:注入 Bearer token,解析 JSON/文本,非 2xx 抛带 status 的 Error。 // (login / create_user / SSE / 文件下载 因 header / 流式 / blob 特殊,仍各自手写 fetch) -import { state } from "./state.js"; +import { state, LS_TOKEN } from "./state.js"; + +// 滑动续签:后端在临期 token 的响应里带 X-Refreshed-Token(见 web/auth.py), +// 这里无感换掉本地 token → 活跃用户永不因 TTL 到期被踢回登录页。 +export function absorbRefreshedToken(r) { + const fresh = r.headers.get("X-Refreshed-Token"); + if (fresh && fresh !== state.token) { + state.token = fresh; + localStorage.setItem(LS_TOKEN, fresh); + } +} export async function api(method, path, body) { const opts = { method, headers: {} }; @@ -10,6 +20,7 @@ export async function api(method, path, body) { opts.body = JSON.stringify(body); } const r = await fetch(path, opts); + absorbRefreshedToken(r); let data = null; const ct = r.headers.get("content-type") || ""; if (ct.includes("application/json")) {