feat(mcp-server): Heicode magic-link 邮箱登录三端点(仅登录,不触碰自有登录)

按 Docs/Heicode-magic-link…md 契约 §7.4/§9.4/§11/§13.2 新增并行登录方式:
- app/magic_link.py:Redis 一次性 token(600s)/code(120s) + 邮箱60s限流 + 复用
  现有 SMTP 通道发链接邮件(SMTP_PASSWORD 未配则不外发,DEBUG 打日志)
- app/routes/auth.py:新增 /api/auth/magic-link/{request,landing,verify}
  · request:IP+邮箱限流,防枚举一视同仁,仅对已存在 role=user 发信(D-1/D-2/D-3)
  · landing:消费 token→生成 code→302 heicode://auth/callback,失败回 HTML
  · verify:消费 code→复用 create_access_token/refresh + 与 /login 逐字段相同
    token_data → 登录产物等价,EU/计费零改动(§1.3)
- config.py:新增 MAGIC_LINK_PUBLIC_BASE_URL(默认 APIM 域,§11 终态)
- 三端点不声明 Depends(require_auth) 即公开,未改 allow_paths(§12.2)
- /login、/me、/refresh、/logout、/register 一行未改

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:25:02 +08:00
co-authored by Claude Opus 4.8
parent 2477d01d61
commit 66423c0845
4 changed files with 893 additions and 1 deletions
@@ -0,0 +1,408 @@
# Heicode magic-link 邮箱登录 — 给 mcp-server 的对接需求
**来源**: Heicode Manager(HM)owner @zsbgnw12 · 对应 HM 工单 #19
**状态**: 需求/契约草案,**待 mcp-server 端确认与排期**
**读者**: mcp-server 后端(Heicode Manager 身份服务)
**基线**: 本文在现有《Heicode-登录接口对接文档.md v1.0》(`/api/auth/login` 等 4 接口已上线)之上**新增**一种登录方式,不改动现有任何接口。
---
## 0. 一句话需求
参考 Claude Code 的**邮箱 magic-link 登录**:客户端只输入邮箱(**不输密码**)→ mcp 发一封邮件 → 用户点链接 → 回跳桌面客户端完成登录。
> **magic-link 是"换一种方式证明邮箱归属",最终必须产出与 `POST /api/auth/login` **完全相同**的登录产物(同样的 `{token, refreshToken, user}`、同样的 JWT claims 含 `channelId`、同样的 24h/7d 有效期、同样的审计与会话)。**
---
## 1. 硬约束(最重要,先读)
1. **绝不改动、绝不影响现有密码登录链路**:`POST /api/auth/login`、`GET /api/auth/me`、`POST /api/auth/refresh`、`POST /api/auth/logout` 行为、字段、JWT 一律不变。magic-link 是**并行新增**的获取 token 的方式,密码登录继续作为兼容/降级路径保留。
2. **magic-link 登录的"账号"与密码登录是同一套用户**:同一邮箱无论用密码还是 magic-link 登录,解析到的都是**同一个 user 记录**(同一 `id`、`channelId`、`role`)。
3. **登录产物必须等价**:`verify` 成功响应体 = `/api/auth/login` 成功响应体(同 `{success, data:{token, refreshToken, user:{id,name,email,role,channelId}}}`、同 JWT claims、同 TTL)。
- **原因(HM owner 强调)**:mcp-server 登录态 = 计费入口(agent-manager pods 运行时长 / EU 计量)。只要登录产物与现有登录一致,**EU 计费、pods 运行计量、下游一切都无需改动**。magic-link 不得引入新的会话类型或新的计费分支。
4. **不得在日志/URL 持久留存敏感凭证**:magic-link token、一次性 code 不进普通日志、不在 URL query 长期留存(仅一次性回跳)。
---
## 2. 需要 mcp-server 新增的内容(3 个端点 + 发邮件 + 落地页)
> 路径前缀建议放在现有 auth 命名空间下(如 `/api/auth/magic-link/*`),**最终 path 由 mcp 定**;定稿后 HM 会在 heicodeDocs `integration/` 锁定契约,客户端据此切真。客户端经 HM 同源代理 `/api/heicode-auth/*` 访问这些端点(无需关心跨域)。
### 2.1 `POST /api/auth/magic-link/request` — 申请登录链接
**请求**
```json
{ "email": "user@example.com" }
```
**行为**
- 校验邮箱格式;查用户(同密码登录的用户库)。
- 生成**一次性、短 TTL(建议 10 分钟)**的 magic-link token,与该邮箱 + 一个随机 `state` 绑定。
- **发邮件**,链接指向落地页(§2.2),链接携带 `token` + `state`。
- **防枚举**:无论邮箱是否注册,**统一返回成功**;配合限流(对齐现有登录 5 次/分钟/IP 量级)。
**成功响应 200**
```json
{ "success": true, "data": { "request_id": "<uuid>", "state": "<random>", "expires_in_sec": 600 } }
```
> `state` 客户端会存下,回跳时严格比对。`request_id` 仅供追踪/可选轮询(见 §4 跨设备)。
### 2.2 落地页 `GET /api/auth/magic-link/landing?token=...&state=...` — 邮件链接指向它(mcp 托管)
**行为**
- 校验 token:存在、未过期、未被用过(一次性)。
- 校验通过 → 生成**一次性、TTL ≤ 2 分钟**的 `code`,绑定到该 user + `state`;**302 跳转**到桌面深链:
```
302 Location: heicode://auth/callback?code=<one-time-code>&state=<state>
```
- 校验失败/过期/已用 → 返回一个**人类可读 HTML 页**(如「链接无效或已过期,请回客户端重新获取」)。
- **Phase 1 跨设备提示(重要)**:落地页文案明确写「**请在已安装 HeiCode 的同一台设备上打开此链接**」。因为深链 `heicode://` 只能唤起本机客户端;若用户在手机/另一台机器打开,本机客户端收不到回跳。Phase 2 再考虑轮询兜底(见 §4)。
### 2.3 `POST /api/auth/magic-link/verify` — 用 code 换登录态
**请求**
```json
{ "code": "<one-time-code>", "state": "<state>", "device_pubkey": "<可选>" }
```
**行为**
- 校验 `code`:存在、未过期(≤2min)、未被用过(一次性)、与 `state` 一致。
- 通过 → **签发与 `/api/auth/login` 完全相同的登录产物**(见 §1.3)。
- `device_pubkey` 可选:mcp 可忽略(设备配对由 HM 侧完成,见 §3);若 mcp 想绑定设备也可记录,但**不得改变返回的 token 结构**。
**成功响应 200(必须与 login 一致)**
```json
{
"success": true,
"data": {
"token": "eyJ...",
"refreshToken": "eyJ...",
"user": { "id": "...", "name": "...", "email": "...", "role": "user", "channelId": "..." }
}
}
```
**错误**:`code` 无效/过期/已用 → `400/401`(客户端会丢弃重来);限流 → `429 + Retry-After`。
---
## 3. 与 HM / 客户端的边界(mcp 不用管的部分)
- **HM 只做同源代理**:客户端经 `https://code.xinghanlab.com/api/heicode-auth/<path>` → HM 透明转发到 `HEICODE_AUTH_BASE_URL`(=`https://apimtaiji.azure-api.net/api/mcp`)。所以你只要实现 §2 三个端点,HM 自动可达,**HM 侧无需你做任何额外接口**。
- **设备配对/会话(HM 侧,已存在,不变)**:客户端拿到 mcp 的 token 后,继续走 HM 现有的 `from-agent → sk- + V2 设备配对(Ed25519/ChaCha20)`——这是 HM↔客户端的模型访问会话,**与你无关、不变**。magic-link 只改变"客户端如何拿到 mcp token"这一段。
- **深链注册/转发(客户端侧)**:`heicode://auth/callback` 由客户端(winos/macos)注册与转发,你只需在落地页 302 到它。
---
## 4. 待 mcp 确认/拍板 + 开放问题
1. **最终 path 前缀**:`/api/auth/magic-link/{request,landing,verify}` 是否 OK?定了我在 heicodeDocs 锁契约。
2. **邮件基础设施**:mcp 当前是否具备发信能力(SMTP/SES/SendGrid 等)?magic-link 邮件的发送、模板、送达率、防滥用由 mcp 侧承载——**这是上线前置**,请确认负责人/通道。(HM owner 已在 #19 标注"邮件基建负责人待产品/基建指认"。)
3. **token / code TTL**:magic-link token 10min、code ≤2min、均一次性 —— 是否可行?
4. **device_pubkey**:你倾向忽略(交给 HM 配对)还是要绑定?(不影响 token 结构即可)
5. **跨设备**:Phase 1 用落地页"同设备"提示即可(无需轮询);若你们愿意提供 `GET /api/auth/magic-link/status?request_id=`(返回 pending/confirmed)作 Phase 2 兜底,客户端可轮询,但非必须。
6. **隐私/合规**:本流程仅"邮箱 → 一次性 code",不采集用户内容;与遥测(另一个工单 #24)的隐私政策修订是两回事,本登录流程无新增 PII 采集。请确认无合规阻碍。
---
## 5. 验收(契约定稿 + 邮件可用后)
- 输入邮箱 → 收到真实邮件 → 点链接 → 落地页 302 → 客户端回跳 → `verify` 换到 token → 进主界面;
- 用 magic-link 登录后,`/me`、`/refresh`、`/logout`、**EU/pods 计费**与密码登录**完全一致**;
- 密码登录路径回归不变;
- token/code 一次性 + 过期失效;限流生效;邮箱枚举不可探测。
---
## 6. 请回复
请 mcp-server 端确认 §4 各项(尤其**邮件基建是否可用**、**最终 path**),并指出本需求与现状有无冲突。确认后:HM 立 heicodeDocs `integration/` 正式契约 → 客户端切真实 path 联调。客户端侧脚手架(邮箱登录 UI + `heicode://` 注册,对 mock)已可先行,不阻塞你们。
*(本文件放在 Heicode↔mcp-server 共享 Docs 通道;有问题直接在本文件追加或回 HM 工单 #19。)*
---
# 7. mcp-server 端确认与讨论(2026-06-09,基于代码核验)
> 本节由 mcp-server 后端逐条核对**真实代码**后给出,所有结论附文件:行号。涉及需 HM 拍板的开放项见 §7.3。
> 核验范围:`app/routes/auth.py`、`app/auth.py`、`app/email_verification.py`、`config.py`。当前仓库**无任何既有 magic-link 代码**(全仓 grep `magic` 无命中),属全新增。
## 7.1 可直接确认的项(§4 逐条答复)
**[§4.1 path 前缀] ✅ OK。** 现有认证路由 `router = APIRouter(prefix="/api/auth", ...)`(`app/routes/auth.py:51`),`/api/auth/magic-link/{request,landing,verify}` 完美落在该命名空间下,与现有 `/login`、`/register`、`/forgot-password/*` 并列。
**[§4.3 token/code TTL + 一次性] ✅ 可行。** 现有验证码已用 Redis `setex`(10min)存、命中即 `delete` 消费的 one-time 模式(`app/email_verification.py:204-231` 存、`:234-308` 验+删)。magic-link token(10min)/code(≤2min) 直接复用同一套,分别用独立 key 前缀即可。Redis 实例已在 `config.py:31` 配好并经 `state.redis_client` 全局可用。
**[§4.4 device_pubkey] ✅ 建议忽略。** 设备配对在 HM 侧(§3 已约定)。token 结构由 `create_access_token(token_data)` 固定,我方忽略 `device_pubkey` 即可,**不动 token 结构**。
**[§4.5 跨设备 Phase2] Phase1 仅落地页文案即可;Phase2 `status?request_id=` 轮询可做但非必须**——Redis 可存 `pending/confirmed` 状态兜底,待 Phase1 上线后再评估。
**[§4.6 隐私/合规] ✅ 无阻碍。** 本流程仅"邮箱 → 一次性 token/code",不新增 PII 采集。
**[§1.3 登录产物等价] ✅ 可严格满足,且 EU/计费零改动。** 核验如下:
- `/login` 成功响应体 = `{success, data:{token, refreshToken, user:{id,name,email,role,channelId}}}`(`app/routes/auth.py:219-231`)。
- `create_access_token` TTL = `settings.jwt_expire_minutes` = **1440(24h)**(`app/auth.py:45` + `config.py:95`),追加 claims `exp/iat/type="access"`,其余 claims 来自传入 `token_data`(`sub/email/role/channelId`)。
- `create_refresh_token` TTL = **7 天**(`app/auth.py:56,68`),`type="refresh"`。
- 结论:magic-link `verify` 复用**同一个** `token_data` 和 `create_access_token/create_refresh_token`,产出的 JWT claims、TTL、响应体可与 `/login` **逐字段一致**。因此不引入新会话类型/新计费分支,满足 §1.1/§1.3。
**[§1.2 同一套用户] ✅ 同库。** `/login` 与认证助手都按 `User.email` 查同一张 `User` 表(`app/auth.py:78-80`、`app/routes/auth.py:158`)。magic-link 按 email 解析到的就是同一 user。
## 7.2 落地必须处理的两处代码事实(非冲突,但属实现前置)
**[A] 新端点必须显式加入鉴权白名单,否则会被全局鉴权拦成 401。**
> ⚠️ **本条 [A] 经 §12 实现级验证后更正:表述不准确,结论作废。** 实际无需改任何 `allow_paths`——magic-link 三端点只要**不声明 `Depends(require_auth)`** 即为公开(与 `login`/`register` 一致)。详见 §12.2。以下原文保留仅作讨论留痕。
>
> ~~`require_auth` 对 `/api` 前缀默认要求鉴权,仅 `allow_paths` 白名单放行(`app/auth.py:197-220`);`/api/auth/login` 在白名单内,但 `/api/auth/magic-link/*` **不在**。同样地,中间件 `authenticate_request` 也有一份独立白名单(`app/auth.py:335-346`)。→ `request`/`landing`/`verify` 三个端点必须**同时**加入这两处 `allow_paths`。~~
**[B] 缺"本服务公网自身 URL"配置——邮件里的 landing 绝对地址无处可取。**
`config.py` 现有 `heicode_newapi_base_url = https://code.xinghanlab.com`(`config.py:124-126`),但**没有**指向 mcp-server 自身公网入口的配置项。需求 §3 的 `HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp` 是 **HM 侧**配置,不在我方代码里。
→ 要在邮件正文里拼出可点击的 landing 绝对 URL,需**新增一个设置项**(拟 `MAGIC_LINK_PUBLIC_BASE_URL`)并由运维注入。这同时牵出一个链路事实(见 §7.3-D):**邮件链接是用户浏览器直接打开的,不经过 HM 的 `/api/heicode-auth/*` JSON 代理**,所以该 URL 必须公网直达、且 APIM 要放行这个 GET 并允许返回 302/HTML。
**[补充] landing 返回 302/HTML 是本服务首次引入的响应类型。** 现有 auth 端点全部返回 JSON `SuccessResponse`;`landing` 需用 FastAPI 的 `RedirectResponse(302)` + 失败 `HTMLResponse`。FastAPI 原生支持,无技术障碍,仅提示与现有风格不同。
**[补充] 邮件模板需新增。** 现有 `send_and_store_verification_code` 发的是**6位数字验证码纯文本**(`app/email_verification.py:59-61` 生成、`:104-118` 正文模板)。magic-link 要发**带 token+state 的 URL**,需新增一个邮件模板与发送函数;SMTP 通道本身可用(`smtp.189.cn:465` SSL,发件 `taijiagent@189.cn`,密码走 `SMTP_PASSWORD` secret,`app/email_verification.py:18-25`)。
**[限流] ✅ 已具备,对齐 §2.1。** 按 IP 5次/60s 的 `_LoginRateLimit`(`app/routes/auth.py:54-85`)+ 按邮箱 60s 的 `check_rate_limit/set_rate_limit`(`app/email_verification.py:146-201`)均可直接复用。
## 7.3 mcp-server → HM 的反问(需 HM 拍板,否则会返工)
**[D-1 ⭐最关键] magic-link 是否需要"首次登录即注册"?**
需求 §1.2/§2.3 表述为"查用户(同密码登录的用户库)"→"签发登录产物",**默认用户已存在**;而 `/login` 对查不到的邮箱直接 401(`app/routes/auth.py:161-165`)。我方 `/register` 在注册时要做一大串默认资源分配——taiji 渠道、CPU/内存配额、平台 Agent 配额、**逐模型在 LiteLLM 创建 key**、20 元余额(`app/routes/auth.py:720-1100`)。
→ 若未注册邮箱点 magic-link 也要能进来,`verify` 就得触发整套注册 provisioning(一个大分支)。**mcp-server 建议:magic-link = 仅登录**;未注册邮箱在 `request` 阶段静默不发信(与 §7.3-D-2 的防枚举一致),注册仍走现有 `send-code + register`。请 HM 确认是否接受"仅登录"。
**[D-2] 防枚举行为将与现有两个接口语义不一致(需 HM 知晓)。**
需求 §2.1 要求 `request` **无论邮箱是否注册都返回成功**。但现有 `register/send-code` 对已注册邮箱返回 `400 该邮箱已被注册`(`app/routes/auth.py:701-705`)、`forgot-password/send-code` 对未注册返回 `404 该邮箱未注册`(`app/routes/auth.py:1136-1142`)——**这两个老接口会泄漏邮箱存在性**。magic-link 新端点将**刻意采用统一成功**的更安全行为。这不是冲突(新端点独立),但请 HM 知晓"magic-link 与老 send-code 语义不同"。
**[D-3] 覆盖角色范围?** 现有 `/login` 区分 `channel`(独立 `Channel` 表/独立 email,`app/routes/auth.py:114-117`)与 `user/admin/...`。HM 的桌面客户端用户应为 `role=user`。请确认 **magic-link 仅面向 `role=user`,channel/admin 继续走密码登录**(mcp-server 倾向如此,避免同一邮箱在 User/Channel 两表的歧义)。
**[D-4] landing 公网 URL 与 APIM 放行由谁配、给什么域名?**(对应 §7.2-B)需 HM/APIM 侧确认:邮件里的 landing 用哪个公网域名(是否 `https://apimtaiji.azure-api.net/api/mcp/api/auth/magic-link/landing`)、APIM 是否放行该 GET 并允许 302+HTML 透传。这属上线前置。
**[D-5] 邮件基建归属(§4.2 HM 已标注"负责人待指认")。** SMTP 通道我方可用(见 §7.2 补充),但 magic-link 邮件的**模板文案、发件人是否沿用 `taijiagent@189.cn`、送达率/防滥用**仍需产品/基建指认负责人。
## 7.4 mcp-server 侧落地清单(待 §7.3 拍板后执行;改鉴权链路属高风险,先出方案再写码)
1. `app/routes/auth.py`:新增 `magic-link/request`、`magic-link/landing`(302+HTML)、`magic-link/verify` 三端点;`verify` 复用 `create_access_token/create_refresh_token` + 与 `/login` 逐字段相同的 `token_data`。
2. `app/auth.py`:两处 `allow_paths` 同步加入 `request`/`landing`/`verify`(§7.2-A)。
3. `app/email_verification.py`:新增 magic-link 邮件模板与发送函数(URL 形式)。
4. Redis:`magic_link_token:{token}`(10min)、`magic_link_code:{code}`(≤2min) 两类 one-time key,复用现有 setex/delete。
5. `config.py`:新增 `MAGIC_LINK_PUBLIC_BASE_URL`(§7.2-B),由运维注入;并在 k8s configmap 增配。
6. 全程不触碰 `/login`、`/me`、`/refresh`、`/logout` 现有行为(满足 §1.1)。
> **本变更影响面(按组织 PR 规则预声明)**:影响 Manager 身份/登录链路(mcp-server)与**邮件基建**;新增需注入的配置项 `MAGIC_LINK_PUBLIC_BASE_URL`;**不影响** Swarm、Agent、CodeGW、计费分支、密钥审批链(沿用现有 SMTP secret,不新增密钥类型);审计沿用现有 `log_audit_event`。正式实现前将另出实现方案评审。
---
# 8. HM owner @zsbgnw12 回复 mcp-server 反问(2026-06-09 · 拍板)
感谢逐条附代码核验,质量很高。§7.1 确认项与 §1.3 登录产物等价的核对(`create_access_token/refresh` + 同 `token_data`)完全符合预期——**这正是"不影响原始登录、EU/计费零改动"的关键**。逐条拍板 §7.3:
**[D-1 ⭐ 仅登录,接受你的建议] ✅ magic-link = 仅登录,不做"首次登录即注册"。**
理由:与现有模型一致(注册走 web 端 / 现有 `send-code + register`,登录走客户端);#19 客户端诉求是"登录方式替代 OAuth STUB",**不含注册**。所以:`request` 阶段未注册邮箱**静默不发信**(与 D-2 防枚举一致),`verify` 只对已存在 user 签发登录产物,**绝不触发你那套 register provisioning(taiji 渠道/配额/LiteLLM key/20 元余额)**。新用户注册维持现状不变。
**[D-2 防枚举] ✅ 知晓并采纳。** 新 `magic-link/request` 用"统一返回成功"的更安全语义;**不要求**你改老的 `register/send-code`、`forgot-password/send-code`(它们维持现状,本次不动)。新端点独立、更安全即可,无冲突。
**[D-3 角色范围] ✅ magic-link 仅面向 `role=user`(桌面客户端)。** channel/admin 继续走密码登录,避免 User/Channel 两表歧义——按你倾向定。
**[D-4 landing 公网 URL + APIM] —— 澄清归属 + 给你二选一:**
- 你判断正确:**邮件链接是浏览器直开的,不经 HM 的 `/api/heicode-auth/*` JSON 代理**。HM 代理只服务客户端的**程序化 JSON 调用**(`request`/`verify` 可经 `code.xinghanlab.com/api/heicode-auth/api/auth/magic-link/{request,verify}` 同源访问);**landing 是浏览器→公网直达,不走 HM**。
- **落地页托管二选一,你们定:**
- **(方案1,推荐)** landing 在你们公网域(`https://apimtaiji.azure-api.net/api/mcp/api/auth/magic-link/landing`)。前置:**APIM 放行该 GET 并允许返回 302 + HTML**——这属 **apimtaiji 平台/APIM 侧配置(与 mcp 同侧),HM 无代码、不托管**。`MAGIC_LINK_PUBLIC_BASE_URL` 即填此域。
- **(方案2,APIM 不便返回 302/HTML 时的兜底)** 由 **HM 托管落地页**(`https://code.xinghanlab.com/heicode/magic-link/landing`):HM 收到浏览器 GET → 调你们一个**校验 token 的 JSON API**(如 `POST /api/auth/magic-link/landing-verify {token,state}` 返回校验结果 + 一次性 code)→ HM 负责 302 到 `heicode://`。这样 302/HTML 在 HM 出,APIM 只需放行一个 JSON。**若你们选方案2,请把 landing 拆成"JSON 校验 API",HM 来出 302。**
**[D-5 邮件基建] 归属仍待产品/基建,我继续在 #19 追 @Fasthei/产品。** 你们 SMTP 通道(`taijiagent@189.cn`)可用是好事;模板文案/发件人/送达率/防滥用的最终签字属产品。这条是**上线前置**,不阻塞你们先把端点/落地页骨架写好(对 mock 邮件或先打日志)。
**[§7.2-A/B 实现前置] 👍 好的提醒,属你们实现细节,HM 无异议:**
- A(两处 `allow_paths` 白名单加 3 端点)、B(新增 `MAGIC_LINK_PUBLIC_BASE_URL` 由运维注入)—— 你们按 §7.4 清单执行即可。改鉴权链路高风险、先出方案评审的做法 👍。
## 下一步(HM 侧)
- D-1/D-2/D-3 已拍定(仅登录 / role=user / 防枚举),**不阻塞你们开始写 request/verify/landing 骨架**。
- D-4 落地页方案(1 还是 2)请你们回一句;选方案2 我这边加 HM 落地页 + 302(小改动)。
- D-5 邮件基建 + path 定稿后,我在 heicodeDocs `integration/` 立正式契约,客户端切真实 path 联调。客户端脚手架(邮箱 UI + `heicode://` 注册,对 mock)已可先行。
---
# 9. mcp-server 回复 HM 拍板 + D-4 选型(2026-06-09,基于代码核验)
> 收到 §8 拍板。D-1/D-2/D-3 我方无异议,按"仅登录 / role=user / 防枚举统一成功"实现。下面只就 **D-4 落地页托管**给出基于真实部署事实的选型与一个前置确认。
## 9.1 先确认 D-1/D-2/D-3 落实口径(与 §8 一致)
- **D-1 仅登录**:`verify` 只对**已存在 user** 签发登录产物,未注册邮箱在 `request` 阶段**静默不发信**,**绝不触发** `register` 那套 provisioning(`app/routes/auth.py:720-1100`)。
- 旁证:现有《Heicode-登录接口对接文档.md v1.0》§1 已写明"注册…走官网 web 端,不在本期范围"——与本次"仅登录"口径天然一致,无需任何注册侧改动。
- **D-2 防枚举**:仅新 `magic-link/request` 用统一成功语义;**不动**老 `register/send-code`、`forgot-password/send-code`。
- **D-3 role=user**:`verify` 解析到的 user 若 `role != "user"` 则不签发(按 §8 规避 User/Channel 两表歧义)。
## 9.2 D-4 选型:**推荐方案1,且落地页走 nginx Ingress 直连、绕开 APIM 的 302/HTML 不确定性**
核实部署清单后发现一个关键事实,能让方案1**不依赖 APIM 能否返回 302/HTML**:
- 我方生产有**两个公网入口**:
1. **nginx Ingress `mcp.taiji-ai.com`** → 直连 `mcp-server:8000`(`k8s/prod/ingress.yaml:35,39-45`),**纯反代透传**,无 JSON 限制,原生支持 302 + HTML;
2. **APIM `https://apimtaiji.azure-api.net/api/mcp`** —— 现有客户端 JSON 登录走它(《Heicode-登录接口对接文档.md》§2.1)。
- **结论**:`request`/`verify`(程序化 JSON)继续走 APIM(与现有 4 接口同源,HM 代理可达);**而落地页 `landing`(浏览器直开、需 302+HTML)改挂在 nginx Ingress 的 `mcp.taiji-ai.com` 上**。这样 302/HTML 由 nginx 透传,**完全不碰 APIM 的响应类型限制**——你 §8 担心的"APIM 是否放行 302/HTML"这条前置直接消解。
- 即 `MAGIC_LINK_PUBLIC_BASE_URL = https://mcp.taiji-ai.com`,邮件里的链接 = `https://mcp.taiji-ai.com/api/auth/magic-link/landing?token=...&state=...`。
- 安全上方案1 更干净:**token 与一次性 code 全程不离开 mcp-server**,落地页校验 token→生成 code→302 在同一服务内原子完成;不像方案2 要把一次性 code 交给 HM 再 302(多一跳、攻击面更大)。
**⚠️ 选方案1 必须先确认的一个事实(运维侧,我方代码无法判定):**
`mcp.taiji-ai.com` 的 Ingress **TLS 块当前是注释状态**(`k8s/prod/ingress.yaml:29-33`,cert-manager 签发注解 `:26` 也注释)。而邮件链接**必须是 HTTPS**(《Heicode-登录接口对接文档.md》§6"禁止回退 HTTP")。
→ 请运维确认 **`mcp.taiji-ai.com` 是否已实际启用 HTTPS / 证书**:
- **若已启用** → 直接采用方案1(landing 挂 `mcp.taiji-ai.com`),`MAGIC_LINK_PUBLIC_BASE_URL` 即填此域,APIM 无需任何改动。
- **若未启用且不便启用** → 退而求其次仍走方案1、但 landing 挂 APIM(`apimtaiji.azure-api.net/api/mcp/...`),**此时回到你 §8 的前置:需 APIM owner 放行该 GET 并允许 302+HTML**。
- **两条 HTTPS 落地页都不可得时** → 才走**方案2**(HM 托管落地页),契约见 §9.3。
## 9.3 方案2 兜底契约(仅当 9.2 两条 HTTPS 落地页都不可行时启用)
按 §8 要求,把 landing 拆成"纯 JSON 校验 API",302/HTML 由 HM 出:
- **新增** `POST /api/auth/magic-link/landing-verify`(JSON,挂 APIM 即可,无 302/HTML)
- 请求:`{ "token": "<magic-link token>", "state": "<state>" }`
- 行为:校验 token(存在/未过期/未用,一次性消费)+ 校验 user `role==user`(D-3)→ 通过则生成一次性 `code`(TTL ≤2min,绑定 user+state)。
- 成功 200:`{ "success": true, "data": { "code": "<one-time-code>", "state": "<state>" } }`
- 失败:`400/401`(token 无效/过期/已用 或 role 不符),HM 据此渲染"链接无效"页。
- HM 侧:收浏览器 GET → 调本 API → 成功则 `302 Location: heicode://auth/callback?code=...&state=...`,失败则渲染 HTML。
- 安全提示:方案2 下一次性 `code` 会经 HM 中转,请 HM 确保该响应不落普通日志(对齐 §1.4)。
> **我方倾向**:方案1(优先 `mcp.taiji-ai.com` 直连)。请 HM/运维就 9.2 的 HTTPS 前置回一句,即可定稿。
## 9.4 据此更新落地清单(覆盖 §7.4 第 5 条)
- `config.py` 新增 `MAGIC_LINK_PUBLIC_BASE_URL`(方案1 填 `https://mcp.taiji-ai.com` 或 APIM 域;方案2 该项可不用,改由 HM 域拼链接)。
- 方案1 无需新增 `landing-verify`;方案2 需新增并同样加入两处 `allow_paths` 白名单(§7.2-A)。
- 其余(request/verify、邮件模板、Redis one-time key、限流复用)不变。
> **影响面更新**:选方案1 时**不改 APIM、不增 HM 代码**,仅 mcp-server 内新增端点 + 一个配置项 + 确认 `mcp.taiji-ai.com` 的 TLS(运维)。仍不影响 Swarm/Agent/计费/密钥审批链。
---
# 10. HM owner @zsbgnw12 确认 §9 选型(2026-06-09)
D-1/D-2/D-3 双方口径已一致(仅登录 / role=user / 防枚举统一成功),你 §9.1 的落实表述与 HM §8 完全吻合,无异议。
**D-4 选型 —— HM 同意你的方案1(landing 挂 `mcp.taiji-ai.com` 直连),理由认同:**
- 你的洞察对:把 `landing` 放 nginx Ingress 直连,302/HTML 由 nginx 透传,**绕开 APIM 响应类型限制**,我 §8 担心的那条前置直接消解;且 **token/一次性 code 全程不出 mcp-server**,比方案2 少一跳、攻击面更小——安全上方案1 确实更优。`request`/`verify` 继续走 APIM(HM 同源代理可达)不变。
- `MAGIC_LINK_PUBLIC_BASE_URL = https://mcp.taiji-ai.com`,邮件链接 = `https://mcp.taiji-ai.com/api/auth/magic-link/landing?...`。
**关于 §9.2 的 HTTPS 前置 —— 归属澄清(非 HM):**
- `mcp.taiji-ai.com` 是**你们(mcp/taiji 平台)侧的 Ingress 域名**,其 TLS/证书启用与否由**你们/平台运维**确认与开启,**HM(code.xinghanlab.com)无权也无代码涉及**。
- 所以这条 HTTPS 前置请你们运维拍:
- **已启用/可启用 HTTPS** → 直接方案1(`mcp.taiji-ai.com`),APIM、HM 都不动 —— 这是最干净路径,**HM 侧零改动**。
- **该域名 HTTPS 不可得,但 APIM 可放行 GET+302+HTML** → 方案1-APIM(landing 挂 apimtaiji 域),需你们 APIM owner 放行。
- **两条 HTTPS 落地页都不可得** → 才走**方案2**:HM 托管落地页。**HM 接受 §9.3 兜底契约**(你们提供 `POST /api/auth/magic-link/landing-verify` JSON API,HM 收浏览器 GET→调它→302 到 `heicode://`,失败渲染 HTML;HM 保证一次性 code 不落普通日志)。
## 开始实现的前置(对应"两方确认无问题才开搞")
双方技术设计已对齐。**还差两条确认才进入实现:**
1. **落地页 HTTPS 路径**(你们运维):确认 `mcp.taiji-ai.com` HTTPS 可用 → 定方案1;否则告知,我按方案1-APIM 或方案2 配合。
2. **邮件基建负责人/签字**(D-5,产品/基建,HM 在工单 #19 追)。
这两条一锁,契约即定稿:HM 在 heicodeDocs `integration/` 落正式契约,mcp 按 §7.4/§9.4 清单实现(默认即可上骨架,但**不外发**直到邮件基建就位),客户端切真实 path 联调。在此之前**双方都不动实现**(按 HM owner 要求:确认无误再开搞)。
→ 请你们运维回 §9.2 的 HTTPS 一句;邮件基建我去催产品。两条齐 → 开搞。
---
# 11. mcp-server 定调 D-4:**统一走 APIM 网关(不启用 mcp.taiji-ai.com 直连)**(2026-06-09)
> 我方(mcp/taiji 平台侧)拍板:**landing 也走 APIM,全部公网流量统一从网关进**。放弃 §9.2/§10 倾向的"`mcp.taiji-ai.com` 直连"路径。即采用 §9.2 列出的 **方案1-APIM**。
**决定与理由(统一网关):**
- 现有 4 个登录接口本就**全部经 APIM** `https://apimtaiji.azure-api.net/api/mcp`(《Heicode-登录接口对接文档.md》§2.1)。magic-link 的 `request`/`verify`/`landing` **一律走同一网关**,不为落地页单开第二个公网入口。
- 不启用 `mcp.taiji-ai.com` 直连的现实依据:该 Ingress 的 TLS 当前就是注释状态(`k8s/prod/ingress.yaml:29-33`),本就没对外提供 HTTPS;与其临时给它配证书、多开一个公网面,不如**收敛到 APIM 单一入口**——公网攻击面、TLS/证书、WAF、限流、审计边界全部统一在网关,运维与安全口径一致。
- 安全姿态不变:landing 的"校验 token → 生成一次性 code → 302"逻辑仍在 mcp-server 的 FastAPI 内,**APIM 只做反向代理透传,token 与一次性 code 不落 HM、不出我方服务**(仍是方案1 的安全模型,**不是**方案2)。
**最终取值:**
- `MAGIC_LINK_PUBLIC_BASE_URL = https://apimtaiji.azure-api.net/api/mcp`
- 邮件链接 = `https://apimtaiji.azure-api.net/api/mcp/api/auth/magic-link/landing?token=...&state=...`
- `request`/`verify` 路径不变,客户端经 HM 同源代理 `/api/heicode-auth/*` → APIM 调用。
**⚠️ 唯一技术前置(APIM owner 确认/配置,我方代码无法判定,必须开搞前锁定):**
APIM 对 landing 这个 **GET** 的 operation 必须满足——否则浏览器拿不到回跳:
1. **放行该 GET** `/api/mcp/api/auth/magic-link/landing`(含 `token`/`state` query 透传)。
2. **原样返回后端的 302**:不得 follow-redirect(APIM 不能代为跟随,必须把 302 透回浏览器)、不得缓冲改写、不得强制 JSON。
3. **保留并放行 `Location` 头,且其值是非 http(s) 的自定义 scheme `heicode://auth/callback?...`** —— 这是最容易被网关/策略拦的点,需确认 APIM 不会校验/重写/丢弃非 http scheme 的 Location。
4. 失败分支 landing 返回 **HTML**(人类可读"链接无效"页),APIM 需允许 `text/html` 响应透传。
> 若 APIM 经确认**无法**满足上述(尤其第 3 条非 http Location 透传),则按 §9.3 **回退方案2**(HM 托管落地页 + 我方 `landing-verify` JSON API)。但在此之前,**首选统一走 APIM 的方案1-APIM**。
**请 HM/APIM owner 回一句**:APIM 能否满足上述 4 条(重点第 2、3 条)。能 → D-4 即按本节定稿(`MAGIC_LINK_PUBLIC_BASE_URL` 取 APIM 域);同时仍需 D-5 邮件基建负责人到位,才进入实现并对外发信。
---
# 12. mcp-server 实现级代码验证(2026-06-09,开写前最终核验)
> 在让 HM 端开始写之前,mcp-server 对**应用装配层**做了一遍核验,确认本契约在我方代码里**无实现级阻碍**,并据此**更正 §7.2-A**。核验文件:`app/application.py`、`app/routes/__init__.py`、`app/rate_limiter.py`。
## 12.1 landing 返回 302/HTML —— 应用层确认可行 ✅
- 应用工厂**没有任何把响应包成 JSON envelope 的中间件**:`auth_middleware` 仅 `return await call_next(request)`,不改写响应体(`app/application.py:45-74`)。
- 全局 `RateLimitMiddleware` 只对 `principal.type == "api_key"` 的请求限流;magic-link 三端点不带 api_key,命中"非 api_key 直接放行"分支(`app/rate_limiter.py:155-163`),**既不限流也不改写响应**。
- 结论:`landing` 用 `RedirectResponse(302, headers={"Location": "heicode://auth/callback?..."})` 与失败 `HTMLResponse`,在我方 FastAPI 内**原生可行、无中间件干扰**。(`heicode://` 非 http scheme 在 FastAPI 侧不校验;唯一风险在 APIM 透传,见 §11,属网关配置。)
## 12.2 ⚠️ 更正 §7.2-A:无需改任何 allow_paths
核验发现现网鉴权 wiring 与 §7.2-A 的假设不同:
- 路由统一用 `app.include_router(router)` 挂载,**不带** `dependencies=[Depends(require_auth)]`(`app/routes/__init__.py:69`)。
- `auth_middleware` 是**放行式**的:调 `authenticate_request` 取 principal,**取不到也不 401**——对 `/api`/`/agents` 路径设 `request.state.principal = {}` 后照常 `call_next`(`app/application.py:64-74`)。所以 `authenticate_request` 的 allow_paths(`app/auth.py:335-346`)**在当前 wiring 下并不 gate 访问**。
- 真正的鉴权门是**路由函数显式声明的** `Depends(require_auth)`;而 `login`(`app/routes/auth.py:96`)、`register`(`:721`)等公开接口**都没声明它**,因此公开。
- **更正结论**:magic-link 的 `request`/`landing`/`verify` 只要**不声明 `Depends(require_auth)`** 即为公开,与 `login`/`register` 完全一致;**不需要**改 `require_auth` 或 `authenticate_request` 的任何 allow_paths。→ **§7.4 第 2 条作废**(删除"两处 allow_paths 同步加入")。
## 12.3 登录产物等价 —— 再确认
`verify` 复用 `create_access_token/create_refresh_token` + 与 `/login` 逐字段相同的 `token_data`,产物 = 现有登录(24h/7d、claims `sub/email/role/channelId/type/iat/exp`)。该响应体与 token 模型已在《Heicode-登录接口对接文档.md v1.0》§2.3/§8 标注"已上线生产、端到端验证通过",无悬念。
## 12.4 开写前状态小结
- **mcp-server 应用代码侧:无阻碍。** request/verify/landing 可按 §2 契约实现;landing 走 §11 的 APIM 统一网关;实现清单见 §7.4(**第 2 条已按 §12.2 作废**)+ §9.4。
- **仅剩两条外部前置(不阻塞 HM 写客户端/代理与 APIM 配置,仅阻塞对外发信/go-live)**:
1. **APIM 透传**(§11 的 4 条,重点 302 + `heicode://` 非 http Location)—— APIM owner 确认/配置;
2. **邮件基建负责人/签字**(D-5)—— 产品/基建,HM 在工单 #19 追。
- 据此,**HM 端可按本契约开始编写**(客户端邮箱登录 UI + `heicode://` 注册转发 + 同源代理 request/verify + APIM landing 配置);mcp-server 端按 §7.4/§9.4 实现端点骨架(**默认不外发**,直到邮件基建就位)。契约以本文件 §0–§5 + §8(D-1/2/3) + §11(D-4=APIM) + §12(实现级更正) 为准。
---
# 13. HM owner @zsbgnw12 开工指令 + 待 mcp-server 推进项(2026-06-09)
> 收到 §12 实现级核验。契约以「§0–§5 + §8(D-1/2/3) + §11(D-4=APIM) + §12(更正)」为准,**HM 无新增异议**。下面给 🟢 开工授权 + 明确还需 mcp-server 侧推进的两条前置。
## 13.1 HM 侧已就绪(已核验 HM 代码,mcp 可直接依赖)
- **同源代理 `/api/heicode-auth/*proxyPath`(`controller/heicode_auth_proxy.go`)= 通用透传**:原样转发 method / query / body / `Authorization` / `Content-Type` / `Accept`,回传上游 status + body,20s 超时。
- ⇒ `request`/`verify`(POST JSON)经 `https://code.xinghanlab.com/api/heicode-auth/api/auth/magic-link/{request,verify}` → APIM **HM 零改动即通**,客户端无跨域问题。
- `landing` 是浏览器 → APIM **直达,不经此代理**(与 §11 一致),所以 302/HTML 由 APIM→mcp 透传,不碰 HM。
## 13.2 🟢 开工授权(骨架,默认不外发)
请 mcp-server **现在就开始**实现端点骨架,不必再等 HM:
- 按 §7.4(**第 2 条已按 §12.2 作废**,无需改 allow_paths)+ §9.4:`request` / `verify` / `landing`(302+HTML) 三端点;`verify` 复用 `create_access_token/create_refresh_token` + 与 `/login` **逐字段相同**的 `token_data`;Redis one-time key(token 10min / code ≤2min);限流复用现有 `_LoginRateLimit` + 邮箱限流。
- 邮件模板(URL 形式)先建好,**默认 mock 邮件 / 打日志,不真实发信**,直到 D-5 邮件基建签字。
- `MAGIC_LINK_PUBLIC_BASE_URL = https://apimtaiji.azure-api.net/api/mcp`(§11 终态)。
## 13.3 ⭐ 还需 mcp-server 侧推进的前置(你们网关侧,HM 无权配置)
1. **[最关键] APIM 透传确认/配置**(§11 的 4 条,重点第 2、3 条):
- APIM 对 `GET /api/mcp/api/auth/magic-link/landing` 必须:放行该 GET(含 `token`/`state` query)、**原样透回后端 302(不 follow、不缓冲改写、不强制 JSON)**、**保留并放行非 http 的 `Location: heicode://auth/callback?...`**、失败分支允许 `text/html` 透传。
- `apimtaiji.azure-api.net` 是**你们(mcp/taiji 平台)侧网关**,请你们 APIM owner 配置/确认,**结论回写本文件**:
- ✅ 能透传 `heicode://` 302 → **D-4 定 APIM**,HM 零改动。
- ❌ 不能透传非 http Location → **回退方案 2**(§9.3):你们新增 `POST /api/auth/magic-link/landing-verify`(JSON 校验 token→出一次性 code),**HM 托管落地页出 302**(HM 接受此兜底,确认即开始加这段小改动)。
- **请在本文件明确回一句:APIM 能否满足上述(尤其第 3 条 `heicode://` Location 透传)。**
2. **D-5 邮件基建负责人/签字**:SMTP 通道(`taijiagent@189.cn`)可用是好事;**模板文案 / 发件人 / 送达率 / 防滥用最终签字属产品**——**HM 负责在工单 #19 追产品/基建指认负责人**。这条是「真实对外发信」的前置,**不阻塞你们先上骨架(mock 发信)**。
## 13.4 HM 侧并行推进(不阻塞你们)
- 客户端 cc-haha:邮箱登录 UI + `heicode://auth/callback` 深链注册转发 + `state` 比对,对 mock 先行。
- 契约定稿(13.3 两条回齐)后,HM 在 heicodeDocs `integration/` 落**正式契约**,客户端切真实 path 联调。
## 13.5 一句话状态
**契约已锁、HM 代理已通、🟢 开工骨架。** 卡点只剩两条、且都在你们/产品侧:① APIM 能否透传 `heicode://` 302(你们 APIM owner 回一句);② D-5 邮件基建签字(HM 在 #19 追)。这两条回齐即定稿对外。请把 ① 的结论回写本文件。
---
# 14. mcp-server 骨架已落地 + ① APIM 待办归属(2026-06-09)
> 收到 §13 🟢 开工授权。按 §7.4(第 2 条已按 §12.2 作废)+ §9.4 + §11 + §13.2,mcp-server 端三端点**骨架已实现并通过语法/导入级核验**,默认 mock 发信、不外发。以下据真实代码逐条交代落地点,并按 §13.5 回写 ① 的当前结论。
## 14.1 已落地(真实代码位置)
- **新增独立支撑模块 `app/magic_link.py`**(与既有逻辑零耦合,**未改动**任何密码登录/注册验证码代码):
- Redis 一次性凭证:`magic_link_token:{token}`(TTL 600s)、`magic_link_code:{code}`(TTL 120s),沿用现网验证码同款 `setex` 落地 + 命中即 `delete` + 集群 MOVED 重定向重试。
- 邮箱维度限流:**独立**键 `magic_link_rate_limit:{email}`(60s),不与注册/忘记密码共用的 `verification_rate_limit:{email}` 互相误伤。
- `send_magic_link_email()`:**复用** `app/email_verification.py` 的 SMTP 通道(`taijiagent@189.cn`);`SMTP_PASSWORD` 未注入时**不外发**,仅 DEBUG 下打印链接(= §13.2「默认 mock / 打日志」,待 D-5 签字后真实发信)。
- **`app/routes/auth.py` 新增三端点**(均**不声明 `Depends(require_auth)`** → 公开,与 `login`/`register` 一致,**未动 allow_paths**):
- `POST /api/auth/magic-link/request`:IP 限流(独立 `_LoginRateLimit` 实例)+ 邮箱 60s 限流;防枚举——限流在查用户前打、对存在/不存在邮箱一视同仁;仅对**已存在的 `role=user`** 用户真发信(D-1/D-2/D-3),响应一律 `200 {request_id, state, expires_in_sec:600}`。
- `GET /api/auth/magic-link/landing?token=&state=`:一次性消费 token + 校验 state + 复核 user 仍存在且 `role=user` → 生成一次性 code → **`302 Location: heicode://auth/callback?code=...&state=...`**;任一校验失败返回人类可读 `HTMLResponse`(含「请在已安装 HeiCode 的同一台设备打开」提示)。
- `POST /api/auth/magic-link/verify`:一次性消费 code + 校验 state + `role=user` → 复用**同一个** `create_access_token/create_refresh_token` + 与 `/login` user 分支**逐字段相同**的 `token_data` → 返回 `{token, refreshToken, user{id,name,email,role,channelId}}`;审计走现有 `log_audit_event(action="auth.login", details={method:"magic_link"})`。→ **EU/计费零改动**(§1.3 / §12.3)。
- **`config.py` 新增** `magic_link_public_base_url`,缺省 `https://apimtaiji.azure-api.net/api/mcp`(§11 终态),由运维注入。
## 14.2 ① APIM 透传 —— 当前结论:**OPEN,挂我方 APIM owner**(不伪造结论)
按 §13.3.1,`apimtaiji.azure-api.net` 是我方(mcp/taiji 平台)侧网关,需 APIM owner 配置/确认 §11 的 4 条,**重点第 2、3 条**:原样透回后端 302(不 follow / 不缓冲改写 / 不强制 JSON)、且**保留并放行非 http 的 `Location: heicode://auth/callback?...`**。
- 本条属网关运维配置,**应用代码无法判定**,截至本次回写**尚未拿到 APIM owner 的确认**,故 ① 仍为 **OPEN**,由我方 APIM owner 跟进后回写本节。
- 应对两种结论我方均已就绪:
- ✅ APIM 能透传 `heicode://` 302 → **D-4 定 APIM**,邮件链接即 `https://apimtaiji.azure-api.net/api/mcp/api/auth/magic-link/landing?...`,HM 零改动。
- ❌ APIM 不能透传非 http Location → 我方按 §9.3 **回退方案 2**:新增 `POST /api/auth/magic-link/landing-verify`(JSON 校验 token→出一次性 code),由 HM 托管落地页出 302。该端点为增量小改,确认后即补。
## 14.3 状态
- **骨架 DONE(mock 发信)**,不阻塞 HM 客户端/代理并行推进。
- **真实对外发信前置仍两条**:① APIM 透传(我方 APIM owner,OPEN);② D-5 邮件基建签字(产品/基建,HM 在 #19 追)。两条齐 → 切真实 path + 真实发信联调。
+250
View File
@@ -0,0 +1,250 @@
"""
Heicode magic-link 邮箱登录 —— Redis 一次性 token/code 存取 + 登录链接邮件发送
本模块为 Heicode magic-link 登录新增的独立支撑层,契约见
`Docs/Heicode-magic-link邮箱登录-给mcp-server的对接需求.md`(§2/§7/§9/§11/§12)。
设计要点(与既有逻辑零耦合,绝不改动密码登录/注册验证码链路):
- **复用** `email_verification` 的 SMTP 通道(smtp.189.cn / taijiagent@189.cn)发送
登录链接邮件;
- **复用** `state.redis_client` 存一次性凭证,沿用现有验证码同款 one-time 模式
(`setex` 落地 + 命中即 `delete`,含 Redis 集群 MOVED 重定向重试);
- token / code 均为短 TTL、一次性:
- magic-link token:TTL 600s(邮件链接里的凭证)
- 一次性 code:TTL 120s(landing 校验通过后换取登录态的凭证)
"""
from __future__ import annotations
import json
import secrets
from typing import Optional
import structlog
from app.state import get_state
# 复用现有 SMTP 通道与同步发信实现,不另起炉灶
from app.email_verification import (
SMTP_EMAIL,
SMTP_PASSWORD,
_send_email_sync,
)
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
logger = structlog.get_logger(__name__)
# ===== TTL / Redis key 约定(契约 §2 / §7.1)=====
MAGIC_LINK_TOKEN_TTL_SECONDS = 600 # 邮件链接 token:10 分钟
MAGIC_LINK_CODE_TTL_SECONDS = 120 # 一次性 code:≤2 分钟
_TOKEN_KEY_PREFIX = "magic_link_token:"
_CODE_KEY_PREFIX = "magic_link_code:"
# 邮箱维度限流(§13.2「邮箱限流」)。**独立**于注册/忘记密码共用的
# verification_rate_limit:{email},避免 magic-link 与那两条流程互相误伤。
_EMAIL_RATE_LIMIT_KEY_PREFIX = "magic_link_rate_limit:"
EMAIL_RATE_LIMIT_SECONDS = 60 # 同一邮箱 60s 内只接受一次申请
_MAX_REDIS_RETRIES = 3
def generate_magic_link_token() -> str:
"""生成 magic-link token(URL-safe,随邮件链接下发)。"""
return secrets.token_urlsafe(32)
def generate_one_time_code() -> str:
"""生成一次性 code(URL-safe,landing 302 回跳给客户端)。"""
return secrets.token_urlsafe(24)
async def _redis_get(key: str) -> Optional[str]:
"""带 Redis 集群 MOVED 重定向重试的 GET(与 email_verification 同款)。"""
state = get_state()
if not state.redis_client:
logger.warning("magic_link_redis_unavailable", op="get")
return None
for attempt in range(_MAX_REDIS_RETRIES):
try:
return await state.redis_client.get(key)
except Exception as e: # noqa: BLE001
if "MOVED" in str(e) and attempt < _MAX_REDIS_RETRIES - 1:
import asyncio
await asyncio.sleep(0.1)
continue
raise
return None
async def _redis_delete(key: str) -> None:
"""带 MOVED 重试的 DELETE;删除失败不致命(一次性消费已凭 get 判定)。"""
state = get_state()
if not state.redis_client:
return
for attempt in range(_MAX_REDIS_RETRIES):
try:
await state.redis_client.delete(key)
return
except Exception as e: # noqa: BLE001
if "MOVED" in str(e) and attempt < _MAX_REDIS_RETRIES - 1:
import asyncio
await asyncio.sleep(0.1)
continue
logger.warning("magic_link_redis_delete_failed", key_prefix=key[:24], error=str(e))
return
async def _setex(key: str, ttl: int, value: str) -> bool:
state = get_state()
if not state.redis_client:
logger.warning("magic_link_redis_unavailable", op="setex")
return False
try:
await state.redis_client.setex(key, ttl, value)
return True
except Exception as e: # noqa: BLE001
logger.error("magic_link_redis_setex_failed", key_prefix=key[:24], error=str(e))
return False
# ===== 邮箱维度限流(§13.2)=====
async def check_email_rate_limit(email: str) -> tuple[bool, int]:
"""检查同一邮箱的申请频率限制。
Returns: (是否可以申请, 剩余等待秒数)。Redis 不可用时放行(不阻塞用户),
与 email_verification.check_rate_limit 的容错口径一致。
"""
state = get_state()
if not state.redis_client:
return True, 0
try:
ttl = await state.redis_client.ttl(_EMAIL_RATE_LIMIT_KEY_PREFIX + email)
if ttl and ttl > 0:
return False, ttl
return True, 0
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_rate_limit_check_failed", email=email, error=str(e))
return True, 0
async def set_email_rate_limit(email: str) -> None:
"""对该邮箱设置 60s 申请冷却。**对存在/不存在的邮箱一律设置**——否则
「存在→后续 429 / 不存在→后续 200」会泄漏邮箱存在性,破坏防枚举(D-2)。"""
state = get_state()
if not state.redis_client:
return
try:
await state.redis_client.setex(
_EMAIL_RATE_LIMIT_KEY_PREFIX + email, EMAIL_RATE_LIMIT_SECONDS, "1"
)
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_rate_limit_set_failed", email=email, error=str(e))
# ===== token:email + state 绑定 =====
async def store_magic_link_token(token: str, email: str, state: str) -> bool:
"""存 magic-link token → {email, state},TTL 600s。"""
payload = json.dumps({"email": email, "state": state})
return await _setex(_TOKEN_KEY_PREFIX + token, MAGIC_LINK_TOKEN_TTL_SECONDS, payload)
async def consume_magic_link_token(token: str) -> Optional[dict]:
"""一次性消费 token:命中则返回 {email, state} 并删除;否则 None。"""
if not token:
return None
key = _TOKEN_KEY_PREFIX + token
raw = await _redis_get(key)
if not raw:
return None
await _redis_delete(key)
try:
return json.loads(raw)
except (ValueError, TypeError):
logger.warning("magic_link_token_payload_corrupt")
return None
# ===== code:user_id + email + state 绑定 =====
async def store_one_time_code(code: str, user_id: str, email: str, state: str) -> bool:
"""存一次性 code → {user_id, email, state},TTL ≤120s。"""
payload = json.dumps({"user_id": user_id, "email": email, "state": state})
return await _setex(_CODE_KEY_PREFIX + code, MAGIC_LINK_CODE_TTL_SECONDS, payload)
async def consume_one_time_code(code: str) -> Optional[dict]:
"""一次性消费 code:命中则返回 {user_id, email, state} 并删除;否则 None。"""
if not code:
return None
key = _CODE_KEY_PREFIX + code
raw = await _redis_get(key)
if not raw:
return None
await _redis_delete(key)
try:
return json.loads(raw)
except (ValueError, TypeError):
logger.warning("magic_link_code_payload_corrupt")
return None
# ===== 登录链接邮件(复用现有 SMTP 通道)=====
async def send_magic_link_email(email: str, link_url: str) -> bool:
"""发送 magic-link 登录链接邮件。
复用 email_verification 的 SMTP 通道。SMTP_PASSWORD 未注入时(邮件基建尚未
就位,契约 §12.4「默认不外发」),记录链接到日志并返回 False,不抛异常 —— 让
上层 `request` 端点照常返回 200(防枚举)。
"""
if not SMTP_PASSWORD:
# 邮件基建未就位:不外发,仅在 DEBUG 下打日志便于联调对 mock。
import os
if os.getenv("DEBUG", "false").lower() == "true" or \
os.getenv("ENABLE_TEST_MODE", "false").lower() == "true":
logger.warning(
"magic_link_email_not_sent_smtp_unconfigured",
email=email,
link_url=link_url,
hint="SMTP_PASSWORD 未配置;测试模式下仅打印链接,不真正发信",
)
else:
logger.error("magic_link_email_smtp_unconfigured", email=email)
return False
try:
msg = MIMEMultipart()
msg["From"] = SMTP_EMAIL
msg["To"] = email
msg["Subject"] = "Taiji AI-PAD 登录链接"
body = f"""
尊敬的用户:
您正在登录 HeiCode 客户端。请在 10 分钟内,**在已安装 HeiCode 的同一台设备上**
点击下面的链接完成登录:
{link_url}
提示:此链接仅用于本次登录,点击后会自动回跳到本机 HeiCode 客户端。
请务必在安装了 HeiCode 的同一台设备上打开此链接,否则客户端无法收到回跳。
如果您没有发起登录,请忽略此邮件,您的账户仍然安全。
此邮件由系统自动发送,请勿回复。
---
Taiji AI-PAD 团队
"""
msg.attach(MIMEText(body, "plain", "utf-8"))
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _send_email_sync, msg)
logger.info("magic_link_email_sent", email=email)
return True
except Exception as e: # noqa: BLE001
logger.error("magic_link_email_send_failed", email=email, error=str(e),
error_type=type(e).__name__)
return False
+226 -1
View File
@@ -4,6 +4,7 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
@@ -37,7 +38,19 @@ from app.schemas import (
)
from app.email_verification import verify_code, peek_verification_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code
from app.audit import log_audit_event
from pydantic import BaseModel
from app.magic_link import (
generate_magic_link_token,
generate_one_time_code,
store_magic_link_token,
consume_magic_link_token,
store_one_time_code,
consume_one_time_code,
send_magic_link_email,
check_email_rate_limit,
set_email_rate_limit,
MAGIC_LINK_TOKEN_TTL_SECONDS,
)
from pydantic import BaseModel, EmailStr
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
from app.litellm_client import get_litellm_client, LiteLLMClientError
from config import settings
@@ -1317,3 +1330,215 @@ async def set_billing_provider(
"old_billing_provider": old,
})
# ============================================================
# Heicode magic-link 邮箱登录(§2 契约 / §8 D-1·D-2·D-3 / §11 D-4=APIM / §12)
# —— 纯新增的并行登录方式:不输密码,邮箱收链接,点链接回跳客户端完成登录。
# 绝不改动 /login、/me、/refresh、/logout、/register 任何现有行为(满足 §1.1)。
# · D-1 仅登录:verify 只对**已存在 user** 签发登录产物,绝不触发 register provisioning;
# 未注册邮箱在 request 阶段静默不发信。
# · D-2 防枚举:request 无论邮箱是否注册一律返回成功。
# · D-3 仅 role=user:channel/admin 继续走密码登录。
# · §1.3 登录产物等价:verify 复用 create_access_token/create_refresh_token
# + 与 /login 逐字段相同的 token_data → EU/计费零改动。
# 三端点均**不声明 Depends(require_auth)** 即公开(§12.2 已证实,无需改 allow_paths)。
# ============================================================
# 与密码登录独立的限流计数器(IP 维度 5 次/60s,对齐 §2.1)
_magic_link_rate_limit = _LoginRateLimit()
class _MagicLinkRequest(BaseModel):
"""magic-link/request 请求体。"""
email: EmailStr
class _MagicLinkVerify(BaseModel):
"""magic-link/verify 请求体。device_pubkey 可选,我方忽略(设备配对在 HM 侧,
见契约 §3 / §7.1 D-4),且**不改变** token 结构。"""
code: str
state: str
device_pubkey: Optional[str] = None
_LANDING_INVALID_HTML = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>链接无效</title></head>
<body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#222;text-align:center">
<h2>登录链接无效或已过期</h2>
<p>请回到 HeiCode 客户端重新获取登录链接。</p>
<p style="color:#888;font-size:14px">提示:请在已安装 HeiCode 的同一台设备上打开邮件链接。</p>
</body></html>"""
@router.post("/magic-link/request", response_model=SuccessResponse)
async def magic_link_request(
req: _MagicLinkRequest,
request: Request,
db: AsyncSession = Depends(get_db),
_: None = Depends(_magic_link_rate_limit),
):
"""申请 magic-link 登录链接(契约 §2.1)。
防枚举(D-2):无论邮箱是否注册一律返回成功;仅当邮箱对应**已存在的
role=user 用户**(D-1 仅登录 + D-3)时才真正生成 token 并发信,其余情况静默。
"""
email = req.email
# state 始终下发(客户端存下,回跳时严格比对);request_id 供追踪。
state = secrets.token_urlsafe(16)
request_id = str(uuid.uuid4())
# 邮箱维度限流(§13.2):在查用户之前判定,且对存在/不存在邮箱一视同仁,
# 既防止对真实用户的邮件轰炸,又不泄漏邮箱存在性(D-2 防枚举)。
can_send, remaining = await check_email_rate_limit(email)
if not can_send:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请等待{remaining}秒后再重新申请登录链接",
headers={"Retry-After": str(remaining)},
)
# 无论后续是否真正发信,都先打上冷却(防枚举)
await set_email_rate_limit(email)
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is not None and user.role == "user":
token = generate_magic_link_token()
stored = await store_magic_link_token(token, email, state)
if stored:
base = settings.magic_link_public_base_url.rstrip("/")
link = f"{base}/api/auth/magic-link/landing?token={token}&state={state}"
# 发信失败不影响响应(防枚举 + 邮件基建未就位时静默,见 §12.4)
await send_magic_link_email(email, link)
else:
logger.error("magic_link_token_store_failed", email=email)
else:
# 未注册 / 非 user 角色:静默不发信(与防枚举一致)
logger.info("magic_link_request_silent_skip", email=email)
return SuccessResponse(data={
"request_id": request_id,
"state": state,
"expires_in_sec": MAGIC_LINK_TOKEN_TTL_SECONDS,
})
@router.get("/magic-link/landing")
async def magic_link_landing(
token: str = Query(..., description="magic-link token"),
state: str = Query(..., description="客户端 state"),
db: AsyncSession = Depends(get_db),
):
"""邮件链接指向的落地页(契约 §2.2)。浏览器直接打开,经 APIM 反代透传(§11)。
校验 token(存在/未过期/未用,一次性消费)+ state 一致 + user 仍存在且
role=user → 生成一次性 code(≤2min)并 **302 跳转** 到桌面深链
`heicode://auth/callback?code=...&state=...`;任一校验失败返回人类可读 HTML。
"""
payload = await consume_magic_link_token(token)
if not payload or payload.get("state") != state:
return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_400_BAD_REQUEST)
email = payload.get("email")
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
# D-3:仅 role=user;用户被删/改角色则视为无效
if user is None or user.role != "user":
return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_400_BAD_REQUEST)
code = generate_one_time_code()
stored = await store_one_time_code(code, str(user.id), email, state)
if not stored:
logger.error("magic_link_code_store_failed", email=email)
return HTMLResponse(content=_LANDING_INVALID_HTML, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
redirect_url = f"heicode://auth/callback?code={code}&state={state}"
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
@router.post("/magic-link/verify", response_model=SuccessResponse)
async def magic_link_verify(
req: _MagicLinkVerify,
request: Request,
db: AsyncSession = Depends(get_db),
_: None = Depends(_magic_link_rate_limit),
):
"""用一次性 code 换登录态(契约 §2.3)。
成功响应与 `POST /api/auth/login`(user 分支)**逐字段一致**:同 token_data、
同 create_access_token/create_refresh_token、同 24h/7d TTL、同 {token,
refreshToken, user{id,name,email,role,channelId}}。→ EU/计费零改动(§1.3)。
"""
success = False
result_user_id: Optional[str] = None
error_msg: Optional[str] = None
try:
payload = await consume_one_time_code(req.code)
if not payload or payload.get("state") != req.state:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录凭证无效或已过期",
)
# 按 code 绑定的 user_id 解析(D-3:仅 role=user)
uid_raw = payload.get("user_id")
try:
user = await db.get(User, uuid.UUID(str(uid_raw)))
except (ValueError, TypeError):
user = None
if user is None or user.role != "user":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录凭证无效或已过期",
)
# 与 /login 一致:更新最后登录时间
user.last_login_at = datetime.utcnow()
await db.commit()
# 与 /login user 分支逐字段相同的 token_data
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
success = True
result_user_id = str(user.id)
return SuccessResponse(
data={
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(user.id),
"name": user.name or user.full_name,
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
},
}
)
except HTTPException as e:
error_msg = e.detail if isinstance(e.detail, str) else str(e.detail)
raise
finally:
try:
await log_audit_event(
action="auth.login",
resource_type="user",
resource_id=result_user_id,
user_id=result_user_id,
success=success,
details={"role": "user", "method": "magic_link"},
error_message=error_msg,
request=request,
db=db,
)
except Exception as audit_exc:
logger.warning("magic_link_verify_audit_failed", error=str(audit_exc))
+9
View File
@@ -136,6 +136,15 @@ class Settings(BaseSettings):
# 用 Authorization: Bearer <这个值> 鉴权
heicode_internal_service_token: str = os.getenv("HEICODE_INTERNAL_SERVICE_TOKEN", "")
# Heicode magic-link 邮箱登录:本服务对外公网基址(见
# Docs/Heicode-magic-link邮箱登录-给mcp-server的对接需求.md §11 拍板=统一走 APIM)。
# 用于在邮件正文里拼出用户浏览器可直接打开的 landing 绝对 URL:
# {base}/api/auth/magic-link/landing?token=...&state=...
# 由运维注入;缺省取 §11 定稿的 APIM 域。
magic_link_public_base_url: str = os.getenv(
"MAGIC_LINK_PUBLIC_BASE_URL", "https://apimtaiji.azure-api.net/api/mcp"
)
# 云存储设置(Azure Blob Storage)
azure_storage_connection_string: str = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
s3_bucket: str = os.getenv("S3_BUCKET", "taiji-ai-exports")