diff --git a/backend/src/Health.Infrastructure/Services/bce_signer.cs b/backend/src/Health.Infrastructure/Services/bce_signer.cs new file mode 100644 index 0000000..d30d4a1 --- /dev/null +++ b/backend/src/Health.Infrastructure/Services/bce_signer.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Security.Cryptography; + +namespace Health.Infrastructure.Services; + +/// +/// 百度云 BCE v1 签名算法(与 baidubce-sdk 算法一致) +/// +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); +} diff --git a/backend/src/Health.Infrastructure/Services/bce_sms_client.cs b/backend/src/Health.Infrastructure/Services/bce_sms_client.cs new file mode 100644 index 0000000..cd90e12 --- /dev/null +++ b/backend/src/Health.Infrastructure/Services/bce_sms_client.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Health.Infrastructure.Services; + +/// +/// 百度云 SMS v3 短信发送客户端(HttpClient 直连 + BCE v1 签名) +/// +public sealed class BceSmsClient(HttpClient http, IConfiguration cfg, ILogger 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 _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 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; + } +} diff --git a/backend/tests/Health.Tests/bce_signer_tests.cs b/backend/tests/Health.Tests/bce_signer_tests.cs new file mode 100644 index 0000000..e9b37e1 --- /dev/null +++ b/backend/tests/Health.Tests/bce_signer_tests.cs @@ -0,0 +1,111 @@ +using Health.Infrastructure.Services; + +namespace Health.Tests; + +/// +/// BCE v1 签名算法测试 +/// Golden value 由 Python 标准库 hmac + hashlib 按 baidubce-sdk 算法手算, +/// 并与 baidubce-sdk 0.9.72 的实际 debug 输出对比验证一致 +/// +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); + } +}