前端不暴露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
@RestController
public 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.html
    private String endpoint = "https://" + region + ".aliyuncs.com";
    private String bucket = "my-bucket";

    @SneakyThrows
    public 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、前端上传代码

<!DOCTYPE html>
<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-Type
                let 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();

然后前端死活上传不了,一直报错:<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 = 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.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,返回值里增加签名前的数据,你这个签名算法是公开的,返回值不增加你的关键密钥就可以了。

猜你喜欢

转载自blog.csdn.net/youbl/article/details/131454580
今日推荐