Compare commits
1 Commits
release/io
...
32aae40b12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32aae40b12 |
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal file
49
backend/src/Health.Infrastructure/Services/bce_signer.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 百度云 BCE v1 签名算法(与 baidubce-sdk 算法一致)
|
||||||
|
/// </summary>
|
||||||
|
public static class BceSigner
|
||||||
|
{
|
||||||
|
private const int ExpirySeconds = 1800;
|
||||||
|
private const string SignedHeaders = "content-length;content-type;host;x-bce-date";
|
||||||
|
|
||||||
|
public static string BuildAuthorization(
|
||||||
|
string accessKeyId,
|
||||||
|
string secretAccessKey,
|
||||||
|
string method,
|
||||||
|
string path,
|
||||||
|
string host,
|
||||||
|
DateTime utcNow,
|
||||||
|
int contentLength,
|
||||||
|
string contentType)
|
||||||
|
{
|
||||||
|
var timestamp = utcNow.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var canonicalHeaders = string.Join("\n",
|
||||||
|
$"content-length:{contentLength}",
|
||||||
|
$"content-type:{Normalize(contentType)}",
|
||||||
|
$"host:{Normalize(host)}",
|
||||||
|
$"x-bce-date:{Normalize(timestamp)}");
|
||||||
|
|
||||||
|
var canonicalRequest = $"{method}\n{path}\n\n{canonicalHeaders}";
|
||||||
|
var authStringPrefix = $"bce-auth-v1/{accessKeyId}/{timestamp}/{ExpirySeconds}";
|
||||||
|
|
||||||
|
var signingKeyDigest = HMACSHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes(secretAccessKey),
|
||||||
|
Encoding.UTF8.GetBytes(authStringPrefix));
|
||||||
|
var signingKeyHex = Convert.ToHexString(signingKeyDigest).ToLowerInvariant();
|
||||||
|
|
||||||
|
var signature = HMACSHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes(signingKeyHex),
|
||||||
|
Encoding.UTF8.GetBytes(canonicalRequest));
|
||||||
|
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
|
||||||
|
|
||||||
|
return $"{authStringPrefix}/{SignedHeaders}/{signatureHex}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string s) => Uri.EscapeDataString(s);
|
||||||
|
}
|
||||||
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal file
64
backend/src/Health.Infrastructure/Services/bce_sms_client.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 百度云 SMS v3 短信发送客户端(HttpClient 直连 + BCE v1 签名)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BceSmsClient(HttpClient http, IConfiguration cfg, ILogger<BceSmsClient> log)
|
||||||
|
{
|
||||||
|
private const string Path = "/api/v3/sendSms";
|
||||||
|
private const string ContentType = "application/json; charset=utf-8";
|
||||||
|
|
||||||
|
private readonly HttpClient _http = http;
|
||||||
|
private readonly ILogger<BceSmsClient> _log = log;
|
||||||
|
private readonly string _ak = cfg["BAIDU_SMS_AK"] ?? "";
|
||||||
|
private readonly string _sk = cfg["BAIDU_SMS_SK"] ?? "";
|
||||||
|
private readonly string _host = cfg["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
|
||||||
|
private readonly string _signatureId = cfg["BAIDU_SMS_SIGNATURE_ID"] ?? "";
|
||||||
|
private readonly string _templateId = cfg["BAIDU_SMS_TEMPLATE_ID"] ?? "";
|
||||||
|
|
||||||
|
public async Task<bool> SendAsync(string phone, string code, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
mobile = $"+86{phone}",
|
||||||
|
template = _templateId,
|
||||||
|
signatureId = _signatureId,
|
||||||
|
contentVar = new { SMSvCode = code }
|
||||||
|
};
|
||||||
|
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, Path)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(body)
|
||||||
|
};
|
||||||
|
req.Content.Headers.TryAddWithoutValidation("Content-Type", ContentType);
|
||||||
|
|
||||||
|
req.Headers.Host = _host;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var timestamp = now.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
req.Headers.Add("x-bce-date", timestamp);
|
||||||
|
var auth = BceSigner.BuildAuthorization(_ak, _sk, "POST", Path, _host, now, body.Length, ContentType);
|
||||||
|
req.Headers.TryAddWithoutValidation("Authorization", auth);
|
||||||
|
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_log.LogError("百度短信发送失败 HTTP {Status}: {Body}", resp.StatusCode, respBody);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(respBody);
|
||||||
|
var respCode = doc.RootElement.GetProperty("code").GetString();
|
||||||
|
if (respCode != "1000")
|
||||||
|
{
|
||||||
|
_log.LogError("百度短信发送失败 code={Code}: {Body}", respCode, respBody);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal file
111
backend/tests/Health.Tests/bce_signer_tests.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BCE v1 签名算法测试
|
||||||
|
/// Golden value 由 Python 标准库 hmac + hashlib 按 baidubce-sdk 算法手算,
|
||||||
|
/// 并与 baidubce-sdk 0.9.72 的实际 debug 输出对比验证一致
|
||||||
|
/// </summary>
|
||||||
|
public class BceSignerTests
|
||||||
|
{
|
||||||
|
private const string Ak = "test-ak";
|
||||||
|
private const string Sk = "test-sk";
|
||||||
|
private const string Host = "smsv3.bj.baidubce.com";
|
||||||
|
private const string Method = "POST";
|
||||||
|
private const string ApiPath = "/api/v3/sendSms";
|
||||||
|
private const string ContentType = "application/json;charset=utf-8";
|
||||||
|
private const int ContentLength = 199;
|
||||||
|
|
||||||
|
private static readonly DateTime Ts1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly DateTime Ts2 = new(2026, 7, 23, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// Golden 1: 固定输入
|
||||||
|
private const string ExpectedSig1 = "7592321b147186724a0194344aa274b4875fbc3442b4a5a07db4fd00cf44e580";
|
||||||
|
private const string ExpectedAuth1 = "bce-auth-v1/test-ak/2026-01-01T00:00:00Z/1800/content-length;content-type;host;x-bce-date/" + ExpectedSig1;
|
||||||
|
|
||||||
|
// Golden 2: 不同 timestamp
|
||||||
|
private const string ExpectedSig2 = "5c058909c04e8a6964fc22f9ae00cd5aae9c9182cb21fca7a76eb834eaa47c0b";
|
||||||
|
|
||||||
|
// Golden 3: 不同 SK
|
||||||
|
private const string ExpectedSig3 = "b944339bad2635d0eb41344427c1328f9785335a64baaabced658fa487fb228a";
|
||||||
|
|
||||||
|
// Golden 4: 不同 content_length
|
||||||
|
private const string ExpectedSig4 = "7b2f8bfdda2255b2331992a4adb9fb7365aa83c62453b61380cc7be350e6105f";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_FixedInput_MatchesGoldenValue()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
Assert.Equal(ExpectedAuth1, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentTimestamp_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth2 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts2, ContentLength, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth2);
|
||||||
|
Assert.EndsWith(ExpectedSig2, auth2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentSecretKey_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth3 = BceSigner.BuildAuthorization(Ak, "test-sk-2", Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth3);
|
||||||
|
Assert.EndsWith(ExpectedSig3, auth3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_DifferentContentLength_ProducesDifferentSignature()
|
||||||
|
{
|
||||||
|
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var auth4 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, 100, ContentType);
|
||||||
|
Assert.NotEqual(auth1, auth4);
|
||||||
|
Assert.EndsWith(ExpectedSig4, auth4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_Signature_Is64CharLowercaseHex()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var sig = auth.Split('/')[^1];
|
||||||
|
Assert.Equal(64, sig.Length);
|
||||||
|
Assert.Matches("^[0-9a-f]{64}$", sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_Format_HasCorrectStructure()
|
||||||
|
{
|
||||||
|
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
||||||
|
var parts = auth.Split('/');
|
||||||
|
Assert.Equal(6, parts.Length);
|
||||||
|
Assert.Equal("bce-auth-v1", parts[0]);
|
||||||
|
Assert.Equal(Ak, parts[1]);
|
||||||
|
Assert.Equal("2026-01-01T00:00:00Z", parts[2]);
|
||||||
|
Assert.Equal("1800", parts[3]);
|
||||||
|
Assert.Equal("content-length;content-type;host;x-bce-date", parts[4]);
|
||||||
|
Assert.Equal(ExpectedSig1, parts[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorization_RealCredentials_MatchesSdkDebugOutput()
|
||||||
|
{
|
||||||
|
// 用百度 SDK 0.9.72 实际 debug 日志里的真实输入,验证 C# 输出和 SDK 完全一致
|
||||||
|
var auth = BceSigner.BuildAuthorization(
|
||||||
|
"ALTAKnJn8wtdLtBPgl7bD1x3N3",
|
||||||
|
"c5ca05a967d04a69bbc0e12f08062dc8",
|
||||||
|
"POST",
|
||||||
|
"/api/v3/sendSms",
|
||||||
|
"smsv3.bj.baidubce.com",
|
||||||
|
new DateTime(2026, 7, 23, 9, 3, 42, DateTimeKind.Utc),
|
||||||
|
199,
|
||||||
|
"application/json;charset=utf-8");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"bce-auth-v1/ALTAKnJn8wtdLtBPgl7bD1x3N3/2026-07-23T09:03:42Z/1800/content-length;content-type;host;x-bce-date/0fd388266f63e9036de09981d7312ce3b7e279c8789823cb80b6c1800005fe13",
|
||||||
|
auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user