15 KiB
财务系统店铺密码直接显示契约
状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 passwordConfigured。
存储事实与授权边界
- 密码真相源是
public."StoreAccount"."passwordCiphertext"。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史v1:AES-256-GCM 值保留兼容。 - 存量
v1:值已由scripts/migrate-store-account-plaintext-20260908.js迁移。STORE_ACCOUNT_ENCRYPTION_KEY仅用于可能残留或恢复出的历史v1:值,不参与当前明文值读取。 passwordConfigured只表示存储值是否非空,不校验字符集或密码强度;因此包含+、/、=等字符的密码也会正确返回 true。它不返回密码本身。- 普通
store_account_reader可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。 - 本接口只信任专用服务身份
finance-store-accounts。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取requesterId。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。 - 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取
passwordConfigured。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,serviceId=null。
HTTP 契约
生产配置完成后的地址:
POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal
POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal
id 为 accounts_v1.id,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
请求头:
Authorization: Bearer <专用服务令牌>
Content-Type: application/json
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
{ "requesterId": "finance-user-123", "purpose": "view" }
purpose 仅允许 view 或 copy。requesterId 是财务会话中的稳定人员标识,1–128 位 [A-Za-z0-9_.:@-]。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
当前页批量请求严格只接受以下字段,正文最大 8192 字节;accountIds 为 1 至 50 个互不重复且符合单账号 ID 规则的值:
{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] }
成功 200:
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
当前页批量成功的 HTTP 状态为 200,每个账号分别返回 200/404/429/503。每项都有独立 UUID;只有状态 200 的项包含密码,并按请求顺序完整返回:
{
"requestId": "<批次 UUID>",
"items": [
{ "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" },
{ "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 }
]
}
所有状态均返回 Cache-Control: no-store、Pragma: no-cache、X-Content-Type-Options: nosniff。X-Request-Id 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
错误体:
{ "requestId": "<UUID>", "error": { "code": "FORBIDDEN", "message": "请求被拒绝" } }
| HTTP | code | 含义 |
|---|---|---|
| 403 | FORBIDDEN | 服务认证失败、非 HTTPS、非法 ID/JSON/用途、额外参数或非 POST |
| 404 | PASSWORD_NOT_FOUND | 账号不存在或没有可用密码,两者不区分 |
| 429 | RATE_LIMITED | 任一限流窗口超限 |
| 503 | UNAVAILABLE | 密钥/解密、数据库、限流或审计不可用;不返回内部异常 |
审计或数据库故障优先返回 503,绝不在无法持久化成功审计时返回密码。数据库完全不可用时无法保存该次失败审计,由网关仅记录追踪 ID、状态与时间供运维关联。成功审计表示服务器已授权并生成响应,不等同于浏览器已经复制成功。
限流和超时
- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。公网 TLS 网关保留 1 KiB 敏感请求正文上限,财务端把 50 行当前页拆成两个最多 25 个账号的批次;批次标记因此按服务 20 次/分钟、1000 次/24 小时、同一人员 10 次/分钟限流,等价于原每页额度。批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
- 入口网关还需按来源 IP 对全部请求(包括未授权请求)限制为 60 次/分钟,返回 429 JSON 和
no-store;这项入口防滥用配置属于上线门禁。应用层数据库限流负责已认证的敏感读取。
环境变量(仅名称及要求)
| 名称 | 所在系统与要求 |
|---|---|
| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 v1: 密文,至少 32 字符;生产不回退到 JWT_SECRET |
| STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 |
| STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 |
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 visible-page 路径 |
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
HTTPS、日志和缓存上线门禁
- 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
- 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖
X-Forwarded-Proto: https及X-Store-Reveal-Edge-Token(值来自独立STORE_ACCOUNT_REVEAL_EDGE_TOKEN),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过nginx -T等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。 - 对精确路径
/internal/store-accounts/*/password/reveal禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。 - 网关自己的 403/404/429/503 也使用脱敏 JSON、
Cache-Control: no-store和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用withApi/request logger;目前路由直接返回白名单响应,不记录异常对象。 - 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。
- 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
视图与审计迁移
初始迁移:prisma/migrations/20260908120000_store_account_password_reveal/migration.sql。明文状态修复:prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql。
- 新增
StoreAccountPasswordAccessAudit,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。 - 只在原视图存在时,以原视图查询作为子查询,在末尾追加
passwordConfigured boolean,保留原字段、筛选、每行内容、ID、所有者与 ACL;EXISTS不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。 passwordConfigured的当前定义为passwordCiphertext <> ''。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
备份与执行顺序(生产必须在明确批准后执行):
- 备份当前
main/部署 SHA、现有视图 DDL/ACL/所有者及_prisma_migrations状态。生成目标库pg_dump -Fc备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。 - 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。
- 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。
- 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行
scripts/sql/check-store-account-password-reveal.sql,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。 - 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
回滚:先移除财务令牌阻止新调用并回退应用 SHA。新增状态列与审计表可安全保留以避免丢失审计;旧应用不依赖它们。需要完整结构回退时,经 DBA 检查依赖后,在事务中以备份 DDL 重建旧视图并恢复原所有者/ACL(PostgreSQL 不允许 CREATE OR REPLACE VIEW 删除尾列,禁止随意 DROP ... CASCADE)。审计记录保留,不直接删表;按项目规则处理已应用迁移历史,禁止直接删除 _prisma_migrations 记录或用 db push。任何完整结构回退先在隔离库演练。
密钥轮换
服务令牌:在受限凭据系统生成新随机令牌;旧值暂放 STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS,新值放主变量,重启上游;更新财务后端,使用虚构账号验收;移除旧值并重启。两值对应同一服务身份,审计/额度不变。怀疑泄露时立即撤销旧值,宁可短暂拒绝请求。
网关凭据:暂停此接口,在网关及上游同时更新 STORE_ACCOUNT_REVEAL_EDGE_TOKEN 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 v1: 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。
本次验证记录
- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;
npm run type-check通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。 - 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。
- 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描
docker logs;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。 - Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。
- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。
运行:npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。
收口:passed with warnings。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。