# FSD m01 · 账号认证与权限（auth + gateway）

LLMS 索引： [llms.txt](/llms.txt)

---

> 本册覆盖代码域 `internal/domain/auth` 与横切包 `internal/gateway`。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §2（权限矩阵 §2.1、状态机 §2.2、一致性 §2.3）。

## m01 功能节目录

| ID | 名称 | 路由/入口 |
|---|---|---|
| F1.01 | B 端两段式登录（密码+TOTP） | `POST /api/auth/login` + `POST /api/auth/mfa` |
| F1.02 | 患者登录 | `POST /pub/v1/login` |
| F1.03 | 访客会话签发 guest-session | `POST /pub/v1/guest-session` |
| F1.04 | JWT 五面鉴权与路由矩阵 | 全部路由面（中间件） |
| F1.05 | RBAC 权限码字典与路由守卫 | `/api` 面业务路由（中间件） |
| F1.06 | /pub IP 限流 | `/pub` 面组级中间件 |

---

#### F1.01 B 端两段式登录（密码+TOTP）

> 对应 SRS：§4.2 后台账号安全（机制细化） ｜ 实现落点：`internal/domain/auth/service.go:70`（Login）/`:114`（MFA）/`handler.go:22-27`（路由） ｜ 操作入口：web `/admin/login` 登录页两段表单；API curl

##### 1 功能定义

B 端员工（ADMIN/CONSULTANT/DOCTOR）以 email+密码完成第一段认证；启用了 TOTP 2FA 的账号（`totp_status='ENABLED'`）追加第二段：`mfa_token` + 6 位动态码换正式 bo JWT。登录成功响应携带 RBAC 字典快照 `permissions[]`（前端菜单/按钮显隐唯一事实源，⚠ DIF-M8 ⑤）。

##### 2 触发条件与前置状态

- 第一段：任意时刻可调（登录即取 token 入口，路由裸挂 `/api` 组无 JWT 中间件）。
- 第二段：仅当第一段返回 `mfa_required=true` 且持有未过期的 `mfa_token`（iss=mfa，TTL 5min，仅含 uid 无业务权限）。
- 账号前置：`user_account.status='ACTIVE'`（DISABLED 一律 401）；`totp_status='ENABLED'` 才触发两段（PENDING/NONE 直发 token——PENDING 态触发两段会 setup 与登录互相锁死，⚠ DIF-M8 ①）。

##### 3 输入与校验

| 字段 | 端点 | 类型 | 校验 |
|---|---|---|---|
| email | login | string | 非空；`NormalizeEmail` 归一化（trim+lower）后查询 |
| password | login | string | 非空；bcrypt 比对 |
| mfa_token | mfa | string | 非空；`Verify(token, IssuerMFA)` 验签+issuer+exp |
| code | mfa | string | 非空；TOTP ±1 时间窗校验（`cryptoutil.TOTPValidate`） |

bind 失败或缺字段 → 400。

##### 4 处理流程

```mermaid
sequenceDiagram
    participant C as 客户端
    participant H as auth.Handler
    participant S as auth.Service
    participant DB as MySQL
    participant R as Redis
    participant SG as gateway.Signer
    C->>H: POST /api/auth/login (email, password)
    H->>S: Login(ctx, email, password)
    S->>DB: SELECT user_account WHERE email=?
    alt 账号不存在 / 密码错 / 非 ACTIVE
        S-->>H: ErrInvalidCredentials → 401（统一，防枚举）
    end
    S->>DB: UPDATE last_login_at（失败仅 warn）
    alt totp_status = ENABLED
        S->>SG: IssueMFA(uid)（iss=mfa, 5min, 仅 uid）
        H-->>C: 200 {mfa_required:true, mfa_token}
    else
        S->>SG: IssueBO(uid, role, pv=1) + permissionsOf 直查字典
        H-->>C: 200 {token, expires_in:43200, role, pv, permissions[]}
    end
    C->>H: POST /api/auth/mfa (mfa_token, code)
    H->>S: MFA(ctx, mfaToken, code)
    S->>SG: Verify(mfa_token, iss=mfa)
    S->>DB: SELECT 账号（须 ACTIVE）
    S->>S: AES-GCM 解密 totp_secret → base32 解码
    S->>S: TOTPValidate(secret, code, now)（±1 窗）
    S->>R: SETNX totp:<db>:<uid>:<code> EX 90（防重放）
    alt redis 错误
        S->>S: 放行 + Warn（第二因子可用性优先）
    else 键已存在（90s 内重放）
        S-->>H: ErrInvalidCredentials → 401
    end
    S->>SG: IssueBO + permissionsOf
    H-->>C: 200 {token, role, pv, permissions[]}
```

##### 5 输出与结果状态

- 第一段（需 2FA）：`{mfa_required: true, mfa_token}`。
- 直发 / 第二段成功：`{token, expires_in: 43200, role, pv: 1, permissions: [...]}`——permissions 为字典表直查快照（零授予归一空数组 `[]` 非 null）。
- `last_login_at` 已更新。

##### 6 状态流转

登录本身不改状态；`totp_status` 三态机（NONE→PENDING→ENABLED，见总则 §2.2）由 F10.02 自助 2FA 端点驱动，`NeedsMFA` 唯一判据 `totp_status=='ENABLED'`。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 邮箱不存在/密码错/账号 DISABLED | 一律 401（不区分——防账号枚举），细节仅进日志 |
| mfa_token 过期（>5min）/伪造/issuer 不符 | 401 |
| TOTP 码错误（含 ±1 窗外） | 401 |
| 同一码 90s 内重复使用 | 401（redis SETNX 防重放；e2e 重跑睡到下一 30s 边界重算） |
| redis 不可用 | 防重放跳过 + Warn（放行，可用性优先） |
| master_key 未配置 / 密文解不开 | 503 语义错误（2FA 不可用，功能降级可见） |
| e2e 注意 | dev seed TOTP 密文预生成写死（dev-only）；base32 必须**无 padding 且长度 %8==0**（双端约束，⚠ DIF-M5 ⑦） |

##### 8 权限与数据规则

- 路由裸挂 `/api`（登录即取 token 入口）；TOTP 自助三端点（setup/enable/disable）挂 bo JWT 子组，登录即可、无权限码。
- 凭据存储：bcrypt(cost 10) 哈希；`totp_secret` 落库 = AES-256-GCM(base32(secret))，master_key 加密（master_key 生产必换）。
- 审计：登录失败仅日志（zap Warn），不进 operation_log（无业务状态变更）。

---

#### F1.02 患者登录

> 对应 SRS：—（DIF-001 患者登录机制 SRS 未定义，已裁决 email+密码，tech-design §15 风险 1） ｜ 实现落点：`internal/domain/patient/service.go:180`（Login）/`handler.go:29`（路由） ｜ 操作入口：app H5 登录页；API curl

##### 1 功能定义

患者以 email+密码登录换取 pt JWT（iss=pt，exp 7d）。service 层强制 `role='PATIENT'`——B 端账号用同凭据登录返回 401，防串面。

##### 2 触发条件与前置状态

- `POST /pub/v1/login` 挂 `/pub` 限流组、不挂 JWT（同 guest-session 先例：登录即取 token 入口）。
- 账号前置：`role='PATIENT'` 且 `status='ACTIVE'`（注册事务链 F4.01 创建）。

##### 3 输入与校验

| 字段 | 类型 | 校验 |
|---|---|---|
| email | string | `ValidateLogin` 纯函数（非空+格式）；`NormalizeEmail` 归一化 |
| password | string | 非空；bcrypt 比对 |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant C as 患者 H5
    participant H as patient.Handler
    participant S as patient.Service
    participant DB as MySQL
    C->>H: POST /pub/v1/login (email, password)
    H->>S: Login(ctx, email, password)
    S->>S: ValidateLogin（纯函数校验）
    S->>DB: SELECT * FROM user_account WHERE email=?
    alt 不存在 / bcrypt 失败 / role≠PATIENT / 非 ACTIVE
        S-->>H: auth.ErrInvalidCredentials → 401
    end
    S->>DB: UPDATE last_login_at（失败仅 warn）
    S->>S: IssuePT(uid)（iss=pt, 7d）
    H-->>C: 200 {token, expires_in:604800, patient_id}
```

##### 5 输出与结果状态

`{token, expires_in: 604800, patient_id}`；`last_login_at` 更新。前端双存 `medilink-pt-token` + cookie 下划线变体（⚠ DIF-M4 ④），旧 guest token 语义作废（app 按 hasPtToken 切 /pt 面 + 服务端绑定守卫双闸）。

##### 6 状态流转

无状态变更（user_account.status 不动；禁用走 F10.01）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| B 端账号凭据正确但 role≠PATIENT | 401（三条件与 bcrypt 同一表达式，日志不区分） |
| 账号被禁用 | 401 |
| email 格式非法 | 400（ValidateLogin） |
| /pub 限流窗口内超 60 次 | 429（F1.06，登录入口共享限流预算） |

##### 8 权限与数据规则

- 无 JWT 即可调（挂 /pub 限流组）；密码 bcrypt；统一 401 防枚举。
- pt token 无 role/pv claim（issuerProfiles 仅要求 Sub>0）——患者权限由 `/pt` 面物理隔离 + service 谓词保证。

---

#### F1.03 访客会话签发 guest-session

> 对应 SRS：—（获客漏斗前置件，⚠ DIF-M2 ① 提前至 M2） ｜ 实现落点：`internal/gateway/guest.go:16`、`cmd/server/main.go:106` ｜ 操作入口：app H5 自动调用（打开即签发）；API curl

##### 1 功能定义

匿名访客自助签发 guest JWT（claim `gsid` = 16 字节随机数 hex 32 位，exp 7d，无 DB 写入）。`/pub` 面的 bootstrap 端点：先有 token 才能调任何受保护 /pub 路由（医院列表/聊天/注册）。

##### 2 触发条件与前置状态

任意时刻可调；`POST /pub/v1/guest-session` 挂限流组、不挂 JWT。无前置状态。

##### 3 输入与校验

无请求体。

##### 4 处理流程

```mermaid
sequenceDiagram
    participant C as 访客浏览器
    participant G as gateway.GuestSession
    participant SG as gateway.Signer
    C->>G: POST /pub/v1/guest-session
    G->>G: crypto/rand 16B → hex 32 位 gsid
    G->>SG: IssueGuest(gsid)（iss=guest, 7d）
    alt jwt.secret 未配置
        SG-->>G: ErrNotConfigured → 503
    end
    G-->>C: 200 {token, expires_in:604800}
```

##### 5 输出与结果状态

`{token, expires_in: 604800}`。前端 localStorage + cookie 双存（抗 webview 清理）；gsid 后续作为 `chat_session` 绑定键（F3.01）与病历文档所有权锚（F4.02）。

##### 6 状态流转

无状态变更（无 DB 写入；gsid 与 chat_session 的关联在建会话时落库）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| jwt.secret 未配置 | 503（功能降级可见；401 语义保留给验签失败不混用） |
| 随机数源失败 | 500 |
| 重复调用 | 签发全新 gsid/token（旧 token 仍有效至过期——访客多标签页各持各的会话） |

##### 8 权限与数据规则

- 匿名可调（/pub bootstrap）；不绑 IP/UA（移动网络切换是常态，绑了是假安全——安全边界在 service 层会话隔离 + 限流）。
- gsid 全局唯一由 16B 随机保证（碰撞概率可忽略）。

---

#### F1.04 JWT 五面鉴权与路由矩阵

> 对应 SRS：§4.2 ｜ 实现落点：`internal/gateway/jwt.go:69`（issuerProfiles）、`:133`（Verify）；中间件挂载 `cmd/server/main.go:93-324` ｜ 操作入口：—（基础设施，无 UI）

##### 1 功能定义

一服务五面路由（`/api` `/pt` `/pub` `/webhooks` SSE），四种 issuer JWT（bo/pt/guest/mfa）互不认；组级 `RequireJWT(iss)` 中间件把 issuer 绑定到整面，SSE 端点用 `RequireJWTQuery`（query `access_token` 兜底、header 优先——EventSource 无法带 Authorization 头）。

##### 2 触发条件与前置状态

受保护面每个请求。`/webhooks` 面不挂 JWT（供应商验签即鉴权，F7.02）。

##### 3 输入与校验

| 面 | issuer | TTL | 必填 claim（结构性校验） |
|---|---|---|---|
| `/api` | bo | 12h | Sub>0 ∧ Role≠"" ∧ PV>0 |
| `/pt` | pt | 7d | Sub>0 |
| `/pub` | guest | 7d | GSID≠"" |
| mfa_token | mfa | 5min | Sub>0（无 role/pv——结构性过不了 bo 面必填校验） |

签名 HMAC-SHA256 自实现（~60 行，无算法降级风险）；issuer 不匹配一律拒（`ErrIssuerMismatch`）。

##### 4 处理流程

```mermaid
flowchart LR
    REQ[请求] --> MW{组级 RequireJWT}
    MW -->|缺头/坏 token/issuer 不符/claim 缺失| R401[401]
    MW -->|通过| CLAIMS[Claims 注入 context]
    CLAIMS --> NEXT[业务 handler]
    SSE[SSE 请求] --> Q{RequireJWTQuery<br/>header 优先 query 兜底}
    Q -->|通过| CLAIMS
```

##### 5 输出与结果状态

通过后 claims 经 `gateway.ClaimsFrom(c)` 注入 handler context；失败统一 401 → 前端登出跳登录。

##### 6 状态流转

无状态变更。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| jwt.secret 未配置 | `NewSigner(nil)` 起服成功，受保护面 Verify 全部 ErrNotConfigured→401（可选缺省模式） |
| mfa_token 打 bo 面 | 401（无 Role/PV claim 过不了必填校验——构造保证非约定） |
| 过期 token | 401 |
| SSE 重连带 Last-Event-ID | 鉴权同普通请求（query token 每次重连重新携带） |

##### 8 权限与数据规则

- 鉴权（Authentication）与本册 F1.05 授权（Authorization）分离：本面只验「你是谁」，权限码验「你能做什么」。
- 患者 JWT 7d vs B 端 12h：患者低频回访长时效换体验；B 端内部权限面短时效控风险。

---

#### F1.05 RBAC 权限码字典与路由守卫

> 对应 SRS：§4.2 ｜ 实现落点：`internal/gateway/rbac.go:82`（RequirePermission）、`rbac_db.go`（NewDBPermissionSource）、`scripts/dev/seed.sql:103-141`（字典与授予） ｜ 操作入口：—（基础设施）；web 菜单显隐消费登录快照

##### 1 功能定义

路由级权限码守卫：`RequirePermission(code, ...)` 中间件按角色从字典表（`permission` + `role_permission`）查权限集，角色不含该码即 403。redis 60s 缓存加速；DB 错误 fail-closed 返回空集（宁可误杀不可错放）。

##### 2 触发条件与前置状态

`/api` 面业务路由逐条挂载（模式 C，main.go 全景）；未挂守卫的端点 = 登录即可（如 `/api/inbox` 通知消费——按角色谓词隔离，无权限码）。

##### 3 输入与校验

权限码全集 14 个（语义与角色矩阵见总则 §2.1）；JWT claims 携带 `pv`（权限版本）参与缓存键隔离。

##### 4 处理流程

```mermaid
flowchart LR
    REQ[请求] --> JWT[RequireJWT bo 已通过]
    JWT --> CACHE{redis rbac:db:perm:role:pv}
    CACHE -->|命中| CHECK
    CACHE -->|未命中| DB[(字典表直查)] --> SET[回写缓存 TTL 60s] --> CHECK
    DB -->|DB 错误| FAILCLOSED[返回空集 fail-closed] --> R403[403]
    CHECK{role 含 code?} -->|否| R403
    CHECK -->|是| NEXT[handler]
```

##### 5 输出与结果状态

放行或 403；权限快照在登录响应透出（`permissions[]`），web 端 `authz.ts` 纯函数 `has(code)` 做菜单/按钮显隐——权限漂移靠重新登录刷新（PV 不变，⚠ DIF-M8 ⑤）。

##### 6 状态流转

无业务状态变更；字典表变更仅经 seed（幂等 INSERT IGNORE）或人工 SQL，重启/缓存过期后生效。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| redis 不可用 | 直查 DB（不降级放行也不误杀） |
| DB 查询错误 | fail-closed 空集 → 403 |
| 权限变更后旧 token | 60s 内可能命中旧缓存；登录快照不变（重新登录刷新） |
| 新增权限码 | seed 重跑（INSERT IGNORE 幂等）+ 重启即生效 |

##### 8 权限与数据规则

- PATIENT 角色不走权限码表（/pt 面物理隔离 + service 谓词）；权限码只管 B 端三角色。
- 静态源 `NewStaticPermissionSource` 保留作 parity 测试基线（⚠ DIF-M4 ⑦）。

---

#### F1.06 /pub IP 限流（内存/Redis 双内核）

> 对应 SRS：§4.1（匿名面防滥用） ｜ 实现落点：`internal/gateway/ratelimit.go:34`（内存内核）、`ratelimit_redis.go`（Redis 内核）、`cmd/server/main.go:100-105`（装配） ｜ 操作入口：—（基础设施）

##### 1 功能定义

`/pub` 面组级 IP 限流：固定窗口 60 次/分钟，超限 429。双内核：单实例内存实现（dev 缺省，假时钟可注入测试）与 Redis 实现（键 `rl:<库名>:<ip>:<bucket>`，dev/e2e 同实例互不串扰）。挂在限流先于验签——洪水不消耗 HMAC。

##### 2 触发条件与前置状态

`/pub` 面所有请求（含 guest-session bootstrap）。装配：redis 可用→Redis 内核（fail-open），否则内存内核。

##### 3 输入与校验

限流键 = 客户端 IP；窗口 60s；上限 60 次。

##### 4 处理流程

```mermaid
flowchart LR
    REQ[请求 /pub/*] --> RL{RateLimit 中间件<br/>先于 RequireJWT}
    RL -->|Allow=true| NEXT[JWT 验签 → handler]
    RL -->|Allow=false| R429[429]
```

##### 5 输出与结果状态

放行或 429 Too Many Requests。

##### 6 状态流转

无业务状态变更（计数窗口内存/redis 状态）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| redis 错误 | fail-open 放行 + Warn（限流是防护不是业务，可用性优先——与 TOTP 防重放同哲学） |
| dev/e2e 同 redis 实例 | 键带库名命名空间隔离（⚠ DIF-M3 ⑧）；e2e 限流段放最后 + 冒烟前清 `rl:*` |
| 代理后真实 IP | 取 echo 客户端 IP（MVP 单 nginx 直连部署无 XFF 链问题） |

##### 8 权限与数据规则

- 上限值上线前压测定（tech-design §15 风险 9：刷聊天气费翻译费）；软上限（每会话 50 条消息提示注册）在 chat 域 F3.03。

---

反链：

- [功能规格说明书(FSD)](/prd/fsd-medilink/)
- [MediLink Global 功能规格说明书（FSD）v1.0 · 总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/)
