需求起因
以前写过一篇文章:前端不暴露ak/sk直接上传aws S3的方案
因为项目里还用到的阿里云的oss上传,就研究了阿里云是不是也有避免ak/sk泄露到前端的方案,
这里也复述一下这么做的原因:
常规上传方案,为避免ak/sk被用户知道,导致文件泄露、篡改,
通常是前端上传文件到后端,再由后端上传到阿里云oss,参考架构:
用户 => 浏览器选文件 => 后端服务器 => oss
这个方案的问题点:
- 链路长,上传慢,因为多了一个中间节点,时间多花一倍,
既然链路长了,那么出现超时中断错误的概率就更高了; - 如果文件太大,后端服务器还不能接收,需要修改默认配置,比如SpringBoot默认上传最大1M,但是修改它又可能导致额外的性能问题,比如好几个人同时上传大文件,这个服务可能就无法响应其它用户请求了,严重的还会导致雪崩;
- 可能会多了不必要的流量费用,一般云服务器的流量流出都要收费的,比如阿里云应该是8毛钱/GB,
那么上传1G的文件,服务器收到1G流量,再上传oss,输出1G流量,中间如果涉及公网或跨区传输,会多花费用
解决方案
查阅了一下阿里云的文档,确实提供了aws类似的解决方案:
1、后端去oss生成一个有时间限制的签名aliyuncs.com域名的url
2、前端通过这个url直接上传oss
这样,ak/sk还是存储在后端,没有了被前端暴露的风险,而且url有时间限制,过了就失效了。
参考官方文档:Java使用签名URL临时授权上传或下载文件
但是,这个官方文档里,只有Java签名url,再用Java上传的Demo,翻了一下没找到javascript版本的demo,只好自己研究了一下实现,下面简述一下实现步骤。
实现步骤
本文基于SpringBoot2.3.7.RELEASE
注:本文demo代码已经上传到github了,有问题可以点这里下载这份代码,在本地运行和验证
1、pom依赖引入,添加阿里云sdk引入:
<dependency><groupId>com.aliyun.oss</groupId><artifactId>aliyun-sdk-oss</artifactId><version>3.16.1</version></dependency>
2、后端增加生成签名url的接口:
需要注意的是,接口要2个参数:
要上传的目标文件相对路径 和 文件的ContentType
package beinet.cn.frontstudy.oss;import com.aliyun.oss.HttpMethod;import com.aliyun.oss.OSS;import com.aliyun.oss.OSSClientBuilder;import com.aliyun.oss.internal.OSSHeaders;import com.aliyun.oss.model.GeneratePresignedUrlRequest;import lombok.SneakyThrows;import lombok.extern.slf4j.Slf4j;import org.joda.time.LocalDateTime;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import java.util.HashMap;import java.util.Map;@Slf4j@RestControllerpublic class OssController {private OSS client;// 下面4个参数,是上传到s3的必需配置/* 注意:绝对不要把ak sk写在代码里,或写在配置里,泄露会导致oss数据泄露,被删除,被占用等不可预知的后果 建议: 1、安全性较低:加密后写入配置文件,代码里解密,参考: https://youbl.blog.csdn.net/article/details/122603550 2、安全性较高:由运维在服务器上配置环境变量,程序中读取环境变量使用*/private String accessKey = "I'm ak";private String secretKey = "I'm sk";private String region = "oss-cn-shenzhen";// 常用Region参考: https://help.aliyun.com/document_detail/140601.htmlprivate String endpoint = "https://" + region + ".aliyuncs.com";private String bucket = "my-bucket";@SneakyThrowspublic OssController() {this.client = new OSSClientBuilder().build(endpoint, accessKey, secretKey);}/** * 生成一个预签名的url,给前端js上传 * 参考官网文档: https://help.aliyun.com/document_detail/32016.html * * @param ossFileName 上传到oss的文件相对路径 * @param contentType 签名里会加入contentType进行计算,因此此参数必须 * @return 签名后的url */@GetMapping("oss/sign")public String preUploadFile(@RequestParam String ossFileName, @RequestParam String contentType) {// 设置请求头。Map<String, String> headers = new HashMap<>();// 指定ContentType,注意:必须指定,这个header加入签名了,不指定时前端带Content-Type上传,会导致签名验证不通过headers.put(OSSHeaders.CONTENT_TYPE, contentType);// 生成签名URL。GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, ossFileName, HttpMethod.PUT);// 设置过期时间1小时。request.setExpiration(LocalDateTime.now().plusHours(1).toDate());// 将请求头加入到request中。request.setHeaders(headers);return client.generatePresignedUrl(request).toString();}}
3、前端上传代码
<html lang="zh"><head><meta charset="UTF-8"><title>阿里云OSS上传演示</title><script type="text/javascript" src="/res/unpkg/vue.min.js"></script><script type="text/javascript" src="/res/unpkg/axios.min.js"></script></head><body><hr><div id="divApp"><input type="file" ref="fileInput1" accept="*" @change="getFile"></div><hr><script>var vueApp = new Vue({el: '#divApp',data: function () {return {title: '阿里云OSS-免ak/sk上传演示代码',ossSignUrl: '',}},methods: {getOssSignUrl: function (type) {// 因为rfc2616协议要求,语法有body必须有Content-Type的header,而oss又会对这个header进行签名计算,所以获取签名url时,要指定Content-Typelet url = '/oss/sign?contentType=' + type + '&ossFileName=abc/signFile123.xxx';return axios.get(url).then(response => {this.ossSignUrl = response.data;}).catch(error => this.ajaxError(error));},// 获取文件数据getFile: function (event) {let type = event.target.files[0].type;this.getOssSignUrl(type).then(() => {this.uploadToSignUrl(event, type);});},uploadToSignUrl: function (evt, type) {// 通过fiddler抓包测试,body直接就是文件的内容,不能带有其它格式axios({method: "PUT",url: this.ossSignUrl,data: evt.target.files[0],transformRequest: [function (data, headers) {//delete headers.common['Content-Type'];headers.put['Content-Type'] = type;return data;}],}).then(response => {alert("上传成功" + response.data);}).catch(error => this.ajaxError(error));},ajaxError: function (error) {alert('未知错误' + error.message);},},});</script></body></html>
过程中踩的坑
本以为前端上传,直接复用aws的代码就可以了,结果调试了半天才找到问题修复问题,踩坑过程整理如下:
第一步,使用标准的multipart/form-data上传出错
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, ossFileName, HttpMethod.PUT);// 设置过期时间1小时。request.setExpiration(LocalDateTime.now().plusHours(1).toDate());// 刚开始没有加这一步:将请求头加入到request中。// request.setHeaders(headers);return client.generatePresignedUrl(request).toString();
然后前端死活上传不了,一直报错:The request signature we calculated does not match the signature you provided. Check your key and signing method.
前端有问题的上传代码:
axios.put(url, evt.target.files[0]).then(response => {alert("上传成功" + response.data);})
后面改成这样,也还是报签名错误:
let formFile = new FormData();formFile.append("file", evt.target.files[0]);axios.put(this.ossSignUrl, formFile);
因为找不到javascript的demo,不知道问题在哪,只能继续翻阿里云文档了……
后面翻到阿里云有Java的demo:[https://help.aliyun.com/document_detail/32016.html#p-bpg-g75-6jc](https://help.aliyun.com/document_detail/32016.html#p-bpg-g75-6jc) 生成的签名url,用这边的代码,是可以正常上传的: “`java HttpPut put = new HttpPut(signedUrl.toString()); HttpEntity entity = new FileEntity(new File(pathName)); put.setEntity(entity); httpClient = HttpClients.createDefault(); response = httpClient.execute(put); “`
于是,我在本机安装了一个Fiddler,打算比对一下javascript的请求包 跟 Java的请求包,有什么差异,
抓包过程,又曲折了一番,httpClient 一直报错:unable to find valid certification path to requested target
最后干脆,把httpClient 设置为忽略ssl证书校验才正常完成抓包。
发现fiddler抓包,javascript签名异常的请求体如下:
PUT https://my-bucket.oss-cn-shenzhen.aliyuncs.com/abc/signFile123.xxx?Expires=1688011585&OSSAccessKeyId=xxx&Signature=xxx HTTP/1.1Host: my-bucket.oss-cn-shenzhen.aliyuncs.comConnection: keep-aliveContent-Length: 211sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"Accept: application/json, text/plain, */*Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryts74dVVwrCRtEbp1sec-ch-ua-mobile: ?0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36sec-ch-ua-platform: "Windows"Origin: http://localhost:8801Sec-Fetch-Site: cross-siteSec-Fetch-Mode: corsSec-Fetch-Dest: emptyReferer: http://localhost:8801/Accept-Encoding: gzip, deflate, brAccept-Language: zh-CN,zh;q=0.9,en;q=0.8------WebKitFormBoundaryts74dVVwrCRtEbp1Content-Disposition: form-data; name="file"; filename="index.txt"Content-Type: text/plain文件内容------WebKitFormBoundaryts74dVVwrCRtEbp1--
能正常上传的Java抓包请求体如下:
PUT https://my-bucket.oss-cn-shenzhen.aliyuncs.com/abc/signFile123.xxx?Expires=1688011757&OSSAccessKeyId=xxx&Signature=xxx HTTP/1.1Content-Length: 28Host: my-bucket.oss-cn-shenzhen.aliyuncs.comConnection: Keep-AliveUser-Agent: Apache-HttpClient/4.5.14 (Java/11)Accept-Encoding: gzip,deflate文件内容
哦,原来阿里云不是按标准的multipart/form-data
上传数据格式来接收文件啊,直接按body是完整文件内容形式读取,好吧,吐槽一下,就不能按标准的文件上传规范来操作吗?
第二步,前端上传多了Content-Type导致签名出错
阿里云牛,我改,前端代码改成直接传文件:
axios({method: "PUT",url: this.ossSignUrl,data: evt.target.files[0],})
直接把文件内容写入body,这回应该没错了吧。
一测试,还是报签名错误,抓包一看:
PUT https://my-bucket.oss-cn-shenzhen.aliyuncs.com/abc/signFile123.xxx?Expires=1688019898&OSSAccessKeyId=xxx&Signature=xxx HTTP/1.1Host: my-bucket.oss-cn-shenzhen.aliyuncs.comConnection: keep-aliveContent-Length: 28sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"Accept: application/json, text/plain, */*Content-Type: application/x-www-form-urlencodedsec-ch-ua-mobile: ?0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36sec-ch-ua-platform: "Windows"Origin: http://localhost:8801Sec-Fetch-Site: cross-siteSec-Fetch-Mode: corsSec-Fetch-Dest: emptyReferer: http://localhost:8801/Accept-Encoding: gzip, deflate, brAccept-Language: zh-CN,zh;q=0.9,en;q=0.8文件内容
body已经不是form-data格式了,怎么还会出错?
幸好fiddler有编辑请求,重放请求功能,一步一步删除多余的header,最后发现删除了Content-Type之后,上传就成功了……
ok,我修改前端代码,删除这个Content-Type就好了,代码修改后如下:
axios({method: "PUT",url: this.ossSignUrl,data: evt.target.files[0],transformRequest: [function (data, headers) {delete headers.common['Content-Type'];delete headers.put['Content-Type'];return data;}],})
怎么还是报错?抓包看,请求里还是有Content-Type啊?!?
查了一下axios,在git官方代码那边有个issue:https://github.com/axios/axios/issues/1672
里面并没有说这是bug,且也不考虑修复这个问题。
我尝试了一些其它方案,依旧没能删除这个请求里的Content-Type.
第三步、逆推解决问题
后面我想起在rfc协议里,应该是要求要提供Content-Type这个Header的,查阅了一下rfc2616协议,里面有这样一段内容:
7.2.1 Type When an entity-body is included with a message, the data type of that body is determined via the header fields Content-Type and Content- Encoding. These define a two-layer, ordered encoding model: entity-body := Content-Encoding( Content-Type( data ) ) Content-Type specifies the media type of the underlying data. Content-Encoding may be used to indicate any additional content codings applied to the data, usually for the purpose of data compression, that are a property of the requested resource. There is no default encoding. Any HTTP/1.1 message containing an entity-body SHOULD include a Content-Type header field defining the media type of that body. If and only if the media type is not given by a Content-Type field, the recipient MAY attempt to guess the media type via inspection of its content and/or the name extension(s) of the URI used to identify the resource. If the media type remains unknown, the recipient SHOULD treat it as type "application/octet-stream".
最后一段的大意,就是有body的http消息,SHOULD包含Content-Type,大写就是rfc强烈建议你加这个头,不加就会让接收者去猜测。
因此,我反过来思考,签名时添加这个header就好了,为什么一定要删除它呢?
于是改造代码,在签名url的方法那边增加一个Content-Type参数,上传oss时,使用相同的参数就好了。
最后,再吐槽一下阿里云:
- 为什么不使用标准的上传格式
multipart/form-data
来做上传呢?
这是标准的文件上传格式,至少应该兼容一下吧; - 为什么不提供javascript版本的demo代码呢?
阿里云提供了签名上传方案,稍微深入思考一下,就知道在Web盛行的当下,应该是前后端分离,所以应该提供一个完整的前后端Demo。 - 签名错误能否提供调试能力?
例如url上加一个debug=true,返回值里增加签名前的数据,你这个签名算法是公开的,返回值不增加你的关键密钥就可以了。