
关键词:文件鉴权 / 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 秒内能展示,超过时间加载失败图。
但我们没这么做,原因:
- 预签名 URL 在有效期内任何人都能访问------不发散用户,但发散时间窗口
- 30 秒太短可能导致弱网环境加载失败,太长是安全隐患
- 最重要的:浏览器的缓存机制可能缓存这张图片,下次直接用缓存,不经过任何鉴权
5.2 blob URL 的性能开销
fetch + URL.createObjectURL 比直接用外链多了一次网络请求和一次内存转换。对于头像这种高频、小文件的场景,我们做了两层优化:
- 前端内存缓存:同一 fileId 的 blob URL 在组件生命周期内复用
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
- 在线演示 :Forge Admin 后台管理
- 默认账号:admin / 123456
- 多租户体验:登录后查看不同租户的数据隔离效果
- Gitee :ForgeLab/forge-admin
- GitHub :yaomindong1996/forge-admin