⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/SpringBoot--MinIO--kkFile-shixianwenjianyulanzheyangcaozuogenganquan/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

一个被问烂的需求:MinIO 文件预览

「Word / Excel / PDF 在线预览,能搞吗?」——这是后台管理系统的高频需求。

国内 Java 项目的标准答案:MinIO 存文件 + kkFileView 转 PDF/HTML 在浏览器里预览。

教程一搜一大把,但 90% 的教程少了关键一步——安全。我看过的最常见方案是:

http://kkfile-server/onlinePreview?url=base64(http://minio-server/bucket/foo.docx)

这个 URL 直接把 MinIO 公网地址 + 文件路径 写在 base64 里——任何人拿到这个链接,用 base64 解码就能直接拿到 MinIO 的下载地址,绕过你的所有鉴权。

更糟的是,国内 OSS / MinIO 部署默认开 LIST 权限的不少,文件路径已知 + bucket 默认 LIST = 攻击者可以遍历你的所有文件。每年都有几起「OSS 数据泄露」的安全事件,根因就这么简单。

这篇要讲的方案不是「能跑」,是「能跑 + MinIO 不暴露 + 文件流走业务 token 鉴权」——这是生产里该有的姿势。

默认接 kkFile 的方案到底什么问题

回顾一下「直传 MinIO 地址」的方案:

浏览器 → kkFile(onlinePreview?url=base64MinIO)
↓
kkFile 解码 base64 拿到 MinIO 地址
↓
kkFile 直接访问 MinIO 下载文件
↓
转换成 PDF/HTML 给浏览器

问题清单:

问题 后果
MinIO 公网地址暴露 浏览器 F12 看 Network 就能拿到
文件路径裸奔 知道一个文件路径 → 推测出更多文件路径(命名规则)
默认 LIST 权限 整个 bucket 文件列表泄露
没有用户级鉴权 任何人有 URL 就能看
跨用户访问 A 用户的文件链接 B 用户能直接打开

「文件预览」表面是个功能题,本质是个安全题——文件存在哪、谁能看、什么时候能看,三件事都要由后端控制。

完整方案:文件流预览 + Token 鉴权

正确架构改成:

浏览器 → kkFile(onlinePreview?url=base64(后端文件流接口?token=xxx))
↓
kkFile 解码 base64 拿到的是【后端接口地址】(不是 MinIO)
↓
kkFile 用 token 调后端的 download 接口
↓
后端校验 token,从 MinIO 拉文件流,返回给 kkFile
↓
kkFile 转 PDF/HTML 返回浏览器

关键改动:

  • kkFile 看到的不是 MinIO 地址,是后端的 /download/{token}/{filename} 接口;
  • 后端接口校验 token,校验通过才从 MinIO 拉文件流;
  • MinIO 永远不直接对外。

下面是关键代码。

文件上传

简单对接 MinIO SDK:

@Service
public class FileService {

@Resource
private MinioClient client;
@Resource
private MinioConfig minioConfig;

public void uploadFile(MultipartFile file) throws Exception {
String fileName = System.currentTimeMillis() + "-" + file.getOriginalFilename();
client.putObject(PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
}
}
@PostMapping("/upload")
public RestResult<Void> upload(MultipartFile file) {
try {
fileService.uploadFile(file);
return RestResult.ok();
} catch (Exception e) {
log.error("上传文件失败", e);
return RestResult.fail(e.getMessage());
}
}

下载(核心:带 token 校验)

下载接口的 URL 设计是 /download/{token}/{filename}——把 token 放在 path 里而不是 header,因为 kkFile 调过来时不会带 Authorization header:

@GetMapping("/download/{token}/{filename}")
public void download(@PathVariable String token,
@PathVariable String filename,
HttpServletResponse response) {
// 鉴权 + 归属校验:token 必须是预览专用 token,且 claim 里的 fileId 跟 filename 对得上
previewTokenService.validate(token, filename);
fileService.streamToResponse(filename, response);
}

文件流从 MinIO 转写到 response:

public void streamToResponse(String filename, HttpServletResponse response) {
try (InputStream input = client.getObject(GetObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(filename)
.build());
OutputStream output = response.getOutputStream()) {

response.setContentType("application/octet-stream;charset=UTF-8");
response.setHeader("Content-Disposition",
"attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));

// try-with-resources 自动关流;用 IOUtils 简化拷贝
IOUtils.copy(input, output);
} catch (Exception e) {
log.error("文件下载失败 {}", filename, e);
throw new ServiceException("文件下载失败");
}
}

改进点:

  • try-with-resources 自动关流——原文手动 close 容易漏;
  • IOUtils.copy 替代手写 buffer 循环——少几行代码且更稳;
  • response 异常时自动回滚连接,不会泄露文件流。

预览地址生成(核心:签发短期 previewToken)

这一步是整个安全模型的关键:不要把登录主 token 拼到 URL 里——一旦泄漏(kkFile 日志、浏览器历史、CDN 缓存),整个用户态就漏了。正确做法是签发一个专用的、短期的、绑定 fileId 的 previewToken:

public String getPreviewUrl(String filename) throws Exception {
// 1. 当前已登录用户(拦截器层把登录主 token 转成 userId)
Long userId = SecurityContextHolder.getCurrentUserId();
if (userId == null) {
throw new ServiceException("未登录");
}

// 2. 业务侧文件归属 / 共享权限校验(自己上传 or 共享给我)
SysFile file = fileService.findByFilename(filename);
if (file == null || !fileAuthService.canPreview(userId, file.getId())) {
throw new ServiceException("无权访问该文件");
}

// 3. 签发短期 previewToken(5 分钟有效,绑定 fileId / userId / scope)
String previewToken = previewTokenService.issue(
userId, file.getId(), Duration.ofMinutes(5));

// 4. 拼成 kkFile 要调用的「后端 download 接口地址」
String downloadUrl = fileDownloadUrl + "/" + previewToken + "/" + filename;

// 5. base64 url-encode 后拼到 kkFile 预览接口
String encoded = base64UrlEncode(downloadUrl);
return filePreviewUrl + encoded
+ "&fullfilename=" + URLEncoder.encode(filename, "UTF-8");
}

public static String base64UrlEncode(String url) throws UnsupportedEncodingException {
String b64 = Base64.getEncoder().encodeToString(url.getBytes(StandardCharsets.UTF_8));
return URLEncoder.encode(b64, "UTF-8");
}
@GetMapping("/getPreviewUrl")
public RestResult<String> getPreviewUrl(String filename) throws Exception {
return RestResult.ok(fileService.getPreviewUrl(filename));
}

返回的 URL 形如:

http://kkfile-server/onlinePreview?url=base64(http://api-server/file/download/{previewToken}/{filename})&fullfilename=xxx.docx

kkFile 解码 base64 拿到的是「带短期 previewToken 的 download 地址」——MinIO 的真实地址永远不会出现在浏览器里,登录主 token 也永远不会被拼到 URL 里。

Token 校验的关键点

这个方案能跑通的核心是 /download/{token}/{filename} 这个接口要在拦截器里放行——否则 kkFile 没有 Authorization header,会被你的 Spring Security / 拦截器卡掉。

拦截器配置

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/file/download/**", // ← 关键:放行 download 接口
"/login",
"/swagger-ui/**"
);
}
}

PreviewTokenService:签发 + 校验

预览 token 跟登录主 token 用不同的 key、不同的 scope、独立短期——这样登录 token 不会被当成预览 token 滥用,预览 token 也不会有登录 token 那么长的有效期:

@Service
public class PreviewTokenService {

private final SecretKey signingKey; // 跟登录 token 不同的密钥
private final SysFileService fileService;

/** 签发:绑 fileId + userId + scope,过期时间通常 5-15 分钟 */
public String issue(Long userId, Long fileId, Duration ttl) {
return Jwts.builder()
.claim("uid", userId)
.claim("fid", fileId)
.claim("scope", "preview")
.setIssuedAt(new Date())
.setExpiration(Date.from(Instant.now().plus(ttl)))
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}

/** 校验:token 必须是 preview scope,且 claim 里的 fileId 跟 path 上的 filename 对应 */
public void validate(String token, String filename) {
Claims claims;
try {
claims = Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (JwtException e) {
throw new ServiceException("预览 token 无效或已过期");
}

// 1. scope 必须是 preview——防登录 token 被误塞进来用
if (!"preview".equals(claims.get("scope", String.class))) {
throw new ServiceException("token scope 不匹配");
}

// 2. 文件归属校验:claim 的 fid 必须跟 path 上的 filename 对得上
Long claimFileId = claims.get("fid", Long.class);
SysFile file = fileService.findByFilename(filename);
if (file == null || !file.getId().equals(claimFileId)) {
throw new ServiceException("token 与文件不匹配");
}
}
}

这套设计兜住三件事:

  • 登录 token 永远不进 URL——URL 里只有短期 previewToken,泄漏了影响也限定在 5 分钟 + 这一个文件;
  • scope 隔离——签发用 scope=preview,登录主 token 拿过来直接被拒;
  • fileId 绑死 filename——签发时 file 是 A,下载时 path 上是 B,直接拒;这是”任何登录用户能看任何文件”漏洞的兜底。

Path 参数顺序

/download/{token}/{filename} 里 filename 必须放最后——文件名可能含 .docx .pdf,Spring 的路径变量解析对带点号的参数有特殊处理,放最后才能正确捕获完整文件名(包含扩展名)。

整套方案上线后怎么验证安全模型生效?打开 F12 看 Network——三件事你应该看到:

  • 浏览器地址栏只剩 http://kkfile-server/onlinePreview?url=<base64>&fullfilename=xxx.docx,MinIO 的真实地址完全不出现;
  • Network 里只有两个域名的请求:kkFile(拉转换后的 PDF/HTML) + 业务后端 download 接口(被 kkFile 内部调用);
  • 把那段 base64 解出来,拿到的是 https://api/download/<previewToken>/xxx.docx——没有登录主 token、没有 MinIO 路径。

这三条任意一条不成立,说明前面某一步漏了——多半是 getPreviewUrl 直接拼了 MinIO 地址,或者 previewToken 用了登录主 token 替代。

真正会让你掉头发的几件事

按踩到概率从高到低排:

坑一:MinIO 一定要关 LIST 权限(最常见,安全底线)

哪怕方案做得再严,如果 MinIO 的 bucket 默认 LIST 权限是开的,攻击者直接连 MinIO 公网就能列出所有文件——你前面所有 token 校验都白做。国内 OSS 数据泄露事件每年都有,根因往往就是这条。

做法:

  • MinIO bucket 设为 private(默认私有);
  • bucket 网络接入只对内网开放,公网入口关闭;
  • 用 nginx 反代隔离 MinIO,业务 token 校验做在反代之前。

坑二:ContentType 设错,kkFile 直接乱码(最常见)

response.setContentType("application/octet-stream;charset=UTF-8");

很多教程里图省事写 text/plain——kkFile 拿到错的 ContentType 会按文本解析,PDF / Excel 直接乱码。新人接入第一周必踩。

做法:按文件后缀返回正确 MIME(application/pdf、application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 等),或统一 application/octet-stream 让 kkFile 自己按文件扩展名判断。

坑三:previewToken TTL 不好定(常见)

5 分钟太短——用户打开 100MB 的 PDF,看到第三页 token 过期;30 分钟太长——一旦 URL 泄漏,攻击窗口大。

做法:

  • 常规文档(Office / PDF):5-15 分钟够用;
  • 大附件 / 视频流:把 TTL 拉长到 1 小时,但要求 fileId 强绑(前面 PreviewTokenService 已经做了);
  • 前端兜底:用户操作时检测预览页存活时长,接近过期重新调 /getPreviewUrl 拿新 URL,避免「看到一半被赶下来」。

坑四:kkFile 缓存可能泄露(少见但破坏力大)

kkFile 默认会把转换后的 PDF/HTML 缓存在自己的本地磁盘——有些版本的缓存路径是按 base64 后的 URL 算 hash 的,不同 token 的同一文件会被重复转换并各存一份。严格场景下这是数据泄露隐患。

做法:

  • 评估磁盘容量,定期清理 kkFile 缓存;
  • 或者在 kkFile 配置里关闭缓存(cache.enabled=false),代价是每次都重新转换;
  • 严格场景下用 kkFile 的「内存缓存模式」,进程重启就清。

一句话收口

文件预览是个「看着简单」的功能——看似只要把文件给 kkFile 它就转成 PDF,但暴露 MinIO 地址 = 公网遍历你的所有文件,业务 token 校验缺位 = 任何登录用户能看任何文件。

正确姿势就一条:MinIO 永远不直接对外,所有文件访问走业务后端的鉴权接口——kkFile 看到的是你的接口,不是 MinIO。

工程取舍很简单——多做一层文件流接口的成本(几十行代码 + 一次拦截器配置)远比「OSS 文件泄露事件」的成本低——后者是一次大事故 + 法务接入 + 监管处罚 + 可能上头条。

文件预览的本质,是把「能看」和「该看」拆开——前者是 kkFile 的能力,后者是你后端的责任。

文章目录
  1. 1. 一个被问烂的需求:MinIO 文件预览
  2. 2. 默认接 kkFile 的方案到底什么问题
  3. 3. 完整方案:文件流预览 + Token 鉴权
    1. 3.0.1. 文件上传
    2. 3.0.2. 下载(核心:带 token 校验)
    3. 3.0.3. 预览地址生成(核心:签发短期 previewToken)
  • 4. Token 校验的关键点
    1. 4.0.1. 拦截器配置
    2. 4.0.2. PreviewTokenService:签发 + 校验
    3. 4.0.3. Path 参数顺序
  • 5. 真正会让你掉头发的几件事
    1. 5.0.1. 坑一:MinIO 一定要关 LIST 权限(最常见,安全底线)
    2. 5.0.2. 坑二:ContentType 设错,kkFile 直接乱码(最常见)
    3. 5.0.3. 坑三:previewToken TTL 不好定(常见)
    4. 5.0.4. 坑四:kkFile 缓存可能泄露(少见但破坏力大)
  • 6. 一句话收口