环境是 .NET 6.0,实现以下几个方面:
- 服务端采用 .NET SDK 生成上传签名和回调配置
- Layui 用自带的 Upload 组件直接上传至 OSS
- 回调的 WebApi 中,对 OSS 的请求进行签名验证
1 .NET SDK 生成授权码和回调配置
SDK 直接使用官方提供的 点击获取
注:根据阿里云的文档,还有一个升级版的 .NET SDK,但是经过测试,问题还比较多,不够成熟。
生成上传签名的逻辑,比较简单(注意看注释说明),C# 代码如下:
// Endpoint 不带 Bucket,如:https://oss-cn-hangzhou.aliyuncs.com
var client = new OssClient("<Endpoint>", "<AccessId>", "<AccessKey>");
// 添加大小限制和前缀,如果有前缀,一定要以 / 结尾
var config = new PolicyConditions();
config.AddConditionItem(PolicyConditions.CondContentLengthRange, 1, 10485760); // 文件大小范围:1byte - 10M
config.AddConditionItem(MatchMode.StartWith, PolicyConditions.CondKey, "<自定义前缀>/");
// 过期时间
var expire = DateTimeOffset.Now.AddSeconds(120);
// 生成 Policy,并进行 Base64 编码
var policy = client.GeneratePostPolicy(expire.LocalDateTime, config);
var policyBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(policy));
// 计算签名
var hmac = new HMACSHA1(Encoding.UTF8.GetBytes("<AccessKey>"));
var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(policyBase64));
var sign = Convert.ToBase64String(bytes);
// 签名计算完毕,开始配置回调参数(签名和回调,可以分开,不用参与签名计算)
// 在文件上传完毕后,OSS 会向指定的地址,发送一个 POST 请求
// 并且,会将 POST 请求的返回结果,作为本次上传的结果返回给前端
// 特别说明:经过多次测试,都没能把 Json 测试成功,所以,采用了官方的 Form 方式
var callback = new
{
CallbackUrl = "<自定义的 WebApi 地址,如:http://www.abcd.com/api/oss/callback>", // 必填
//CallbackHost = "www.abcd.com",
CallbackBody = "imageName=${object}&userId=${x:userid}", // 必填
//CallbackBodyType = "application/x-www-form-urlencoded"
};
// 本文采用 System.Text.Json
var jsonOpts = new JsonSerializerOptions
{
IncludeFields = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
// 将签名和回调的内容,返回给前端
return new
{
AccessId = "<AccessId>",
Host = "<Bucket>.oss-cn-hangzhou.aliyuncs.com",
Dir = "<前缀>/",
Policy = policyBase64,
Signature = sign,
Expire = expire.ToUnixTimeSeconds(),
Callback = JsonSerializer.Serialize(callback, jsonOpts),
UserId = 123456
};
回调的参数说明详见 官方文档
官方参数说明,见上,补充说明如下:
-
CallbackUrl 官方说支持 HTTPS,但是本人多次测试,都无法成功,均返回 502 错误,如果碰到相同错了,建议试试将地址改为 HTTP 访问(必须服务器支持)
如果回调的接口方,配置了 app.UseHttpsRedirection(),一定注意,OSS 的请求一旦收到30x的跳转,也会判定为回调失败
还有就是,如果回调接口内部,存在逻辑错误,比如抛出异常,OSS 回调会返回 400 错误 -
CallbackBody 系统参数就不过多说明,详见上面的官方文档,主要说说自定义参数,在构造 Form 表单的参数时,参数名,可以任意命名,但是有两点要注意:
- 自定义参数的格式,必须是
${x:<占位符>}
,x:
一定不能少 - 自定义参数的占位符,就是
${x:<占位符>}
部分,花括号内部的名称,必须全部小写,比如:可以是userName=${x:username}&Age=${x:age}
也可以是username=${x:username}&age=${x:age}
,总之,注意占位符全部小写就行了,参数名,按照任意命名方式都可以,回调接口内注意读取就行了 - 可以不用自定义占位符,直接对参数进行赋值,比如:
username=张三&age=20
- 自定义参数的格式,必须是
至此,上传签名,和回调的参数配置,就全部完成了,返回给前端即可。
2 Layui 自带的 Upload 组件
其他上传组件,请自行测试,如果使用 Layui 的 Upload 组件,有一个地方需要注意。
OSS 对于 POST 请求有一个限制,表单域最后一个参数,必须是 file(详见阿里云官方说明),如下图所示:
Layui 的 Upload 组件,是将 file 放置在请求表单域的第一个,源码如下(2.7.6/2.8.0 Beta2均如此):
知道了地方,修改也就简单了,就是把这两部分的位置对调,就可以了,Javascript 代码如下所示:
// 其他代码
var formData = new FormData();
// 先追加其他参数到表单域
layui.each(options.data, function(key, value) {
value = typeof value === 'function' ? value() : value;
formData.append(key, value);
});
// 最后追加 file 到表单域
formData.append(options.field, file);
// 其他代码
如果使用的是压缩后的 Layui 代码,在代码中,查找 FormData
关键字,然后调整位置,就可以了。
修改了源码这个位置后,其他就按照 Layui 的官方文档配置就行了,代码如下(如果回调设置了自定义占位符,记得在发送请求时,将占位符和对应的值,添加到请求中):
layui.upload.render({
elem: '<上传元素的选择器>',
url: '<C# 返回的 Host>',
data: {
key: '<C# 返回的 Dir>' + '${filename}', // ${filename} 在 OSS 上传时,表示原文件名
policy: '<C# 返回的 Policy>',
OSSAccessKeyId: '<C# 返回的 Policy>',
success_action_status: '200', // 限制为 200,不然 OSS 可能返回 203
signature: '<C# 返回的 Signature>',
callback: '<C# 返回的 Callback>',
"x:userid": '<C# 返回的 UserId>' // "x:userid" 该名称,和占位符一致,必须全部小写
},
choose: function(obj) {
// 选择完文件后的逻辑
},
before: function(obj) {
// 上传前的逻辑
},
progress: function(percent, elem, res, index) {
// 监听上传进度
},
done: function(res, index, upload) {
// 上传成功后的逻辑
},
error: function(index, upload) {
// 上传失败后的逻辑
}
});
其他更多的配置,可以参考 官方文档
3 回调及回调签名验证
我们自己实现的 WebApi,在收到 OSS 的回调请求后,可以不进行签名的验证,直接进行业务操作。
由于该 WebApi 是匿名访问的,如果担心接口安全问题,可以对请求进行签名验证,防止非 OSS 的请求进行业务操作。
OSS 会将签名,和 RSA 公钥 Url 地址,放置在请求的 Header 中,内容如下:
// 其他参数不过多解释(包括以 x-oss- 开头的参数)
// 参与签名验证的,主要有两个
Authorization = <Base64 编码后的签名内容>
x-oss-pub-key-url = <Base64 编码后的公钥 Url(PEM 文件)>
签名的加密方式是 RSA,用到了库 Portable.BouncyCastle
点击查看
验证步骤如下:
- 对
Authorization
的值,进行Base64
解码,得到byte[]
- 对
x-oss-pub-key-url
的值,进行Base64
解码,得到公钥的Url 地址
- 公钥的
Url 地址
,必须是以下两个之一:
http://gosspublic.alicdn.com/
https://gosspublic.alicdn.com/
- 根据公钥的
Url 地址
获取到公钥的内容- 拼接
<请求路径><Url 参数>\n<body 内容>
,并计算 MD5,得到byte[]
- 基于 RSA,采用 MD5 模式进行验证
- 将验证的结果,返回给 OSS(OSS 仅接收 Json 格式的返回)
- OSS 会将返回的内容直接返回给前端
官方的说明,可以看这里
验签的 C# 代码如下:
// 获取 Authorization 并解码
if (!Request.Headers.TryGetValue("Authorization", out var tempAuth) || StringValues.IsNullOrEmpty(tempAuth))
{
// OSS 会将此内容,直接返回给前端
return new { Code = 400, Msg = "Authorization 为空" };
}
var byteAuth = Convert.FromBase64String(tempAuth);
// 获取 x-oss-pub-key-url 并解码
if (!Request.Headers.TryGetValue("x-oss-pub-key-url", out var tempPubKeyUrl) || StringValues.IsNullOrEmpty(tempPubKeyUrl))
{
return new { Code = 400, Msg = "Pub Key Url 为空" };
}
var bytePubKeyUrl = Convert.FromBase64String(tempPubKeyUrl.ToString());
var pubKeyUrl = Encoding.ASCII.GetString(bytePubKeyUrl);
// 验证公钥域名
if (!pubKeyUrl.StartsWith("http://gosspublic.alicdn.com/", StringComparison.OrdinalIgnoreCase)
&& !pubKeyUrl.StartsWith("https://gosspublic.alicdn.com/", StringComparison.OrdinalIgnoreCase))
{
return new { Code = 400, Msg = "Pub Key Url 错误" };
}
// 从公钥 Url 获取公钥内容
// 获取到的内容,如:-----BEGIN PUBLIC KEY----- …xxxx… -----END PUBLIC KEY-----
var pubKey = string.Empty;
HttpClient cli = null;
try
{
cli = _clientFactory.CreateClient();
pubKey = await cli.GetStringAsync(pubKeyUrl);
}
catch
{
return new { Code = 400, Msg = "获取 Pub Key 失败" };
}
finally
{
if (cli != null)
{
cli.Dispose();
}
}
if (string.IsNullOrEmpty(pubKey))
{
return new { Code = 400, Msg = "Pub Key 错误" };
}
// 拼接 Path、QueryString、Body
var reqPath = HttpUtility.UrlDecode(Request.Path);
var reqQueryString = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty;
// 获取 Form 值时,如果从 Request.Body 读取,Body 被读取一次后,就不能再读了
// 所以,这里用 Dictionary<string, string> 将 Body 的全部参数,按照原顺序暂存
// 如果 OSS 回调的 Json 方式可以成功,那么,可以直接将 Request.Body 读取成字符串
// 验证完签名后,将 Body 字符串反序列化成 Json 对象,进行剩下的业务逻辑即可
var dictBody = new Dictionary<string, string>();
if (Request.HasFormContentType)
{
foreach (var item in Request.Form)
{
dictBody.Add(item.Key, item.Value);
}
}
// 由于上面在添加到 Dictionary 时,已经是 UrlDecode 后的内容
// 所以,在拼接 Body 键值对字符串的时候,需要将内容进行 UrlEncode,还原到传来时的内容
// 又由于 .NET 的 HttpUtility.UrlEncode 后的内容,字母都是小写的(Java 是大写)
// 所以,自己实现了一个扩展方法,支持 UrlEncode 后大小写
var body = string.Join("&", dictBody.Select(per => $"{per.Key.UrlEncode(true)}={per.Value.UrlEncode(true)}"));
var signBody = string.Concat(reqPath, reqQueryString, "\n", body); // 拼接时,body 前面要加 \n
// 构造 RSA,用 MD5 的方式验证签名
var pubKeyBase64 = pubKey.Replace("-----BEGIN PUBLIC KEY-----", string.Empty).Replace("-----END PUBLIC KEY-----", string.Empty).Replace("\n", string.Empty);
var pubKeyParam = (RsaKeyParameters)PublicKeyFactory.CreateKey(Convert.FromBase64String(pubKeyBase64));
var pubKeyXml = $"<RSAKeyValue><Modulus>{Convert.ToBase64String(pubKeyParam.Modulus.ToByteArrayUnsigned())}</Modulus><Exponent>{Convert.ToBase64String(pubKeyParam.Exponent.ToByteArrayUnsigned())}</Exponent></RSAKeyValue>";
using (var rsa = new RSACryptoServiceProvider())
using (var md5 = MD5.Create())
{
rsa.FromXmlString(pubKeyXml);
var rsaDeformatter = new RSAPKCS1SignatureDeformatter(rsa);
rsaDeformatter.SetHashAlgorithm("MD5");
var isValid = rsaDeformatter.VerifySignature(md5.ComputeHash(Encoding.UTF8.GetBytes(signBody)), byteAuth);
if (!isValid)
{
return new { Code = 400, Msg = "验签失败" };
}
}
// TODO: 后续的业务逻辑处理
对字符串进行 UrlEncode 的扩展方法代码如下:
public static string UrlEncode(this string content, bool needUpper = false)
{
if (string.IsNullOrEmpty(content))
{
return string.Empty;
}
if (!needUpper)
{
return HttpUtility.UrlEncode(content);
}
var result = new StringBuilder();
foreach (var per in content)
{
var temp = HttpUtility.UrlEncode(per.ToString());
if (temp.Length > 1)
{
result.Append(temp.ToUpper());
continue;
}
result.Append(per);
}
return result.ToString();
}
至此,整个 OSS 的 Web 直传、回调(回调签名验证),就全部完成。
验签之后,就可以进行其他的业务逻辑了。