Files

22 KiB
Raw Permalink Blame History

文件读取与上传签名接入及发布

本次保护 /admin/sys-file/oss/file 的全部文件。默认 observe 只记录签名校验结果;只有加载完整配置并切到 enforce 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。

上传入口 /admin/sys-file/upload、/admin/sys-file/inner-upload、/admin/sys-file/qn/upload 同步使用应用签名和服务端文件白名单。上传复用这套配置和密钥,不增加环境密钥组;读取签名不能用于上传。

配置准备

服务端配置见 config.example.yml。密钥为至少 32 字节随机数据的 Base64 字符串,HMAC 使用 Base64 解码后的字节。不要复用 OAuth 客户端信息或密码加密密钥。

在 pj_service_cloud 执行以下命令可为并列的五个项目生成匹配的本地配置:

python3 scripts/generate-file-access-config.py \
  --public-base-url https://www.pjzhhq.cn/api \
  --allowed-origin https://stgl.pj.gov.cn

按用户最终要求,所有环境共用一套密钥配置,admin/h5/mini/flutter/service/package 各自保留固定密钥;开发、测试、生产不再另建密钥组。只有公网基础地址等部署参数随目标环境填写。脚本统一读取 .file-access/keys.json,重复执行不会更换密钥;旧命令的 --environment 参数仍接受,但不再切换密钥。

迁移时沿用原生产密钥组,避免已构建客户端失效。脚本只更新受管理的文件访问配置字段,保留其他业务环境变量;移除旧 Vite 模式文件中的文件访问字段,避免其覆盖公共配置。旧文件备份位于 Java 项目的 .file-access/migration-backup/,不参与构建。当前活动配置均被 Git 忽略:

首次生成只做一次,其他构建机器和 CI 同步这套现有配置。密钥统一后无需随部署环境重新生成。

项目 本地文件 使用方式
Java .file-access/config.yml Nacos 共享配置或 Spring 外部配置
后台 .env.local 所有 Vite 构建模式共用
H5 和小程序 各项目的 .env.local 所有 Vite 构建模式共用
Flutter .file-access/config.json 所有构建使用 --dart-define-from-file=.file-access/config.json

前端的配置项为 VITE_FILE_ACCESS_APP_ID、VITE_FILE_ACCESS_KEY_ID、VITE_FILE_ACCESS_KEY、VITE_FILE_ACCESS_ALLOWED_ORIGINS。Flutter 去掉 VITE_ 前缀。Java 的 clients 包含 admin/h5/mini/flutter/service/package;service 和 package 密钥只放服务端。

自动构建不会取得被 Git 忽略的 .env.local。 后台的 build、build:prod、build:docker 统一使用 --mode prod,自动工具执行 npm run build / pnpm build 时读取 .env.prod。自动构建应从已提交的 .env.prod 或流水线环境变量取得同一组 admin 配置,继续使用既有密钥;不要为流水线重新生成密钥。缺少必要配置时构建会失败,发布流程应停止,不能继续部署上一次留下的 dist。

H5 使用 uni-app 的 production 模式,自动构建配置为该项目的 .env.production,其中 appId 为 h5,复用既有 H5 密钥。build、build:h5 显式使用 uni build -p h5 --mode production;Dockerfile 的 pnpm build:h5 同样读取该文件,默认产物目录为 dist/build/h5。自动构建所需的新配置文件须与源码一起提交;本地 .env.local 仍可用于开发,不作为 CI 输入。

public-base-url 是外部可访问的网关基础地址,可以带 /api,不含 /admin/sys-file/oss/file。现有的图片/API 基础地址继续由各端原有配置决定;allowed-origins / ALLOWED_ORIGINS 只列确实属于本系统的历史域名。上线前必须逐环境核对域名、基础路径、appId、kid 和密钥。相对 /api/admin/sys-file/oss/file 按当前文件基础地址解析,绝对历史地址保留其原有域名和部署路径。

Java 不会自动读取仓库里的 .file-access。 本地运行可以设置:

export SPRING_CONFIG_ADDITIONAL_LOCATION=file:/absolute/path/pj_service_cloud/.file-access/config.yml

通过现有 Nacos 配置体系加载这套 file-access 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 observe,已有配置保留当前模式;显式 --mode 可更新本地生成配置的模式。先检查配置源优先级;config.example.yml 中的环境变量占位符不能代替实际密钥。严格模式要求 admin/mini/flutter/service/package 的有效活动密钥及公网基础地址。h5 条目可整体省略;一旦配置该条目,密钥也必须完整。未配置 H5 不会自动放行 H5 请求,只有下面明确配置的图片来源可免签。运行中错误配置返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 package/service 密钥。

协议

请求查询参数为 appId、kid、ts、sign、fileName。签名原文按 LF 连接,无末尾换行:

v1
oss-file-read
appId
kid
ts
Base64URL无填充编码的UTF-8文件名

ts 是 Unix 秒;sign 是 HMAC-SHA256 的 64 位小写十六进制。fileName 只 URL 解码一次,始终放在最后。后端生成的 APK URL 因此仍以 .apk 结尾,兼容旧终端的扩展名解析。

默认普通签名有效 1800 秒,package 有效 86400 秒,允许签发时间领先最多 300 秒。GET /admin/sys-file/oss/access-info 匿名返回 R.data.serverTime(毫秒)和 ttlSeconds,不包含密钥。各端校准时差并在签名剩余不足 5 分钟时续期;校时失败保留上次时差,不能退回裸链接。传输 URL 缓存在内存里,普通渲染不会每次产生新签名。

重复关键参数、缺少字段、非规范时间戳或非规范签名为 400;未知客户端/密钥、错误签名、过期、时钟越界为 403;文件不存在为 404,数据库或存储读取失败为 500。GET 和 HEAD 共用校验。普通登录 Token 不会绕过校验。文件响应最多私有缓存 5 分钟且不超过剩余签名寿命;错误响应禁止缓存。已经输出部分字节后的网络/存储中断无法可靠改写 HTTP 状态,应按中断响应处理。

block-browser-navigation 默认为 true。严格模式下,浏览器携带 Sec-Fetch-Mode: navigate 或 Sec-Fetch-Dest: document/iframe/frame 的文件请求返回 403,即使签名正确也不放行;覆盖地址栏粘贴、新窗口打开和直接嵌入文件页面。图片、音视频以及 fetch/XHR 下载继续校验签名后访问。响应的 Vary 包含 Sec-Fetch-Mode 和 Sec-Fetch-Dest,防止浏览器把图片缓存复用为文件页面。观察模式仅记录 browser_navigation,不拦截。

后台会截获本系统文件 <a> 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。

旧 mobile H5 的图片来源兼容

旧 H5 无需重新构建或更新。在更新后的后端配置中增加:

file-access:
  public-image-referer-prefixes:
    - https://www.pjzhhq.cn/mobile/
    - https://yyfb.idw.jinhua.gov.cn:6443/

只对 GET/HEAD 图片读取生效:请求带有单个 Referer,其协议、主机、有效端口与配置匹配时,按以下两种范围判断:

  • 带目录的配置(如 /mobile/)仍要求 Referer 路径位于该目录下,目录必须以 / 结尾。
  • 根来源配置(如 https://yyfb.idw.jinhua.gov.cn:6443/,末尾 / 可省略)允许该来源下所有页面,包括浏览器跨域策略只发送根来源的请求。端口必须匹配;未写端口的 HTTPS 表示 443,不会匹配 6443。根来源不能再限制到某个应用目录。

命中后,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 #/ 不会随 HTTP Referer 发送,也不能写进配置。跨域策略如果省略路径,单纯配置长目录不会自动退回根来源匹配。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。

这是独立的来源白名单,保留或省略 clients.h5 都可使用;不能仅根据 appId=h5 放行。新版本允许省略整个 H5 客户端配置,但空密钥、错误密钥配置仍报错,其他五类客户端的必要配置保持严格检查。若删除共享配置中的 H5 条目,所有加载旧版公共配置类的服务也需要同步更新;保留现有 H5 配置同样可以启用来源兼容。

配置默认空列表;显式清空列表即可恢复图片签名要求。必须部署新 UPMS 和对应的公共依赖后再启用配置,不能用旧后端只删除 H5 密钥。Referer 必须由浏览器和代理保留;没有 Referer 的匿名请求仍返回 403。Referer 可被脚本模拟,这是一层兼容性的来源检查。成功响应私有缓存最多 5 分钟,Vary 额外区分 Referer,代理不得忽略该差异复用放行响应。

根来源支持需要新版 pjcloud-common-core 和 UPMS 一起构建发布。建议将这份图片来源白名单放在 UPMS 自己的配置中;若通过共享配置下发给其他启用文件访问配置的服务,这些服务也必须先更新公共依赖,旧版校验类会拒绝根来源。此改动不要求更新 H5 或更换任何密钥。

上传身份与文件类型

现有登录鉴权及 @Inner 内部调用限制保留。每次 POST multipart 上传只允许一个名为 file 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段:

请求头 内容
X-File-App-Id admin/h5/mini/flutter/service,package 不可上传
X-File-Key-Id 现有 kid,如 v1
X-File-Timestamp Unix 秒级时间戳
X-File-Nonce 16~64 位字母、数字、_、-,每次请求重新生成
X-File-Signature HMAC-SHA256 的 64 位小写十六进制

签名原文按 LF 连接,无末尾换行;密钥仍使用 Base64 解码后的字节:

v1
oss-file-upload
POST
target
appId
kid
ts
nonce

target 分别为 upload、inner-upload、qn-upload,三种签名不能互换。inner-upload 仅接受 service。签名用于应用身份和本次上传操作校验,文件内容由服务端独立检查。默认有效 300 秒,允许客户端时间领先最多 60 秒;复用现有匿名校时接口。严格模式下 Redis 原子 SET NX 消耗 nonce,重复请求返回 403;Redis 或配置不可用返回 500,不自动放行。不增加 Dart UA 上传兼容。

file-access.upload 默认策略如下,省略配置时 Java 使用相同默认值:

文件类别 白名单后缀
图片 jpg/jpeg/png/gif/webp/bmp/ico/svg
文档和安装包 pdf/apk/doc/docx/xls/xlsx/ppt/pptx
文本和压缩包 txt/csv/zip
音视频 mp4/mov/webm/avi/mp3/wav/m4a/aac/ogg/amr/flac
仅内部上传额外允许 log/gz,供终端日志使用

默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。

JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 .png 的合法 WebP 保存为 .webp、MIME 为 image/webp。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。

缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 observe 下仍执行,观察模式只放宽应用签名准入。监控为 file.upload.requests,标签 outcome/app/mode,不记录密钥、签名和登录 Token。

后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 uni.uploadFile 拦截器,以及 Flutter HttpManage 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。RemoteFileService 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。

已有读取严格模式的环境,先发布客户端及调用文件服务的 Java 服务,再发布 UPMS。 旧 UPMS 忽略新增头;新版 UPMS 在 enforce 下会拒绝没有上传签名的旧网页/服务请求。Java 调用方须随公共依赖一同更新,避免只升级 UPMS。旧 Flutter 当前通过硬件业务接口上传取证和日志,再由 Java 调用文件中心,这条链路无需旧设备立即升级;不要把这一结论扩展到其他直接调用文件中心的旧客户端。必要时切回 observe 恢复旧身份请求,文件白名单仍然生效。

入口与数据约束

部分 接入位置与行为
Java 文件入口 FileAccessGuard,SysFileController,SysFileServiceImpl;租户跳过只作用于文件查询并恢复原状态
Java 内部读取 RemoteFileService.getFile 通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 %xx;钱包、硬件导出/日志/取证及发票盖章通过相同签名读取
APK 终端 /app-version/ad/page 和 /app/last-version 返回对象使用 package 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变
后台 src/utils/file-access*.ts;FileImage 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址
后台编辑器 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史
H5 和小程序 src/sheep/utils/file-access*.ts、filePath/parseHtml、共享预览下载工具及各页面资源属性;公共算法不依赖 DOM/window;富文本图片样式保留
Flutter FileAccess、AppNetworkTool、SignedNetworkImage;主副屏独立初始化,缓存键用规范化原始地址,传输 URL 用签名
Flutter APK 重试 原生下载收到 403 时重新查询相同 ID、版本码、平台和应用的已发布版本,最多重试一次,不能换成另一个版本继续安装

业务对象、表单、上传返回值、SQLite 人脸 imagePath、失败记录和 referenceImageUrl 继续保留原始引用。本次没有数据库迁移、SQLite 升级或清空人脸库。新业务展示统一调用文件工具,不要在 API 响应拦截器里递归改写业务字符串。

第三方 URL、静态资源、data:、blob:、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。

Java 图片合成等内部读取

文件中心图片使用 RemoteFileService.getFile(fileName),每次调用由专属 Feign 拦截器生成 service 签名,并通过服务发现访问 UPMS。业务字段如果保存的是完整或相对文件链接,先用 FileAccessUrl.fileName(source) 提取原始文件名:

String fileName = FileAccessUrl.fileName(source);
if (fileName == null) throw new IllegalArgumentException("不是文件中心引用");
try (Response response = remoteFileService.getFile(fileName)) {
    if (response.status() < 200 || response.status() >= 300 || response.body() == null) {
        throw new IOException("文件读取失败");
    }
    try (InputStream input = response.body().asInputStream()) {
        BufferedImage image = ImageIO.read(input);
        // 校验 image 非空后合成。
    }
}

提取工具支持原始文件名、/sys-file/oss/file、/admin/sys-file/oss/file 及带网关前缀的 /admin/sys-file/oss/file 链接;查询中的文件名只解码一次,重复、缺失或非法文件名拒绝处理。链接中的主机和旧签名不参与内部调用,始终读取当前环境的 UPMS,不会向原链接主机发送 service 凭据。

WalletRechargeInvoiceServiceImpl.stamp 已按此方式读取底图。因此 http://127.0.0.1:9999/admin/sys-file/oss/file?...、历史域名链接和相对链接都不再依赖 IMAGE_BASE_URL 或公网来源列表。非文件中心图片保留原有 HTTP 读取方式,相对静态资源仍使用 IMAGE_BASE_URL。调用方和 UPMS 必须加载相同的 clients.service 活动密钥;本次修复需要重新构建并发布 Wallet 及更新后的 common-core,无需为本机地址扩大 allowed-origins。

旧终端兼容只接受完整 Dart/<版本> (dart:io) UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。

发布和回滚

  1. 在配置中心准备匹配的环境配置,保持 mode: observe 和 legacy-dart.enabled: true。发布 UPMS、integrated,以及包含文件调用的 wallet、hardware、canteen 等服务和统一公共依赖。只更新 UPMS 会遗漏内部读取。
  2. 发布后台、H5、小程序,并提供携带生产配置的新 Flutter 包。小程序保留原文件域名,真机检查 request/downloadFile 域名配置、图片预览和打开文档。
  3. 至少观察一个完整业务日,确认登录前 Logo/背景、人员照片、商品/菜品、富文本、附件下载、导出、票据和旧设备更新链路。旧设备必须实测图片 UA 和 Android 原生 APK 下载。
  4. 通过验收后设置 mode: enforce。按当前配置中心机制刷新或重启实例,逐个确认配置已生效;旧网页需刷新,小程序需加载新版本。
  5. 根据设备版本台账确认覆盖后再关 legacy-dart.enabled。没有自动失效日,短期没有旧请求不能证明设备已全部升级。
  6. 遗漏影响业务时切回 observe 并保留诊断;这会重新允许普通未签名请求。无需数据库回滚,也无需四端同时回退。

代理/CDN 必须把文件路径送到后端校验,保留完整查询参数,不得忽略查询参数缓存文件、强制公共缓存或通过其他公开存储入口绕过该接口。校时接口也必须匿名可达。不要在访问日志中记录完整签名 URL、密钥或登录 Token;可按路径、来源类别和结果计数。

监控 file.access.requests(Prometheus 通常呈现为 file_access_requests_total),标签 outcome/app/mode。结果包括 signed、missing_signature、malformed_signature、invalid_signature、expired、clock_skew、legacy_dart、unknown_client、unknown_key、configuration_error 等。文件查询/存储失败另计 file.access.read.failures。拒绝/兼容日志按结果采样,计数器保留完整次数。

轮换时先在服务端增加新 kid,同时保留旧 kid,再更新客户端构建;服务端主动签发使用 active-kid。Flutter 的旧 kid 需等设备覆盖后才移除。不要用重新运行生成脚本代替轮换流程。

本地验证命令

三个前端分别执行:

node --test tests/file-access.test.cjs tests/file-upload.test.cjs

后台执行 pnpm build:prod,H5 执行 pnpm build:h5,小程序执行 pnpm build:mp-weixin。跨语言固定向量在各项目 test-vectors/file-access.json 和 test-vectors/file-upload.json,其中的密钥是公开测试数据,不能用于部署。配置生成器回归执行 python3 -m unittest discover -s scripts/tests。

Java 定向回归:

mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \
  -am test \
  -Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,FileAccessUrlTest,WalletRechargeInvoiceFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \
  -Dsurefire.failIfNoSpecifiedTests=false

Flutter 使用项目 FVM 3.27.4:

fvm flutter test --no-pub --dart-define-from-file=.file-access/config.json \
  test/file_access_test.dart test/file_upload_test.dart test/package_download_test.dart \
  test/face_sync_plan_test.dart test/face_sync_pipeline_test.dart \
  test/face_sync_failure_store_test.dart test/face_readiness_cache_test.dart \
  test/customer_display_balance_test.dart test/customer_display_balance_widget_test.dart
fvm flutter build apk --release --no-pub --dart-define-from-file=.file-access/config.json

验收结果与待现场验证项见 verification.md。不要把本地构建成功或源码中的 Dart UA 推断视为旧安装包验收。