ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot对接阿里云OSS文件上传:从签名直传到安全回调实战

Spring Boot对接阿里云OSS文件上传:从签名直传到安全回调实战 做后端这些年十有八九的项目最后都会撞上同一个需求文件上传。早期我习惯把文件扔到本地磁盘单机的时候没感觉等用户量上来磁盘告警、备份麻烦、域名换乱、扩展困难每一样都够折腾一阵子。后来把对象存储迁到阿里云OSS整套链路从设计到落地文档里其实很多细节要靠自己踩。这篇文章就把Spring Boot对接阿里云OSS做文件上传的完整流程重新捋一遍从开通服务、初始化SDK、封装签名接口、配置回调校验到开发阶段常见的跨域、超时、命名坑以及文件上传安全边界尽量做到看完能直接照着落地。这篇内容适合谁看我自己定位是两类人一是刚接触OSS上传、对着官方文档不知道从哪下手的Java后端二是已经跑通了基本上传、但发现回调校验或安全策略还悬着的老开发。后者可以直接跳到第四章和第五章。文章中涉及的都是生产环境验证过的做法代码以Spring Boot 2.7.x为基础SDK用的是aliyun-sdk-oss 3.17.x新版本大同小异。1. 为什么我不建议把文件直接存本地OSS选型前后的思考1.1 本地存储的三类痛点和OSS的对应解法先说痛点。很多人一开始图省事把上传的文件直接塞到项目的静态目录或某块挂载盘里。小项目确实能跑但到一定规模后问题就暴露出来了。第一是磁盘容量不可控。用户头像、商品详情图、日志文件、合同PDF这些文件一旦多起来单机磁盘很快见底。扩容就得加盘加盘就意味着停机维护或者迁移数据成本直接上去了。第二是备份和冗余成本高。本地文件只能自己做副本搞个定时任务rsync到另一台机器或者买NAS这些方案不是不能用但没有一种能像OSS那样自带跨可用区冗余。OSS默认就把数据做了多副本这一点对中小团队来说是省心的。第三是访问链路和域名管理混乱。本地文件如果要走HTTPS得自己搞证书、配Nginx、处理负载均衡。而OSS天然支持HTTPS、CDN加速、自定义域名绑定上传后拿到的URL就是可直接访问的公网地址基本不用再单独维护一套静态资源服务。1.2 三个必须理解的基础概念Region、Bucket、Object开始写代码之前建议先想清楚OSS的三个基本概念不然你会被文档里的各种名词绕晕。Region地域指OSS数据中心所在的物理区域比如华东1杭州、华北2北京。选Region要遵循就近原则服务器在上海Bucket最好就建在华东2上海内网访问还免流量费。Bucket存储空间可以理解为一个全局唯一命名的文件仓库。名称在OSS内是全局唯一的所以创建的时候经常发现你心仪的名字被占了加个后缀就好。Object对象就是存在Bucket里的文件。OSS里的每个文件都叫Object它有唯一的Key这个Key就是文件的完整路径名比如avatar/2024/04/01/xxx.jpg。这三个概念弄清楚后你再去看OSS的API文档和SDK示例代码基本能对得上号。另外还要提前做好一个决定Bucket的读写权限。如果只是普通业务文件不建议选公共读更不建议选公共读写后续章节我会专门讲权限策略。2. 动手前的基础准备从开通OSS到创建Bucket2.1 创建Bucket时最容易被忽略的参数流程上先到阿里云控制台开通OSS服务然后创建Bucket。创建的时候有几个参数值得多看一眼。Bucket名称全局唯一命名规则是只能包含小写字母、数字和短横线不能以短横线开头或结尾。这个名称会出现在后续的访问域名里所以尽量起得有意义一点。地域一旦创建就不能修改所以创建前想清楚。如果是做测试选离你最近的地域就好如果是生产最好选和业务服务器相同的地域走内网Endpoint可以省公网流量费。读写权限这里有一个我强烈建议的选择——私有Private。原因后面详细说。如果你只是想临时测试上传选公共读也行但线上千万别图省事。版本控制默认关闭。如果业务上有文件误删回滚需求可以后续在Bucket设置里开启不用创建时纠结。我见过很多人在这一步直接把权限选成公共读理由是反正图片URL要给人看。其实图片URL完全可以通过签名URL方式临时授权或者绑CDN域名来做不需要把整个Bucket裸奔。2.2 AccessKey的创建方式别拿主账号到处贴这一步很多人会犯一个毛病直接用主账号的AccessKeyAccessKey ID / AccessKey Secret写进配置文件。主账号的AccessKey权限是全部资源权限一旦泄露整个账号下的资源都遭殃。正确做法是创建RAM子账号并把这个子账号的权限范围限制在OSS操作上。控制台里的操作路径是RAM访问控制 - 用户 - 创建用户 - 给用户选择编程访问方式会生成一组AccessKey。创建完之后给这个用户添加权限策略一般用系统自带的AliyunOSSFullAccess就够了如果你有多环境隔离或者更细粒度的需求可以单独写自定义策略只允许访问某个Bucket{ Version: 1, Statement: [ { Effect: Allow, Action: oss:PutObject, Resource: acs:oss:*:*:your-bucket-name/* } ] }这段JSON的意思是仅允许向指定Bucket上传文件其他的OSS操作全部拒绝。强烈建议把RAM授权这件事当成上线流程的一部分别把方便当安全。2.3 引入依赖并搭好配置骨架项目是Maven构建的话引入阿里云OSS的SDK依赖dependency groupIdcom.aliyun.oss/groupId artifactIdaliyun-sdk-oss/artifactId version3.17.4/version /dependency当前项目用3.17.4锁版本新项目可以适当升级。如果构建时遇到javax.xml.bind相关的包缺失通常是JDK版本过高导致的需要在pom里额外引入jakarta.xml.bind-api和jaxb-runtime。这个坑常在JDK 11以上的环境出现先记着。依赖引入之后在application.yml里加OSS配置aliyun: oss: endpoint: oss-cn-hangzhou.aliyuncs.com access-key-id: your-access-key-id access-key-secret: your-access-key-secret bucket-name: your-bucket-name # 自定义回调地址稍后代码里会用到 callback-url: https://api.example.com/oss/callback注意这里有个细节Endpoint分公网和内网两种形式。公网是oss-cn-hangzhou.aliyuncs.com内网是oss-cn-hangzhou-internal.aliyuncs.com。如果你的应用跑在阿里云的ECS上且ECS和OSS在同一个地域强烈建议使用内网Endpoint上传速度快、不产生公网下行流量费。3. 上传方案怎么选后端转发与签名直传的对比3.1 方案一后端接收文件再转发到OSS最容易想到的上传方式是前端把文件POST给后端后端读成InputStream再调用OSS SDK的putObject方法把流写入OSS。实现上非常简单核心就一行ossClient.putObject(bucketName, objectKey, inputStream);但实际生产中我不推荐用这个方案处理大文件。原因有三点第一后端服务器成了流量瓶颈。每次上传都先到后端再转发OSS相当于后端机器要承担所有上传流量。带宽不大的服务器一个100MB的文件就可能拖垮其他接口响应。第二上传超时风险高。前端到后端、后端到OSS是两段链路任何一段慢HttpServletRequest就会等待很久体验很差。第三浪费服务器磁盘和CPU。文件先落临时目录再上传或者全程在内存里转都会占用不必要的资源。3.2 方案二服务端签名、前端直传OSS更优雅的做法是服务端签名直传。核心思路是后端不接触文件内容只负责生成一份带有过期时间的上传凭证Policy前端拿到凭证后直接把文件以表单方式POST到OSS的EndpointOSS接收文件后返回结果或者触发配置好的回调接口让后端感知上传完成。关键点在于上传凭证是后端用AccessKeySecret对一段策略文本加签生成的前端只有凭证没有Secret所以拿不到你的账号密钥。同时凭证里可以限制上传目录、文件大小、有效期这比后端中转灵活得多。3.3 一个表决定方案两种方案取舍我经常直接用一张表给团队讲清楚对比项后端转发签名直传服务端带宽占用高所有文件经过后端低后端只处理签名和回调上传速度受后端带宽限制直接走OSS节点速度快服务端CPU/内存有额外开销几乎没有文件大小限制受网关/容器限制单文件最大5GB安全性可控性好需要严格校验签名参数实现复杂度低中等结论很明确除了一些需要在后端做内容处理的场景比如压缩图片、解析Excel、病毒扫描签名直传是更合理的默认方案。我的项目最终采用的就是服务端签名 前端直传 OSS回调通知后端的组合。4. 核心实现配置骨架、签名接口与回调校验4.1 配置参数绑定到Java Bean写代码的第一步是把YAML配置绑定成一个配置类避免在业务代码里到处写Value。Component ConfigurationProperties(prefix aliyun.oss) public class OssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; private String callbackUrl; // getter / setter 省略 }ConfigurationProperties的prefix对应application.yml里的aliyun.oss前缀字段名和配置key要一一对应。启动项目后如果配置注入失败Spring会在启动阶段就报错不会等到调用时才暴露这点很省心。4.2 生成Policy签名和上传凭证核心的签名逻辑我封装在一个OssSignatureService里方法返回一个Map前端直接取里面的字段组装表单即可。Service public class OssSignatureService { private final OSS ossClient; private final OssProperties ossProperties; public OssSignatureService(OssProperties properties) { this.ossProperties properties; this.ossClient new OSSClientBuilder().build( properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret() ); } public MapString, String createPolicy(String dir, long maxSize) { // 签名有效期单位秒。建议30秒-10分钟太短影响用户体验太长有滥用风险 long expireTime 30; long expireEndTime System.currentTimeMillis() expireTime * 1000; PolicyConditions policyConditions new PolicyConditions(); // 限制上传文件大小maxSize单位是字节比如5MB就是 5 * 1024 * 1024 policyConditions.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, maxSize); // 限制上传目录前缀防止用户把文件传到别的路径下 policyConditions.addConditionItem(PolicyConditions.COND_STARTS_WITH, $key, dir); String postPolicy ossClient.generatePostPolicy(expireEndTime, policyConditions); String encodedPolicy Base64.encodeToString(postPolicy.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); String postSignature ossClient.calculatePostSignature(postPolicy); MapString, String result new HashMap(); result.put(accessid, ossProperties.getAccessKeyId()); result.put(policy, encodedPolicy); result.put(signature, postSignature); result.put(dir, dir); result.put(host, https:// ossProperties.getBucketName() . ossProperties.getEndpoint()); result.put(expire, String.valueOf(expireEndTime / 1000)); return result; } }这里有几个信息需要解释。dir是允许上传的虚拟目录前缀比如avatar/2024/04/01/前端提交文件时表单里的key字段必须是以dir开头的完整路径。maxSize限制单文件大小OSS服务端会校验超过直接拒绝。accessid传的是AccessKey ID但签名必须由后端生成AccessKey Secret始终不出现在任何客户端代码里。4.3 上传完成后的回调校验直传模式下前端提交文件到OSS成功后有两种方式让后端知道结果。一种是前端拿到OSS的返回结果再主动调后端接口另一种是OSS在文件接收完成后直接向后端发一个HTTP回调。推荐后者因为回调是服务端到服务端的通信前端无法伪造安全性更高。开启回调需要在前端表单里多带两个隐藏字段x-oss-callback和x-oss-callback-var。x-oss-callback的值是一个URL编码后的JSON示例{ callbackUrl: https://api.example.com/oss/callback, callbackHost: api.example.com, callbackBody: filename${object}size${size}mimeType${mimeType}bucket${bucket}etag${etag}, callbackBodyType: application/x-www-form-urlencoded }后端收到这个回调后必须做两件事第一验证回调请求确实来自OSS。判断方法取请求头中的Authorization、x-oss-pub-key-url、x-oss-request-id用OSS文档提供的签名算法校验。核心代码如下PostMapping(/oss/callback) public ResponseEntityMapString, Object callback(HttpServletRequest request) throws Exception { String authorization request.getHeader(Authorization); String pubKeyUrl request.getHeader(x-oss-pub-key-url); // 公钥URL可能包含特殊字符需要URLDecoder解码后再请求公钥字符串 pubKeyUrl URLDecoder.decode(pubKeyUrl, UTF-8); String pubKey HttpClientUtil.doGet(pubKeyUrl); // 回调Body是请求体的原始字节 byte[] requestBodyBytes getRequestBodyBytes(request); String requestBody new String(requestBodyBytes, StandardCharsets.UTF_8); // 签名规则Authorization OSS Base64(HMAC-SHA1(公钥, 回调Body)) String expectedSignature OSS signWithHmacSha1(pubKey, requestBody); if (!expectedSignature.equals(authorization)) { return ResponseEntity.status(403).build(); } // 解析回调Body拿到objectKey等信息落库 MapString, String params parseFormBody(requestBody); FileRecord record new FileRecord(); record.setObjectKey(params.get(filename)); record.setSize(Long.parseLong(params.get(size))); fileRecordMapper.insert(record); MapString, Object result new HashMap(); result.put(Status, OK); return ResponseEntity.ok(result); }第二处理完成业务后必须返回一个具体的JSON响应给OSS比如上面的{Status:OK}。如果回调返回非200OSS会认为上传失败前端也会收到一个错误提示这点联调时要格外注意。4.4 Controller和统一返回结构签名接口的Controller很简单只负责接收请求参数然后透传RestController RequestMapping(/oss) public class OssController { private final OssSignatureService signatureService; PostMapping(/policy) public ResultMapString, String policy(RequestParam String dir, RequestParam(defaultValue 5242880) long maxSize) { return Result.success(signatureService.createPolicy(dir, maxSize)); } }返回结构我用了一套全局统一的ResultT封装code、msg、data三个字段和公司内部其他接口风格保持一致前端处理起来也统一。这一步没有技术难度但建议在项目一开始就定好不然后期每个接口返回都不一样很痛苦。5. 安全边界文件上传最容易翻车的地方5.1 后缀黑名单为什么挡不住实际风险互联网上关于文件上传漏洞的分析非常多很多CTF题目和攻防演练中都能看到这类问题的影子。核心原因层出不穷有些场景只做了简单的后缀黑名单攻击者可以通过大小写绕过如asp改成Asp或aSp、双重扩展名绕过如shell.jpg.php、特殊符号绕过如file.php.、file.php%00.jpg等方式尝试突破。更麻烦的是某些服务器中间件存在解析特性差异比如Apache在某些配置下会把test.php.jpg按PHP脚本来解析或者对多后缀文件有不同处理逻辑。这些都不是靠拉一个黑名单就能覆盖的。所以我的原则是文件名后缀统一走白名单不在白名单内的文件一律拒绝。5.2 前端限制与后端校验的双层配合前端要限制上传类型这是为了用户体验比如input的accept.jpg,.jpeg,.png,.gif,.pdf以及上传组件里的类型判断。但前端限制只是防君子不防小人真正可靠的是后端在回调阶段做二次校验。后端能做的校验有三个层级后缀白名单。比如只允许jpg/jpeg/png/gif/bmp/pdf/zip这个列表按业务需求定。Content-Type校验。比如JPEG的MIME类型应该是image/jpeg如果上传的文件声称是图片但Content-Type是application/x-php直接拒绝。Magic Number校验。这是更硬核的一招读取文件开头几个字节判断真实类型。JPEG开头是FF D8 FFPNG开头是89 50 4E 47GIF开头是47 49 46 38。这种校验能拦截大部分改装后缀的伪装文件。这里额外提一句如果你处理的文件类型需要更深入的检测比如PDF、Office文档建议引入独立的文件识别库或者调用三方内容检测服务单纯靠后缀和Content-Type远远不够。5.3 私有读写与防盗链怎么配我在第二章里反复强调Bucket权限选私有这里就实打实讲下原因。如果Bucket是公共读任何人拿到文件URL就能直接下载没有时效限制。被爬虫抓取、被违规下载、产生大量外链流量费都是可能发生的。私有Bucket配合签名URL是更稳的做法。生成签名URL的方式URL url ossClient.generatePresignedUrl( bucketName, objectKey, new Date(System.currentTimeMillis() 3600 * 1000) );这段代码生成一个有效期一小时的临时访问链接链接里带了签名参数过期自动失效。权限和时效都掌握在后端手里。如果业务上还要防止别人盗链可以在Bucket的防盗链设置里配置Referer白名单也可以接入CDN做更细粒度的访问控制。5.4 objectKey的命名设计objectKey就是文件在OSS里的存储路径它的设计直接影响后续管理效率和安全。第一不要把用户上传的原始文件名直接作为objectKey。原因很直观中文文件名、特殊字符、重名覆盖任何一个都够你头疼。我常用的命名格式{业务类型}/{yyyyMMdd}/{UUID}.{ext}比如用户头像avatar/20240401/8f14e45fceea167a5a36dedd4bea2543.jpg。业务类型解决归类问题日期目录方便后续生命周期清理UUID避免重名冲突后缀保留用于Content-Type判断。第二要注意路径穿越问题。如果你允许前端传目录参数后端必须校验这个参数不能包含..和/开头否则用户可能构造../../路径把文件写到预期之外的目录。我通常在拼接key之前做一次正则校验if (!dir.matches(^[a-zA-Z0-9-_/]{1,100}/$)) { throw new IllegalArgumentException(invalid dir); }6. 联调踩坑记录跨域、超时和文件名归一化6.1 前端直传碰到CORS签名直传模式下前端会直接向OSS的Endpoint发起POST请求这就涉及跨域。第一次联调时前端给我报了个CORS错误Access to XMLHttpRequest at https://bucket.oss-cn-hangzhou.aliyuncs.com from origin http://localhost:8080 has been blocked by CORS policy。原因很直接OSS Bucket没有配置CORS规则。解决方式是在OSS控制台选择对应Bucket进入数据安全 - 跨域设置新建规则来源填前端域名测试阶段可以写*生产环境务必收敛到具体域名。允许Methods至少勾选POST因为直传用的是表单POST。Allowed Headers*即可签名直传时前端需要带Content-Type等头。Expose Headers建议填ETag有些业务需要读取上传后返回的ETag。配置完成后等一分钟左右生效再刷新页面重试。这个坑没有技术难度但确实会卡住第一次做直传的人。6.2 回调地址可达性与超时OSS的回调是服务端发起的HTTP请求到你的接口所以回调地址必须是公网可以访问的域名或IP。测试阶段我用过内网IP地址结果OSS怎么都调不通。后来换成公网域名就好了。还有两个时间参数要注意一是OSS回调等待后端响应的超时时间默认是5秒如果你的回调接口里有耗时的业务操作比如写库、同步调用第三方服务一定要把耗时控制在几秒以内否则OSS会认为回调失败前端会收到类似CallbackFailed的错误。二是回调失败后的重试次数OSS默认有限定的重试机制但业务侧最好不要依赖重试最好把回调逻辑做成幂等的重复调用不会产生重复数据。6.3 中文文件名、空格和特殊字符前端直传时表单里的key字段如果包含中文或特殊字符OSS会返回InvalidArgument或InvalidObjectName。这个问题常见于用户上传的文件名没有经过处理。我的对策分两步前端在组装表单参数前对文件名统一做编码处理建议用JavaScript的encodeURIComponent。后端在生成签名dir时也对目录参数做归一化只允许小写字母、数字、短横线和斜杠中文目录名直接拒绝。按之前的objectKey设计key里的文件名部分直接用UUID压根不会出现中文只是这一步要在前端表单里写对逻辑。6.4 排查问题时的关键日志位置OSS的错误响应体往往是XML格式前端和后端如果只看浏览器Network里的状态码很难定位原因。我自己的排查习惯是优先看OSS返回的Code和Message字段比如InvalidAccessKeyId说明AccessKey配错了SignatureDoesNotMatch说明签名逻辑有问题AccessDenied说明权限不足。后端开启SDK的日志开关在开发环境把日志级别调到DEBUG。OSS Java SDK支持配置日志能把每一步HTTP请求的细节打出来。如果回调失败去OSS控制台的日志管理里查看请求日志确认回调是否发出、返回码是多少。这些信息比瞎猜有效得多多花几分钟看日志能省下半天联调时间。7. 再往前走一步大文件分片与断点续传7.1 multipartUpload的完整流程单文件如果超过一定大小比如视频、压缩包几百MB甚至几个GB直传表单方式就不太合适了。OSS提供MultipartUpload分片上传能力流程分三步初始化分片上传调用InitiateMultipartUploadRequest拿到一个全局唯一的uploadId。并发上传分片把文件切成固定大小的Part每个Part关联uploadId和PartNumber可以并发上传。完成分片上传所有Part传完后收集每个Part的ETag和PartNumber调用CompleteMultipartUploadRequest完成合并。Java SDK中对应的实现逻辑大致是这样InitiateMultipartUploadRequest initRequest new InitiateMultipartUploadRequest(bucketName, objectKey); InitiateMultipartUploadResult initResult ossClient.initiateMultipartUpload(initRequest); String uploadId initResult.getUploadId(); // 假设文件分片列表已经准备好 ListPartETag partETags new ArrayList(); for (int i 0; i partCount; i) { UploadPartRequest uploadPartRequest new UploadPartRequest(); uploadPartRequest.setBucketName(bucketName); uploadPartRequest.setKey(objectKey); uploadPartRequest.setUploadId(uploadId); uploadPartRequest.setPartNumber(i 1); uploadPartRequest.setInputStream(partStream); uploadPartRequest.setPartSize(partSize); UploadPartResult uploadPartResult ossClient.uploadPart(uploadPartRequest); partETags.add(uploadPartResult.getPartETag()); } CompleteMultipartUploadRequest completeRequest new CompleteMultipartUploadRequest( bucketName, objectKey, uploadId, partETags); ossClient.completeMultipartUpload(completeRequest);官方SDK还提供了UploadFileRequest这种封装好的断点续传上传接口底层自动处理分片和断点记录适合在服务端本地有完整文件路径的场景。前端上传场景一般也就是把文件切片后并行POST到OSS相对复杂一些但没有本质区别。7.2 断点续传和追加上传的选用场景断点续传不是所有业务都需要的。我自己的判断标准是文件大于100MB且用户网络不稳定值得投入做切片续传。文件小于10MB直接用普通直传就完了不要为了炫技增加前端复杂度。日志追加、渐进写入场景用AppendObject更合适比如物联网设备上报的数据文件。AppendObject可以把数据不断追加到同一个Object最多追加10000次。7.3 生命周期管理和存储类型OSS另外一个容易忽略但又很实用的配置是生命周期规则。比如你可以设置临时上传目录temp/下的文件超过7天自动删除avatar/目录下的历史头像180天后自动转归档存储。这些规则都在控制台配置不需要写代码但能帮你省掉一堆手动清理的脚本。存储类型选择也值得提一嘴标准存储适合频繁访问低频访问适合月内偶尔访问的数据归档存储适合长期不访问的备份数据但取回要额外付费和等待时间。上传的Object如果对实时性要求不高我习惯在初始化ObjectMetadata时设置存储类型避免默认全部是标准存储成本高不少。最后补一句经验OSS上传这套链路代码写起来其实没多少行真正价值在细节的安全和边界处理上。我个人在实际项目中的体验是前期把Bucket权限、RAM账号、白名单校验、回调签名这四件事一次性做对后面上线后基本不用再回头补漏洞。如果每一样都图省事后期查一次安全日志就够你熬夜好几宿的。建议你也从一个小场景开始——先用代码跑通图片上传再加白名单和回调校验最后再扩展大文件分片。这条路走顺其他对象存储MinIO、腾讯云COS换起来也是同构操作一通百通。
返回列表