diff --git a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj index 0242d33d..5ac09967 100644 --- a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj +++ b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj @@ -76,6 +76,8 @@ + + diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs new file mode 100644 index 00000000..a2d9a63f --- /dev/null +++ b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs @@ -0,0 +1,93 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GeneralUpdate.Common.Shared.Security; + +public interface IHttpAuthProvider +{ + Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default); +} + +public sealed class NoOpAuthProvider : IHttpAuthProvider +{ + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + => Task.CompletedTask; +} + +public sealed class BearerTokenAuthProvider : IHttpAuthProvider +{ + private readonly string _token; + public BearerTokenAuthProvider(string token) + { + _token = token ?? throw new ArgumentNullException(nameof(token)); + } + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return Task.CompletedTask; + } +} + +public sealed class ApiKeyAuthProvider : IHttpAuthProvider +{ + private readonly string _headerName; + private readonly string _apiKey; + public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key") + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _headerName = headerName ?? throw new ArgumentNullException(nameof(headerName)); + } + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Add(_headerName, _apiKey); + return Task.CompletedTask; + } +} + +public sealed class HmacAuthProvider : IHttpAuthProvider +{ + private readonly string _secretKey; + public HmacAuthProvider(string secretKey) + { + _secretKey = secretKey ?? throw new ArgumentNullException(nameof(secretKey)); + } + public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + var body = request.Content != null + ? await request.Content.ReadAsStringAsync().ConfigureAwait(false) + : string.Empty; + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var sig = ComputeHmacSha256($"{body}|{ts}", _secretKey); + request.Headers.Add("X-Update-Timestamp", ts); + request.Headers.Add("X-Update-Signature", sig); + } + private static string ComputeHmacSha256(string data, string key) + { + var hash = new HMACSHA256(Encoding.UTF8.GetBytes(key)) + .ComputeHash(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } +} + +public static class HttpAuthProviderFactory +{ + public static IHttpAuthProvider Create(string? scheme, string? token, string? appSecretKey) + { + if (!string.IsNullOrEmpty(appSecretKey)) + return new HmacAuthProvider(appSecretKey); + if (!string.IsNullOrEmpty(token)) + { + return (scheme ?? "").ToLowerInvariant() switch + { + "apikey" => new ApiKeyAuthProvider(token), + _ => new BearerTokenAuthProvider(token) + }; + } + return new NoOpAuthProvider(); + } +} diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs new file mode 100644 index 00000000..8a54f4dc --- /dev/null +++ b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs @@ -0,0 +1,21 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GeneralUpdate.Common.Shared.Security; + +public interface ISslValidationPolicy +{ + bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors); +} + +public sealed class StrictSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => sslPolicyErrors == SslPolicyErrors.None; +} diff --git a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs index 3bcfd10b..0ba5c832 100644 --- a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs +++ b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs @@ -1,116 +1,169 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Net.Security; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using System.Threading; using System.Threading.Tasks; using GeneralUpdate.Common.Internal; using GeneralUpdate.Common.Internal.JsonContext; using GeneralUpdate.Common.Shared.Object; +using GeneralUpdate.Common.Shared.Security; namespace GeneralUpdate.Common.Shared.Service { public class VersionService { + private static readonly HttpClient _sharedClient; + private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy(); + + private readonly IHttpAuthProvider _authProvider; + private readonly TimeSpan _timeout; + private readonly int _maxRetries; + + static VersionService() + { + var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = SharedCertValidation; + _sharedClient = new HttpClient(handler, disposeHandler: false); + } + + public static void SetSslValidationPolicy(ISslValidationPolicy policy) + { + _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy)); + } + + private static bool SharedCertValidation( + HttpRequestMessage message, + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + { + return _globalSslPolicy.ValidateCertificate(certificate, chain, sslPolicyErrors); + } + + public VersionService( + IHttpAuthProvider? authProvider = null, + TimeSpan? timeout = null, + int maxRetries = 3) + { + _authProvider = authProvider ?? new NoOpAuthProvider(); + _timeout = timeout ?? TimeSpan.FromSeconds(30); + _maxRetries = maxRetries; + } + private VersionService() { } - - /// - /// Report the result of this update: whether it was successful. - /// - /// - /// - /// - /// - /// - public static async Task Report(string httpUrl - , int recordId - , int status - , int? type - , string scheme = null - , string token = null) + + // ═══════════ Static API (backward-compatible) ═══════════ + + public static Task Report(string httpUrl, int recordId, int status, int? type, + string scheme = null, string token = null) { - var parameters = new Dictionary - { - { "RecordId", recordId }, - { "Status", status }, - { "Type", type } + var auth = HttpAuthProviderFactory.Create(scheme, token, null); + var svc = new VersionService(auth); + return svc.ReportAsync(httpUrl, recordId, status, type); + } + + public static Task Validate(string httpUrl, string version, + int appType, string appKey, int platform, string productId, + string scheme = null, string token = null) + { + var auth = HttpAuthProviderFactory.Create(scheme, token, appKey); + var svc = new VersionService(auth); + return svc.ValidateAsync(httpUrl, version, appType, platform, productId); + } + + // ═══════════ Instance methods ═══════════ + + private async Task ReportAsync(string httpUrl, int recordId, int status, + int? type, CancellationToken token = default) + { + var p = new Dictionary { + { "RecordId", recordId }, { "Status", status }, { "Type", type } }; - await PostTaskAsync>(httpUrl, parameters, ReportRespJsonContext.Default.BaseResponseDTOBoolean, scheme, token); + await PostAsync>( + httpUrl, p, ReportRespJsonContext.Default.BaseResponseDTOBoolean, token) + .ConfigureAwait(false); } - /// - /// Verify whether the current version needs an update. - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task Validate(string httpUrl - , string version - , int appType - , string appKey - , int platform - , string productId - , string scheme = null - , string token = null) + private async Task ValidateAsync(string httpUrl, string version, + int appType, int platform, string productId, CancellationToken token = default) { - var parameters = new Dictionary - { - { "Version", version }, - { "AppType", appType }, - { "AppKey", appKey }, - { "Platform", platform }, - { "ProductId", productId } + var p = new Dictionary { + { "Version", version }, { "AppType", appType }, + { "Platform", platform }, { "ProductId", productId } }; - return await PostTaskAsync(httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, scheme, token); + return await PostAsync( + httpUrl, p, VersionRespJsonContext.Default.VersionRespDTO, token) + .ConfigureAwait(false); } - private static async Task PostTaskAsync(string httpUrl, Dictionary parameters, JsonTypeInfo? typeInfo = null, string scheme = null, string token = null) + private async Task PostAsync(string httpUrl, Dictionary parameters, + JsonTypeInfo? typeInfo, CancellationToken token, int maxRetriesOverride = 0) { - try + int max = maxRetriesOverride > 0 ? maxRetriesOverride : _maxRetries; + for (int attempt = 0; ; attempt++) { - var uri = new Uri(httpUrl); - using var httpClient = new HttpClient(new HttpClientHandler + try { - ServerCertificateCustomValidationCallback = CheckValidationResult - }); - httpClient.Timeout = TimeSpan.FromSeconds(15); - httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*"); - - if (!string.IsNullOrEmpty(scheme) && !string.IsNullOrEmpty(token)) + return await SendAsync(httpUrl, parameters, typeInfo, token) + .ConfigureAwait(false); + } + catch (Exception ex) when (attempt < max - 1 && IsRetryable(ex)) { - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue(scheme, token); + GeneralTracer.Warn( + $"HTTP attempt {attempt + 1}/{max} failed, retrying. {ex.Message}"); + await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000), token) + .ConfigureAwait(false); } - - var parametersJson = - JsonSerializer.Serialize(parameters, HttpParameterJsonContext.Default.DictionaryStringObject); - var stringContent = new StringContent(parametersJson, Encoding.UTF8, "application/json"); - var result = await httpClient.PostAsync(uri, stringContent); - var reseponseJson = await result.Content.ReadAsStringAsync(); - return typeInfo == null - ? JsonSerializer.Deserialize(reseponseJson) - : JsonSerializer.Deserialize(reseponseJson, typeInfo); } - catch (Exception e) + } + + private async Task SendAsync(string httpUrl, Dictionary parameters, + JsonTypeInfo? typeInfo, CancellationToken token) + { + var uri = new Uri(httpUrl); + using var request = new HttpRequestMessage(HttpMethod.Post, uri); + request.Headers.Accept.ParseAdd("application/json"); + + var json = JsonSerializer.Serialize( + parameters, HttpParameterJsonContext.Default.DictionaryStringObject); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + await _authProvider.ApplyAuthAsync(request, token).ConfigureAwait(false); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(_timeout); + + var response = await _sharedClient.SendAsync(request, timeoutCts.Token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync() + .ConfigureAwait(false); + return typeInfo == null + ? JsonSerializer.Deserialize(responseJson) + : JsonSerializer.Deserialize(responseJson, typeInfo); + } + + private static bool IsRetryable(Exception ex) + { + if (ex is OperationCanceledException) return false; + if (ex is TaskCanceledException) return true; + if (ex is TimeoutException) return true; + if (ex is System.IO.IOException) return true; + if (ex is HttpRequestException hre) { - GeneralTracer.Error("The PostTaskAsync method in the VersionService class throws an exception.", e); - throw e; + var msg = hre.Message ?? ""; + return msg.Contains("timeout", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || msg.Contains("500") || msg.Contains("502") + || msg.Contains("503") || msg.Contains("504"); } + return false; } - - private static bool CheckValidationResult( - HttpRequestMessage message, - X509Certificate2 certificate, - X509Chain chain, - SslPolicyErrors sslPolicyErrors - ) => true; } -} \ No newline at end of file +} diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj index 4df180fb..bbed19c1 100644 --- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj +++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj @@ -84,6 +84,8 @@ + +