FSD m01 · 账号认证与权限(auth + gateway)
本册覆盖代码域
internal/domain/auth与横切包internal/gateway。全局规则见 00-总则 §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 输入与校验
| 字段 | 端点 | 类型 | 校验 |
|---|---|---|---|
| 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 处理流程
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 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| string | ValidateLogin 纯函数(非空+格式);NormalizeEmail 归一化 | |
| password | string | 非空;bcrypt 比对 |
4 处理流程
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 处理流程
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 处理流程
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 处理流程
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 处理流程
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。