文件访问不能只返回 URL:Forge Admin 鉴权图片和文件存储设计

关键词:文件鉴权 / AuthImage / 私有文件 / 对象存储 / 预签名 URL


一、问题场景:你的"私有"文件其实人人都能看到

来看一段最常见的文件上传代码:

less 复制代码
@PostMapping("/upload")
public RespInfo<String> upload(@RequestParam MultipartFile file) {
    String url = ossClient.upload("avatars/" + file.getOriginalFilename(), file.getInputStream());
    return RespInfo.success(url);
    // 返回: https://your-bucket.oss-cn-hangzhou.aliyuncs.com/avatars/身份证照片.jpg
}

前端拿着这个 URL 直接塞进 <img src="...">,页面完美展示。然后你打开浏览器无痕窗口,把 URL 粘贴到地址栏------图片照样打开。

问题出在哪?浏览器请求 <img> 标签时不会携带自定义认证头。你的 JWT Token、Session Cookie 对这张图片完全无效。任何人拿到 URL 都能直接访问。

更糟糕的是:

  • CDN 会缓存这张图片,加速泄露
  • 搜索引擎可能索引到 OSS 直链
  • 离职员工的浏览器历史里永远躺着这些 URL

这不是 OSS 的问题,是你把权限控制的责任推给了传输层 。Forge Admin 的 forge-starter-file 模块的核心设计理念就是:文件访问和接口访问一样,必须经过鉴权


二、解决方案:AuthImage 鉴权图片组件

2.1 直接返回 URL 为什么危险

sql 复制代码
┌──────────┐   GET /api/file/info/123    ┌──────────┐
│  前端     │ ──────────────────────────→ │  后端     │
│          │ ←── { "url": "https://..." } │          │
└────┬─────┘                              └──────────┘
     │
     │  <img src="https://oss.com/private/身份证.jpg">
     ▼
┌──────────┐                               ┌──────────┐
│  浏览器   │ ──── HTTP GET(无 Cookie ────→ │   OSS    │
│          │       无 Authorization 头)     │          │
│          │ ←─── 200 OK,图片正常返回 ──── │          │
└──────────┘                               └──────────┘

因为浏览器发起 <img src> 请求时,只会自动携带同域 Cookie。跨域的 OSS 请求不会被附上任何认证信息,但 OSS Bucket 如果是公开读权限,它照样返回。

2.2 AuthImage 的核心思路:不直接用外链

AuthImage.vue 是整个方案的关键组件。它的逻辑很简单:

css 复制代码
不是用外链 URL 当 img src,而是:
1. 先调后端接口获取带签名的临时 URL
2. 用 fetch(手动带 Authorization 头)拉取文件流
3. 转成 blob URL,再赋给 img src
xml 复制代码
<script setup>
import { resolveRenderableFileUrl } from '@/utils/file.js'

async function loadImage() {
  // 核心:所有文件 ID 都通过这个函数解析
  const url = await resolveRenderableFileUrl(props.src, props.expires)
  imageSrc.value = url
}
</script>
<template>
  <img :src="imageSrc" @error="handleError" />
</template>

前端使用方式:

xml 复制代码
<!-- 只需要传 fileId,AuthImage 自动处理鉴权 -->
<AuthImage :src="fileId" :fallback="/default-avatar.png" />

2.3 resolveRenderableFileUrl 三步判断

这是整个鉴权访问的调度中心,位于前端 file.js

sql 复制代码
输入:src(可能是 fileId / 外链 / data: / blob:)

  ├─ 是外部直链 / data: / blob:?
  │   → 直接返回,不处理(外部链接本来就不该出现在私有场景)
  │
  ├─ 否则认为是 fileId
  │   → 调用 GET /api/file/url/{fileId}?expires=43200
  │   → 后端返回临时访问 URL
  │
  └─ 返回的 URL 是内部接口?(如 /api/file/download/{fileId})
      → fetch(url, { headers: getAuthHeaders() })
      → 拿到文件流 → URL.createObjectURL(blob)
      → 返回 blob URL(同源,浏览器可以正常展示)

关键点fetch 时手动注入 Authorization 头。这是 <img> 标签做不到,但 JavaScript fetch 可以做到的事。blob URL 是浏览器内存中的临时地址(blob:https://...),天然同源,不会被外部访问。


三、数据结构:文件元数据与存储抽象

3.1 文件元数据表:sys_file_metadata

字段 类型 说明
id bigint 主键
file_id varchar(64) 对外暴露的唯一标识(UUID)
original_name varchar(255) 原始文件名
storage_name varchar(255) 存储路径名(UUID + 扩展名)
file_size bigint 文件大小(字节)
mime_type varchar(128) MIME 类型
md5 varchar(32) 文件 MD5,支撑秒传
storage_type varchar(20) 存储类型(local/tencent/rustfs)
storage_path varchar(500) 存储路径
business_type varchar(50) 业务类型(avatar/attachment/material)
business_id varchar(64) 关联业务 ID
is_private tinyint 是否私有(0=公开,1=私有)
uploader_id bigint 上传者用户 ID
uploader_name varchar(50) 上传者名称

is_private + uploader_id 是实现文件级权限控制的核心字段。

3.2 存储类型:策略模式 + SPI 插件化

scss 复制代码
┌─────────────────────────────────────────┐
│            FileManager(调度中心)        │
│   upload() / download() / getFileUrl()   │
└──────────────┬──────────────────────────┘
               │
       ┌───────┼───────┐
       ▼       ▼       ▼
┌─────────┐ ┌───────┐ ┌──────────┐
│  Local  │ │ Tencent│ │  RustFS  │
│  Storage│ │  COS   │ │  (S3兼容) │
└─────────┘ └───────┘ └──────────┘

切换方式有三种:

yaml 复制代码
# 方式一:配置文件切换默认存储
forge:
  file:
    default-storage-type: tencent
less 复制代码
// 方式二:上传时动态指定
@PostMapping("/upload")
public RespInfo<FileMetadata> upload(
    @RequestParam("file") MultipartFile file,
    @RequestParam(value = "storageType", required = false) String storageType
) { ... }
erlang 复制代码
-- 方式三:数据库配置(sys_file_storage_config 表)
INSERT INTO sys_file_storage_config (storage_type, is_default, enabled, config_json)
VALUES ('tencent', 1, 1, '{"secretId":"...","secretKey":"...","bucket":"..."}');

四、实现链路:从上传到展示的完整旅程

4.1 文件上传

scss 复制代码
前端(AuthUpload 组件 + token 请求头)
  → POST /api/file/upload (MultipartFile + storageType + isPrivate)
    → FileController.upload()
      → 权限校验:非私有文件只有管理员能上传
      → FileManager.upload()
        → 1. 确定存储类型(参数 > 默认配置)
        → 2. 文件校验(大小、类型白名单,从 StorageConfigProvider 获取)
        → 3. 秒传检查(计算 MD5,查库是否存在同文件+同业务)
        → 4. 调用 FileStorage.upload() 实际存储
           - LocalFileStorage: Files.copy() 到本地磁盘
           - TencentCosFileStorage: COS SDK putObject()
           - RustfsFileStorage: S3 SDK v2 putObject()
        → 5. 构建 FileMetadata → 持久化到 sys_file_metadata
  → 返回 FileMetadata JSON(不含真实物理路径)

4.2 鉴权下载(本地存储)

对于本地存储的文件,后端代理输出流:

scss 复制代码
前端 fetch("/api/file/download/{fileId}", { headers: { Authorization: "..." } })
  → FileController.download()
    → 从 sys_file_metadata 查出文件元数据
    → checkPermission() 鉴权(私有文件验证 uploaderId)
    → 读取文件流 → response.setContentType() → 写入输出流
    → 浏览器收到 blob → URL.createObjectURL(blob)

4.3 鉴权下载(对象存储 - COS/S3)

对于云存储,后端生成预签名 URL:

scss 复制代码
前端调用 GET /api/file/url/{fileId}?expires=43200
  → FileController.getFileUrl()
    → checkPermission() 鉴权
    → FileManager.getFileUrl()
      → 有自定义 CDN 域名 → 直接返回 CDN 地址
      → 否则 → 调用存储实现的 generatePresignedUrl()
        - TencentCosFileStorage: COS SDK generatePresignedUrl()
        - RustfsFileStorage: S3Presigner.presignGetObject()
  → 返回带签名的临时 URL(默认 12 小时有效)
  → 前端 fetch + Authorization 头拿文件流 → blob URL

这里有一个值得注意的设计:OSS 预签名 URL + fetch 双重鉴权。前者保证 URL 有时效性且由后端授权生成,后者保证当前请求确实来自已认证用户。


五、设计取舍:安全与性能的平衡

5.1 为什么不直接用 OSS 预签名 URL 当 img src?

技术上可行:

ini 复制代码
// 生成一个 30 秒有效的预签名 URL 直接返回
String signedUrl = cosClient.generatePresignedUrl(bucket, key, 30);
return RespInfo.success(signedUrl);

前端直接 <img :src="signedUrl">,30 秒内能展示,超过时间加载失败图。

但我们没这么做,原因:

  1. 预签名 URL 在有效期内任何人都能访问------不发散用户,但发散时间窗口
  2. 30 秒太短可能导致弱网环境加载失败,太长是安全隐患
  3. 最重要的:浏览器的缓存机制可能缓存这张图片,下次直接用缓存,不经过任何鉴权

5.2 blob URL 的性能开销

fetch + URL.createObjectURL 比直接用外链多了一次网络请求和一次内存转换。对于头像这种高频、小文件的场景,我们做了两层优化:

  1. 前端内存缓存:同一 fileId 的 blob URL 在组件生命周期内复用
  2. expires 参数:对于展示给未登录用户的内容(如登录页背景),可以用长过期时间减少刷新频率

实测一个 50KB 的头像图片,fetch + blob 转换的总耗时约 80ms,用户感知不到差异。

5.3 checkPermission() 的一个已知设计缺口

当前 FileManager.download() 方法中,checkPermission() 的逻辑:

ini 复制代码
公开文件(is_private = 0)→ 所有人可访问
私有文件(is_private = 1)→ 仅 uploaderId == 当前用户ID

FileController.download() 标注了 @ApiPermissionIgnore(跳过接口权限校验),而 FileManager.download() 内部未显式调用 checkPermission()。在后续版本中我们计划在下载链路中补上这一层校验------这也是为什么说文件鉴权要内外两层都做,不能只靠一层。

5.4 秒传:MD5 驱动的去重

文件上传时,FileManager.upload() 会先计算 MD5 并查库。如果存在相同 MD5 且 businessType + businessId 一致,直接返回已有记录的 fileId,跳过实际上传。

这个设计对于"多次提交同一个附件"的场景非常有效------比如用户反复保存表单,每次附带同一张截图,后端不会重复存储。


六、二开指南:接入你自己的存储

6.1 基础配置

yaml 复制代码
forge:
  file:
    default-storage-type: local
    local:
      base-path: /data/files
    tencent-cos:
      secret-id: your-secret-id
      secret-key: your-secret-key
      bucket: your-bucket
      region: ap-guangzhou

6.2 扩展新存储(以 MinIO 为例)

typescript 复制代码
@Component
public class MinioFileStorage implements FileStorage {
    @Override
    public StorageType getStorageType() { return StorageType.MINIO; }

    @Override
    public FileMetadata upload(MultipartFile file, String businessType, String businessId) {
        // 用 MinIO Java SDK 实现上传
        minioClient.putObject(...);
        return buildMetadata(file);
    }

    @Override
    public String generatePresignedUrl(String storagePath, int expires) {
        // MinIO presignedGetObject
        return minioClient.getPresignedObjectUrl(...);
    }

    @Override
    public void download(String storagePath, OutputStream outputStream) {
        minioClient.getObject(...);
    }
}

不需要改任何现有代码,SPI 机制会自动发现并注册。

6.3 前端接入

xml 复制代码
<template>
  <!-- 图片展示:直接用 AuthImage -->
  <AuthImage :src="fileId" :fallback="/default.jpg" />

  <!-- 文件下载:调用 getFileUrl 拿到授权 URL -->
  <a :href="downloadUrl" @click.prevent="downloadFile">下载附件</a>
</template>
<script setup>
import { getFileUrl } from '@/api/file'

async function downloadFile() {
  const url = await getFileUrl(props.fileId)
  window.open(url)
}
</script>

七、体验预告

forge-starter-file 为文件管理提供了一套开箱即用的安全基座。从本地开发到云上生产,只需改一行配置就能切换存储后端;从公开素材到私密合同,只需一个 isPrivate 参数就能控制权限。

我们也看到了当前方案的改进空间:checkPermission() 的权限模型偏简单(仅基于 uploaderId),对于需要 RBAC 文件权限的场景(如"部门主管可以看本部门所有文件"),需要业务侧自行扩展。后续版本我们计划将文件权限接入数据权限体系,实现更细粒度的行级文件鉴权。

下一篇预告:B06《分布式幂等怎么落地到业务接口?从 Token 到 Redisson 锁》------重复提交、重复支付、重复审批,一个注解全搞定。


体验 Forge Admin

相关推荐
lcreek1 小时前
Java 反序列化漏洞深度解析(一):从URLDNS到真正的DNS探测
java·反序列化漏洞
杰克尼1 小时前
天机学堂复习总结(day03-day04)
java·开发语言·redis·elasticsearch·spring cloud
阿坤带你走近大数据2 小时前
数仓架构的设计思路、模型选择依据、落地难点及解决方案的介绍
架构·管理·数仓·业务与技术融合
ftpeak2 小时前
Mooncake:以 KVCache 为中心的分离式 LLM 服务架构
人工智能·ai·架构·ai编程·ai开发
x***r1512 小时前
jdk-11.0.16.1_windows使用步骤详解(附JDK 11环境变量配置与验证教程)
java·开发语言·windows
弹简特2 小时前
【Java项目-轻聊】01-项目演示+项目介绍+准备工作+项目源码
java
luck_bor3 小时前
File类&递归作业
java·开发语言
武子康3 小时前
Java-07 深入浅出 MyBatis数据库一对多关系模型实战:表结构设计与查询实现
java·后端
花椒技术4 小时前
企业内部 Agent 落地复盘:Gateway、Skill 和二次确认如何串起受控业务执行
后端·agent·ai编程
Agent手记5 小时前
制造业生产流程自动化,Agent需要具备哪些能力?深度拆解2026工业级智能体落地范式与核心架构
大数据·人工智能·ai·架构·自动化