在典型的服务端和客户端架构下,常见的文件上传方式是服务端代理上传:客户端将文件上传到业务服务器,然后业务服务器将文件上传到OSS。在这个过程中,一份数据需要在网络上传输两次,会造成网络资源的浪费,增加服务端的资源开销。为了解决这一问题,您可以在客户端直连OSS来完成文件上传,无需经过业务服务器中转。
官方文档其实都写了, 只是不仔细看的话,很难搞出来最安全的组合 :
上传文件到OSS需要使用RAM用户的访问密钥(AccessKey)来完成签名认证,但是在客户端中使用长期有效的访问密钥,可能会导致访问密钥泄露,进而引起安全问题。
目前一共有以下几种方式可以让用户直传到OSS:
客户端持有长期AK/SK这种方式肯定不能用; 签名URL这种比较简单, 本文不考虑. 以下表格是其中三种方式的对比.
协议 | Post Policy | STS Policy | STS + Post Policy |
---|---|---|---|
安全性 | 相对安全, 会暴露长期AK. 可以加较多限制 | 相对安全, 会暴露临时AK, Secret. 因为要多次使用, 必须放开某个目录权限. 需客户端自己控制避免覆盖文件, 无法控制文件类型, callback等字段限制 |
安全, 仅暴露临时AK, Token. 可以加较多限制 |
请求频率 | 可以每次上传申请 | 必须缓存Policy(一般建议业务调用方使用服务端缓存, 页面JS可能生命周期较短), 频繁调用STS服务会引起限流; 客户端: 过期后重新获取(一般为30分钟, 需和服务提供方确认) |
可以每次上传获取 服务器端: 缓存Sts Token并做过期检查 |
断点续传/分片上传 | 不支持 | 支持 | 不支持 |
使用方式 | 通过构建HTML表单上传的方式上传文件(JS, Java均可) | 可以使用 OSS SDK上传(JS, Java都有SDK) | 通过构建HTML表单上传的方式上传文件(JS, Java均可) |
我们可以看到, 如果需要使用”断点续传/分片上传”, 则只能使用STS Policy方式, 但是无法做过多的细节限制. 而简单的Post Policy方式, 会直接暴露长期AK给客户端. 而使用 STS + Post Policy的方式, 暴露给客户端的是临时AK和比较严格的Post Policy, 一举二得, 就是生成Policy的代码有点多.
那我们希望对客户端做哪些控制哪
我们先看看如何生成这个Policy, 流程如下:
/**
* STS Service. for test.
*
* @author Felix Zhang 2024-07-22 14:34
* @version 1.0.0
*/
@Service
@Slf4j
public class StsServiceImpl {
//注: 以下配置使用Spring的属性自动设置
@Value("${sts.endpoint}")
private String stsEndpoint;
@Value("${sts.regionId}")
private String stsRegionId;
@Value("${sts.accessKeyId}")
private String stsAccessKeyId;
@Value("${sts.accessKeySecret}")
private String stsAccessKeySecret;
@Value("${sts.roleSessionName}")
private String roleSessionName;
@Value("${sts.role-arn}")
private String roleArn;
@Value("${sts.stspostrampolicy}")
private String stspostrampolicy;
@Value("${sts.stsrampolicy}")
private String stsrampolicy;
@Value("${sts.durationSeconds}")
private Long durationSeconds;
@Value("${oss.tempPath}")
private String tempPath;
@Value("${oss.publicTempPath}")
private String publicTempPath;
@Value("${oss.bucket.tmp}")
private String tmpBucket;
@Value("${oss.endpoint}")
private String endpoint;
Map<String, StsCredentialInfo> stsTokenCache = new HashMap<>();
//获取可用的StsToken
public StsCredentialInfo fetchUsableStsToken(boolean forPostPolicy, String source,
String fileLevel, boolean forcenewpolicy)
throws Exception {
String stsTokenCacheKey = source + "_" + fileLevel;
boolean ok = false;
StsCredentialInfo theInfo = null;
if (!forcenewpolicy) {
theInfo = stsTokenCache.get(stsTokenCacheKey);
if (theInfo != null) {
//如果仅仅是使用普通的StsPolicy, 是否还保留30分钟冗余哪?
//检查是否过期保证期内 30分钟: 留给Post Signature 30分钟, 所以只有30分钟了
if ((theInfo.getExpire() * 1000 + 1800 * 1000) < System.currentTimeMillis()) {
ok = true;
}
}
}
if (!ok) {
theInfo = generateStsToken(forPostPolicy, source, fileLevel);
}
return theInfo;
}
//按source+fileLevel: 缓存到一个Map
private StsCredentialInfo generateStsToken(boolean forPostPolicy, String source,
String fileLevel)
throws Exception {
// Init ACS Client
DefaultProfile.addEndpoint("", "", "Sts", stsEndpoint);
IClientProfile profile = DefaultProfile.getProfile(stsRegionId, stsAccessKeyId, stsAccessKeySecret);
DefaultAcsClient client = new DefaultAcsClient(profile);
// Optional
final AssumeRoleRequest request = new AssumeRoleRequest();
request.setMethod(MethodType.POST);
request.setRoleArn(roleArn);
request.setRoleSessionName(roleSessionName);
request.setDurationSeconds(durationSeconds);
//设置虚拟角色的Policy, 缩小权限
String mypolicy = dynamicCreateRamPolicy(forPostPolicy, source);
request.setPolicy(mypolicy);
log.info("ram policy: \n" + mypolicy);
final AssumeRoleResponse response = client.getAcsResponse(request);
// set result
AssumeRoleResponse.Credentials credentials = response.getCredentials();
StsCredentialInfo stsCredentialInfo = new StsCredentialInfo();
stsCredentialInfo.setAccessKeyId(credentials.getAccessKeyId());
stsCredentialInfo.setAccessKeySecret(credentials.getAccessKeySecret());
stsCredentialInfo.setSecurityToken(credentials.getSecurityToken());
stsCredentialInfo.setExpiration(credentials.getExpiration());
Date date = DateUtil.parseIso8601Date(credentials.getExpiration());
stsCredentialInfo.setExpire( date.getTime() / 1000);
//Post Signature不需要, 它会自己设置
//String callbackStr = generateCallback();
//stsCredentialInfo.setCallback(callbackStr);
// 指定ossKey, 加上业务名
if (fileLevel.equals("privateFile")) {
String ossKey = tempPath + "/" + source + "/";
stsCredentialInfo.setBucketName(tmpBucket);
stsCredentialInfo.setUploadPath(ossKey);
//ossTmpTokenDTO.setFileId(IdWorker.getId()); //干嘛用?!
}
//todo 注意区分图像和文件
else if (fileLevel.startsWith("public")) {
String ossKey = publicTempPath + "/" + source + "/";
stsCredentialInfo.setBucketName(tmpBucket);
stsCredentialInfo.setUploadPath(ossKey);
}
stsCredentialInfo.setEndPoint(endpoint);
String stsTokenCacheKey = source + "_" + fileLevel;
stsTokenCache.put(stsTokenCacheKey, stsCredentialInfo);
return stsCredentialInfo;
}
private String dynamicCreateRamPolicy(boolean forPostPolicy, String source) {
String theRamPolicy = stsrampolicy;
if(forPostPolicy){
theRamPolicy = stspostrampolicy;
}
//动态替换
return theRamPolicy.replace("{source}", source);
}
}
其中使用的临时角色权限设定如下(stspostrampolicy):
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:PutObject"
],
"Resource": [
"acs:oss:*:*:xxx-test-tmp/resource-temp/{source}/*",
"acs:oss:*:*:xxx-test-pub/openfile-temp/{source}/*",
"acs:oss:*:*:xxx-test-pub/openimage-temp/{source}/*"
]
},
{
"Effect": "Deny",
"Action": [
"oss:PutObject"
],
"Resource": [
"acs:oss:*:*:xxx-test-tmp/resource-temp/{source}/*",
"acs:oss:*:*:xxx-test-pub/openfile-temp/{source}/*",
"acs:oss:*:*:xxx-test-pub/openimage-temp/{source}/*"
],
"Condition": {
"StringEquals": {
"oss:x-oss-object-acl": [
"public-read",
"public-read-write"
]
}
}
}
]
}
可以看到只有PubObject的权限, 而且只能传到临时目录下. (后续通过其他调用移动到正式目录下); 而且只能是private权限的文件.
@Value("${oss.endpoint}")
private String endpoint;
@Value("${oss.accessKeyId}")
private String accessKeyId;
@Value("${oss.accessKeySecret}")
private String accessKeySecret;
@Value("${oss.bucket.tmp}")
private String tmpBucket;
@Value("${oss.tempPath}")
private String tempPath;
@Value("${oss.publicTempPath}")
private String publicTempPath;
@Value("${oss.defaultFolder.tmp}")
private String defaultTempFolder;
@Value("${oss.callbackUrl}")
private String callbackUrl;
@Value("${oss.defaultFolder.resourceTemp}")
private String defaultTemporaryFolder;
private OSSClient ossClient;
public OSSClient newClient(String theAccessKeyId, String theAccessKeySecret) {
ClientConfiguration conf = new ClientConfiguration();
return new OSSClient(endpoint, new DefaultCredentialProvider(theAccessKeyId, theAccessKeySecret), conf);
}
public Map<String, String> generatePostPolicy(String fileLevel, String md5,
String source, String requestId) throws Exception {
return generatePostPolicy(ossClient, fileLevel, md5, source, requestId);
}
public Map<String, String> generatePostPolicy(OSSClient theClient, String fileLevel, String md5,
String source, String requestId) throws Exception {
String bucket = tmpBucket;
if (StringUtils.isNotBlank(fileLevel) && (fileLevel.equals("publicFile") || fileLevel.equals("publicImage"))) {
//Todo 注意: 此处使用xxx-pub这个bucket
bucket = tmpBucket;
}
String host = "https://" + bucket + "." + endpoint;
// 用户上传文件时指定的前缀, 统一上传到临时path中
// 限定业务目录, private/public** 实际情况会不一样, 注意修改
String dir = tempPath + "/" + source + "/";
//todo 注意区分图像和文件
if (fileLevel.startsWith("public")) {
dir = publicTempPath + "/" + source + "/";
}
Map<String, String> respMap = null; //返回结果
try {
long expireTime = 1800;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
// PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 5 * 1024 * 1024 * 1024L);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
//限定bucket
policyConds.addConditionItem("bucket", bucket);
Map<String, Object> callback = getCallback(md5, source, requestId);
String callbackData = BinaryUtil.toBase64String(JSONUtil.parse(callback).toString().getBytes(StandardCharsets.UTF_8));
//增加callback限制条件
policyConds.addConditionItem("callback", callbackData);
//强制要求 x-oss-forbid-overwrite
policyConds.addConditionItem("x-oss-forbid-overwrite", "true");
//限制图像类型 注意补全图片类型
if (fileLevel.equals("publicImage")) {
policyConds.addConditionItem(MatchMode.In, COND_CONTENT_TYPE, images);
}
log.info("policy cond: " + policyConds.jsonize());
//生成policy输出字符串
String postPolicy = theClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes(StandardCharsets.UTF_8);
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = theClient.calculatePostSignature(postPolicy);
respMap = new LinkedHashMap<String, String>();
respMap.put("accessid", theClient.getCredentialsProvider().getCredentials().getAccessKeyId());
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("policyexpire", String.valueOf(expireEndTime / 1000));
respMap.put("callback", callbackData);
respMap.put("bucket", bucket);
}
catch (Exception e) {
log.error("获取oss签名失败:" + e);
throw new Exception(e);
}
return respMap;
}
//callback限定
private Map<String, Object> getCallback(String md5, String source, String requestId) {
String sourceId = source + "_" + requestId;
Map<String, Object> callback = new HashMap<>();
callback.put("callbackUrl", callbackUrl);
callback.put("callbackBody", ("{\"mimeType\":${mimeType},\"contentMd5\":${contentMd5},\"size\":${size},\"bucket\":${bucket},\"object\":${object}," +
"\"md5\":\"${md5}\",\"userparamuserid\":${x:userparamuserid},\"sourceId\":\"${sourceId}\"}")
.replace("${md5}", Objects.nonNull(md5) ? md5 : "").replace("${sourceId}", sourceId));
callback.put("callbackBodyType", "application/json");
return callback;
}
可以看到我们在Post Policy里增加了文件大小, 目录, bucket, callback, 强制覆盖参数, 图像类型等强制限制, 如果客户端不遵循规则, 则会被拒绝上传, 保证了OSS服务的安全有序.
下面是组合输出给客户端的组合代码
/**
* 使用角色扮演临时AK生成 Post Policy. (既不暴露长期AK, 也能做强制性的细粒度限制; 需要考虑二个过期时间的协调问题)
*/
@RequestMapping("/stspostpolicy")
public ApiResult stsPostPolicy(@RequestParam(defaultValue = "labtest") String source, String md5,
@RequestParam(defaultValue = "privateFile") String fileLevel,
@RequestParam(required = false) boolean forcenewpolicy) {
String uuid = UUID.randomUUID().toString();
StsCredentialInfo stsCredentialInfo;
OSSClient ossClient = null;
try {
//
stsCredentialInfo = stsService.fetchUsableStsToken(true, source, fileLevel, forcenewpolicy);
//临时client
ossClient = ossClientService.newClient(stsCredentialInfo.getAccessKeyId(), stsCredentialInfo.getAccessKeySecret());
//根据StsPolicy生成PostSignature
Map<String, String> map = ossClientService.generatePostPolicy(ossClient, fileLevel, md5, source, uuid);
//增补信息
map.put("securityToken", stsCredentialInfo.getSecurityToken());
map.put("stsTokenExpiration", stsCredentialInfo.getExpiration()); //token的过期时间, 用作排查
map.put("stsTokenExpire", String.valueOf(stsCredentialInfo.getExpire()));
return ApiResult.succeed(UrcResultCode.RESULT_CODE__SUCCESS, "请求临时权限成功", map).setRequestId(uuid);
}
catch (Exception e) {
log.error("请求临时权限失败", e);
return ApiResult.succeed(UrcResultCode.RESULT_CODE__INNER_ERROR, "请求临时权限失败," + e, null).setRequestId(uuid);
}
finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
然后客户端(JS, Java都可以是客户端)就可以根据Policy直接上传文件到OSS了, 但不能违反STS临时凭证和Post Policy的限制.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>上传文件到OSS</title>
</head>
<body>
<div class="container">
<form>
<div class="mb-3">
<label for="file" class="form-label">选择文件</label>
<input type="file" class="form-control" id="file" name="file" required/>
</div>
<button type="submit" class="btn btn-primary">上传</button>
</form>
</div>
<script type="text/javascript">
const form = document.querySelector("form");
const fileInput = document.querySelector("#file");
form.addEventListener("submit", (event) => {
event.preventDefault();
const file = fileInput.files[0];
const filename = fileInput.files[0].name;
fetch("/lab/stspostpolicy?source=labtest2&fileLevel=publicImage", {method: "POST"})
.then((response) => {
if (!response.ok) {
throw new Error("获取签名失败");
}
return response.json();
})
.then((res) => {
const data = res.data;
const formData = new FormData();
formData.append("OSSAccessKeyId", data.accessid);
formData.append("signature", data.signature);
//sts token
formData.append("x-oss-security-token", data.securityToken);
//post policy
formData.append("policy", data.policy);
formData.append("success_action_status", "200");
formData.append("name", filename);
formData.append("key", data.dir + filename);
//测试: 如果没有callback 或错误callback, 测试成功, 符合预期
formData.append("callback", data.callback)
//callback var 有三种设置方式 https://help.aliyun.com/zh/oss/developer-reference/callback
//变量必须为小写: 测试可行
formData.append("x:userparamuserid", "123456");
//测试: x-oss-forbid-overwrite 测试成功, 符合预期
formData.append("x-oss-forbid-overwrite", "true")
//测试 content-type: 限制成功, 符合预期
formData.append("file", file);
return fetch(data.host, {method: "POST", body: formData});
})
.then((response) => {
if (response.ok) {
console.log("上传成功");
alert("文件已上传");
} else {
console.log("上传失败", response);
alert("上传失败,请稍后再试");
}
})
.catch((error) => {
console.error("发生错误:", error);
});
});
</script>
</body>
</html>
@RestController
@RequestMapping("/lab/clientdemo")
@Slf4j
public class StsPostPolicyUploadDemo {
public static final String host = "http://127.0.0.1:9006"; //本地
public static final String upload_policy_path = "/lab/stspostpolicy";
private final CloseableHttpClient httpClient = HttpClients.createDefault();
//临时文件, 用来测试上传
String resourceFilePath = "/tempfile/sample01.mp4";
@RequestMapping("/stspost")
public String testStsPostPolicyUpload() throws Exception {
//获取上传policy接口
String policyResult = fetchUploadPolicy();
JSONObject jsonObject = JSONObject.parseObject(policyResult);
boolean success = jsonObject.getBoolean("success");
if (!success) {
return "failed";
}
JSONObject dataObject = jsonObject.getJSONObject("data");
//上传文件到OSS
String dir = dataObject.getString("dir");
String host = dataObject.getString("host");
String policy = dataObject.getString("policy");
String sig = dataObject.getString("signature");
String accessid = dataObject.getString("accessid");
String securityToken = dataObject.getString("securityToken");
String callback = dataObject.getString("callback");
String extension = resourceFilePath.substring(resourceFilePath.lastIndexOf("."));
String storageKey = dir + UUID.randomUUID() + "." + extension;
LinkedHashMap<String, String> param = new LinkedHashMap<>();
param.put("callback", callback);
//设置动态变量
param.put("x:userparamuserid", "123456");
param.put("key", storageKey);//object name
param.put("OSSAccessKeyId", accessid);
param.put("policy", policy);
param.put("Signature", sig);
param.put("x-oss-security-token", securityToken);
param.put("x-oss-forbid-overwrite", "true");
//如果是文件, 则读取文件
byte[] content = IOUtils.resourceToByteArray(resourceFilePath);
String uploadResult = uploadFile(host, param, resourceFilePath, content);
return uploadResult;
}
/**
* 获取Sts Post 方式的Policy.
*/
public String fetchUploadPolicy() throws IOException {
String url = host + upload_policy_path;
HttpPost httpRequest = new HttpPost(url);
List<NameValuePair> nameValuePairs = new ArrayList<>();
NameValuePair param1NameValuePair = new BasicNameValuePair("source", "atest");
NameValuePair param2NameValuePair = new BasicNameValuePair("fileLevel", "privateFile");
nameValuePairs.add(param1NameValuePair);
nameValuePairs.add(param2NameValuePair);
httpRequest.setEntity(new UrlEncodedFormEntity(nameValuePairs, StandardCharsets.UTF_8));
CloseableHttpResponse httpResponse = httpClient.execute(httpRequest);
HttpEntity httpEntity = httpResponse.getEntity();
return EntityUtils.toString(httpEntity);
}
/**
* 上传文件. 仅供参考, 实际中要考虑各种细节.
*/
public String uploadFile(String url, LinkedHashMap<String, String> map, String fileName, byte[] content)
throws IOException {
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
ContentType contentType = ContentType.create("application/mp4", StandardCharsets.UTF_8);
HttpPost httpPost = new HttpPost(url);
for (String key : map.keySet()) {
builder.addPart(key, new StringBody(map.get(key), contentType));
log.debug("formData: " + key + ":" + map.get(key));
}
builder.addBinaryBody("file", content, contentType, fileName);
httpPost.setEntity(builder.build());
CloseableHttpResponse httpResponse = httpClient.execute(httpPost);
InputStream ips = httpResponse.getEntity().getContent();
return IOUtils.toString(ips, StandardCharsets.UTF_8);
}
@PostMapping("/upload-callback")
public ApiResult ossUploadBack(@RequestBody JSONObject callbackBody) throws Exception {
log.info("[OSS上传callback] content:{}", callbackBody);
//...校验签名/合法性, 处理结果
return ApiResult.succeed(UrcResultCode.RESULT_CODE__SUCCESS, "请求签名成功", null);
}
}
因为强制客户端设置了callback回调, 所以每次用户上传文件, 服务端都能收到OSS的回调, 我们把相关文件信息记录下来, 就可以做后续的处理了.
我们可以看到在callback里设定了几种变量:
至此, 我们就完成了一个很安全的客户端直传方案, 可以很放心地让客户端直传到OSS上了.
Page PV: