feat(auth): JWT 滑动续签——活跃用户临期无感换 token,不再因 7d TTL 掉线(bump 0.58.39)
原状:无状态 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) <noreply@anthropic.com>
This commit is contained in:
parent
011aee1334
commit
f7e5a80ed9
|
|
@ -5,6 +5,10 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.58.39 — 2026-07-18
|
||||
|
||||
- 登录更省心:只要在持续使用,登录状态会在临近过期时自动续期,不用再每隔几天重新登录一次。长时间没打开才需要重新登录。
|
||||
|
||||
## 0.58.38 — 2026-07-17
|
||||
|
||||
- 修定时任务/企业微信推送的简报里「产物文件」链接点开报 404 的问题:现在无论是新推送的消息,还是历史已推送的简报,点文件链接都能正常弹出预览(txt/docx 等直接在线看)。
|
||||
|
|
|
|||
44
EMBED.md
44
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` 企业微信段「应用主页免登」。
|
||||
|
|
|
|||
|
|
@ -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=<rel>)`(`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 粗体 `**<wd>/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)。
|
||||
|
|
|
|||
5
RUN.md
5
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`。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.58.38"
|
||||
__version__ = "0.58.39"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
98
web/auth.py
98
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 <token>")
|
||||
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 <token>")
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue