一个被问烂的需求: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 给浏览器
问题清单 :
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
「文件预览」表面是个功能题,本质是个安全题 ——文件存在哪、谁能看、什么时候能看,三件事都要由后端控制。
完整方案:文件流预览 + 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
publicclass 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);
thrownew 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) {
thrownew ServiceException("未登录");
}
// 2. 业务侧文件归属 / 共享权限校验(自己上传 or 共享给我)
SysFile file = fileService.findByFilename(filename);
if (file == null || !fileAuthService.canPreview(userId, file.getId())) {
thrownew 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
publicclass PreviewTokenService {
privatefinal SecretKey signingKey; // 跟登录 token 不同的密钥
privatefinal 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) {
thrownew ServiceException("预览 token 无效或已过期");
}
// 1. scope 必须是 preview——防登录 token 被误塞进来用
if (!"preview".equals(claims.get("scope", String.class))) {
thrownew 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)) {
thrownew 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 的能力,后者是你后端的责任。
扫码领红包
微信赞赏
支付宝扫码领红包
