0


前端不暴露ak/sk直接上传阿里云oss的方案

需求起因

以前写过一篇文章:前端不暴露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@RestControllerpublicclassOssController{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";@SneakyThrowspublicOssController(){this.client =newOSSClientBuilder().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 =newHashMap<>();// 指定ContentType,注意:必须指定,这个header加入签名了,不指定时前端带Content-Type上传,会导致签名验证不通过
        headers.put(OSSHeaders.CONTENT_TYPE, contentType);// 生成签名URL。
        GeneratePresignedUrlRequest request =newGeneratePresignedUrlRequest(bucket, ossFileName, HttpMethod.PUT);// 设置过期时间1小时。
        request.setExpiration(LocalDateTime.now().plusHours(1).toDate());// 将请求头加入到request中。
        request.setHeaders(headers);return client.generatePresignedUrl(request).toString();}}

3、前端上传代码

<!DOCTYPE html><htmllang="zh"><head><metacharset="UTF-8"><title>阿里云OSS上传演示</title><scripttype="text/javascript"src="/res/unpkg/vue.min.js"></script><scripttype="text/javascript"src="/res/unpkg/axios.min.js"></script></head><body><hr><divid="divApp"><inputtype="file"ref="fileInput1"accept="*"@change="getFile"></div><hr><script>var vueApp =newVue({
        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 =newGeneratePresignedUrlRequest(bucket, ossFileName, HttpMethod.PUT);// 设置过期时间1小时。
request.setExpiration(LocalDateTime.now().plusHours(1).toDate());// 刚开始没有加这一步:将请求头加入到request中。// request.setHeaders(headers);return client.generatePresignedUrl(request).toString();

然后前端死活上传不了,一直报错:

<Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>

前端有问题的上传代码:

axios.put(url, evt.target.files[0]).then(response =>{alert("上传成功"+ response.data);})

后面改成这样,也还是报签名错误:

let formFile =newFormData();
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 生成的签名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.1
Host: my-bucket.oss-cn-shenzhen.aliyuncs.com
Connection: keep-alive
Content-Length: 211
sec-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=----WebKitFormBoundaryts74dVVwrCRtEbp1
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36
sec-ch-ua-platform: "Windows"
Origin: http://localhost:8801
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8801/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8

------WebKitFormBoundaryts74dVVwrCRtEbp1
Content-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.1
Content-Length: 28
Host: my-bucket.oss-cn-shenzhen.aliyuncs.com
Connection: Keep-Alive
User-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.1
Host: my-bucket.oss-cn-shenzhen.aliyuncs.com
Connection: keep-alive
Content-Length: 28
sec-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-urlencoded
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36
sec-ch-ua-platform: "Windows"
Origin: http://localhost:8801
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8801/
Accept-Encoding: gzip, deflate, br
Accept-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,返回值里增加签名前的数据,你这个签名算法是公开的,返回值不增加你的关键密钥就可以了。

本文转载自: https://blog.csdn.net/youbl/article/details/131454580
版权归原作者 游北亮 所有, 如有侵权,请联系我们删除。

“前端不暴露ak/sk直接上传阿里云oss的方案”的评论:

还没有评论