跳转到主要内容

FSD m01 · 账号认证与权限(auth + gateway)

本册覆盖代码域 internal/domain/auth 与横切包 internal/gateway。全局规则见 00-总则 §2(权限矩阵 §2.1、状态机 §2.2、一致性 §2.3)。

m01 功能节目录

ID名称路由/入口
F1.01B 端两段式登录(密码+TOTP)POST /api/auth/login + POST /api/auth/mfa
F1.02患者登录POST /pub/v1/login
F1.03访客会话签发 guest-sessionPOST /pub/v1/guest-session
F1.04JWT 五面鉴权与路由矩阵全部路由面(中间件)
F1.05RBAC 权限码字典与路由守卫/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 输入与校验
字段端点类型校验
emailloginstring非空;NormalizeEmail 归一化(trim+lower)后查询
passwordloginstring非空;bcrypt 比对
mfa_tokenmfastring非空;Verify(token, IssuerMFA) 验签+issuer+exp
codemfastring非空;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 输入与校验
字段类型校验
emailstringValidateLogin 纯函数(非空+格式);NormalizeEmail 归一化
passwordstring非空;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≠PATIENT401(三条件与 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 输入与校验
面issuerTTL必填 claim(结构性校验)
/apibo12hSub>0 ∧ Role≠"" ∧ PV>0
/ptpt7dSub>0
/pubguest7dGSID≠""
mfa_tokenmfa5minSub>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 过不了必填校验——构造保证非约定)
过期 token401
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
权限变更后旧 token60s 内可能命中旧缓存;登录快照不变(重新登录刷新)
新增权限码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。