阿里云 OSS Web 直传/回调/回调签名验证(.NET/C#/Layui)

环境是 .NET 6.0,实现以下几个方面:

  1. 服务端采用 .NET SDK 生成上传签名和回调配置
  2. Layui 用自带的 Upload 组件直接上传至 OSS
  3. 回调的 WebApi 中,对 OSS 的请求进行签名验证

1 .NET SDK 生成授权码和回调配置

SDK 直接使用官方提供的 点击获取
注:根据阿里云的文档,还有一个升级版的 .NET SDK,但是经过测试,问题还比较多,不够成熟。

生成上传签名的逻辑,比较简单(注意看注释说明),C# 代码如下:

// Endpoint 不带 Bucket,如:https://oss-cn-hangzhou.aliyuncs.com
var client = new OssClient("", "", "");

// 添加大小限制和前缀,如果有前缀,一定要以 / 结尾
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(""));
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 = "",
    Host = ".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均如此):

Layui Upload 源码

知道了地方,修改也就简单了,就是把这两部分的位置对调,就可以了,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: '',
    data: {
        key: '' + '${filename}',   // ${filename} 在 OSS 上传时,表示原文件名
        policy: '',
        OSSAccessKeyId: '',
        success_action_status: '200',   // 限制为 200,不然 OSS 可能返回 203
        signature: '',
        callback: '',
        "x: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 = 
x-oss-pub-key-url = 

签名的加密方式是 RSA,用到了库 Portable.BouncyCastle 点击查看

验证步骤如下:

  1. Authorization 的值,进行 Base64 解码,得到 byte[]
  2. x-oss-pub-key-url 的值,进行 Base64 解码,得到公钥的 Url 地址
  3. 公钥的 Url 地址,必须是以下两个之一:
    • http://gosspublic.alicdn.com/
    • https://gosspublic.alicdn.com/
  4. 根据公钥的 Url 地址 获取到公钥的内容
  5. 拼接 <请求路径>\n,并计算 MD5,得到 byte[]
  6. 基于 RSA,采用 MD5 模式进行验证
  7. 将验证的结果,返回给 OSS(OSS 仅接收 Json 格式的返回)
  8. 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 将 Body 的全部参数,按照原顺序暂存
// 如果 OSS 回调的 Json 方式可以成功,那么,可以直接将 Request.Body 读取成字符串
// 验证完签名后,将 Body 字符串反序列化成 Json 对象,进行剩下的业务逻辑即可
var dictBody = new Dictionary();
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 = $"{Convert.ToBase64String(pubKeyParam.Modulus.ToByteArrayUnsigned())}{Convert.ToBase64String(pubKeyParam.Exponent.ToByteArrayUnsigned())}";
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 直传、回调(回调签名验证),就全部完成。

验签之后,就可以进行其他的业务逻辑了。

你可能感兴趣的:(阿里云 OSS Web 直传/回调/回调签名验证(.NET/C#/Layui))