别再让 MinIO 暴露公网!Spring Boot + kkFile 文件流预览 + Token 鉴权
这是一个或许对你有用的社群 一对一交流/面试小册/简历优化/求职解惑欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料《项目实战视频》从书中学往事中“练”《互联网高频面试题》面朝简历学习春暖花开《架构 x 系统设计》摧枯拉朽掌控面试高频场景题《精进 Java 学习指南》系统学习互联网主流技术栈《必读 Java 源码专栏》知其然知其所以然这是一个或许对你有用的开源项目国产Star破10w的开源项目前端包括管理后台、微信小程序后端支持单体、微服务架构RBAC权限、数据权限、SaaS多租户、商城、支付、工作流、大屏报表、ERP、CRM、AI大模型、IoT物联网等功能多模块https://gitee.com/zhijiantianya/ruoyi-vue-pro微服务https://gitee.com/zhijiantianya/yudao-cloud视频教程https://doc.iocoder.cn【国内首批】支持 JDK17/21SpringBoot3、JDK8/11Spring Boot2双版本一个被问烂的需求MinIO 文件预览默认接 kkFile 的方案到底什么问题完整方案文件流预览 Token 鉴权Token 校验的关键点真正会让你掉头发的几件事一句话收口一个被问烂的需求MinIO 文件预览「Word / Excel / PDF 在线预览能搞吗」——这是后台管理系统的高频需求。国内 Java 项目的标准答案MinIO 存文件 kkFileView 转 PDF/HTML 在浏览器里预览。教程一搜一大把但 90% 的教程少了关键一步——安全。我看过的最常见方案是http://kkfile-server/onlinePreview?urlbase64(http://minio-server/bucket/foo.docx)这个 URL 直接把MinIO 公网地址 文件路径写在 base64 里——任何人拿到这个链接用 base64 解码就能直接拿到 MinIO 的下载地址绕过你的所有鉴权。更糟的是国内 OSS / MinIO 部署默认开 LIST 权限的不少文件路径已知 bucket 默认 LIST 攻击者可以遍历你的所有文件。每年都有几起「OSS 数据泄露」的安全事件根因就这么简单。这篇要讲的方案不是「能跑」是「能跑 MinIO 不暴露 文件流走业务 token 鉴权」——这是生产里该有的姿势。基于 Spring Boot MyBatis Plus Vue Element 实现的后台管理系统 用户小程序支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能项目地址https://github.com/YunaiV/ruoyi-vue-pro视频教程https://doc.iocoder.cn/video/默认接 kkFile 的方案到底什么问题回顾一下「直传 MinIO 地址」的方案浏览器 → kkFileonlinePreview?urlbase64MinIO ↓ kkFile 解码 base64 拿到 MinIO 地址 ↓ kkFile 直接访问 MinIO 下载文件 ↓ 转换成 PDF/HTML 给浏览器问题清单问题后果MinIO 公网地址暴露浏览器 F12 看 Network 就能拿到文件路径裸奔知道一个文件路径 → 推测出更多文件路径命名规则默认 LIST 权限整个 bucket 文件列表泄露没有用户级鉴权任何人有 URL 就能看跨用户访问A 用户的文件链接 B 用户能直接打开「文件预览」表面是个功能题本质是个安全题——文件存在哪、谁能看、什么时候能看三件事都要由后端控制。基于 Spring Cloud Alibaba Gateway Nacos RocketMQ Vue Element 实现的后台管理系统 用户小程序支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能项目地址https://github.com/YunaiV/yudao-cloud视频教程https://doc.iocoder.cn/video/完整方案文件流预览 Token 鉴权正确架构改成浏览器 → kkFileonlinePreview?urlbase64(后端文件流接口?tokenxxx) ↓ kkFile 解码 base64 拿到的是【后端接口地址】不是 MinIO ↓ kkFile 用 token 调后端的 download 接口 ↓ 后端校验 token从 MinIO 拉文件流返回给 kkFile ↓ kkFile 转 PDF/HTML 返回浏览器关键改动kkFile 看到的不是 MinIO 地址是后端的/download/{token}/{filename}接口后端接口校验 token校验通过才从 MinIO 拉文件流MinIO 永远不直接对外。下面是关键代码。文件上传简单对接 MinIO SDKService 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 RestResultVoid 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 headerGetMapping(/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 转写到 responsepublic 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;charsetUTF-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 的 previewTokenpublic 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. 签发短期 previewToken5 分钟有效绑定 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 RestResultString getPreviewUrl(String filename) throws Exception { return RestResult.ok(fileService.getPreviewUrl(filename)); }返回的 URL 形如http://kkfile-server/onlinePreview?urlbase64(http://api-server/file/download/{previewToken}/{filename})fullfilenamexxx.docxkkFile 解码 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 隔离——签发用scopepreview登录主 token 拿过来直接被拒fileId 绑死 filename——签发时 file 是 A下载时 path 上是 B直接拒这是任何登录用户能看任何文件漏洞的兜底。Path 参数顺序/download/{token}/{filename}里filename 必须放最后——文件名可能含.docx.pdfSpring 的路径变量解析对带点号的参数有特殊处理放最后才能正确捕获完整文件名包含扩展名。整套方案上线后怎么验证安全模型生效打开 F12 看 Network——三件事你应该看到浏览器地址栏只剩http://kkfile-server/onlinePreview?urlbase64fullfilenamexxx.docxMinIO 的真实地址完全不出现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;charsetUTF-8);很多教程里图省事写text/plain——kkFile 拿到错的 ContentType 会按文本解析PDF / Excel 直接乱码。新人接入第一周必踩。做法按文件后缀返回正确 MIMEapplication/pdf、application/vnd.openxmlformats-officedocument.spreadsheetml.sheet等或统一application/octet-stream让 kkFile 自己按文件扩展名判断。坑三previewToken TTL 不好定常见5 分钟太短——用户打开 100MB 的 PDF看到第三页 token 过期30 分钟太长——一旦 URL 泄漏攻击窗口大。做法常规文档Office / PDF5-15 分钟够用大附件 / 视频流把 TTL 拉长到 1 小时但要求 fileId 强绑前面 PreviewTokenService 已经做了前端兜底用户操作时检测预览页存活时长接近过期重新调/getPreviewUrl拿新 URL避免「看到一半被赶下来」。坑四kkFile 缓存可能泄露少见但破坏力大kkFile 默认会把转换后的 PDF/HTML 缓存在自己的本地磁盘——有些版本的缓存路径是按 base64 后的 URL 算 hash 的不同 token 的同一文件会被重复转换并各存一份。严格场景下这是数据泄露隐患。做法评估磁盘容量定期清理 kkFile 缓存或者在 kkFile 配置里关闭缓存cache.enabledfalse代价是每次都重新转换严格场景下用 kkFile 的「内存缓存模式」进程重启就清。一句话收口文件预览是个「看着简单」的功能——看似只要把文件给 kkFile 它就转成 PDF但暴露 MinIO 地址 公网遍历你的所有文件业务 token 校验缺位 任何登录用户能看任何文件。正确姿势就一条MinIO 永远不直接对外所有文件访问走业务后端的鉴权接口——kkFile 看到的是你的接口不是 MinIO。工程取舍很简单——多做一层文件流接口的成本几十行代码 一次拦截器配置远比「OSS 文件泄露事件」的成本低——后者是一次大事故 法务接入 监管处罚 可能上头条。文件预览的本质是把「能看」和「该看」拆开——前者是 kkFile 的能力后者是你后端的责任。欢迎加入我的知识星球全面提升技术能力。 加入方式“长按”或“扫描”下方二维码噢星球的内容包括项目实战、面试招聘、源码解析、学习路线。文章有帮助的话在看转发吧。 谢谢支持哟 (*^__^*