阿里云 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("<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均如此):

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: '<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 点击查看

验证步骤如下:

  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. 拼接 <请求路径><Url 参数>\n<body 内容>,并计算 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<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 直传、回调(回调签名验证),就全部完成。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容