// A & L Security API v1 - cliente de referência para netstandard2.1. // Dependência de JSON: Newtonsoft.Json (normalmente já presente no servidor 7DTD). // Guarde a chave somente no servidor. Nunca faça log do cabeçalho Authorization. using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; namespace ALSecurity.Sdk { public sealed class AlsSecurityClient : IDisposable { private readonly HttpClient http; private readonly string apiKey; private readonly bool ownsHttpClient; public AlsSecurityClient(string baseUrl, string apiKey, HttpClient httpClient = null) { if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentNullException(nameof(baseUrl)); if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentNullException(nameof(apiKey)); if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri uri) || uri.Scheme != Uri.UriSchemeHttps) throw new ArgumentException("A baseUrl deve usar HTTPS.", nameof(baseUrl)); this.apiKey = apiKey; http = httpClient ?? new HttpClient(); ownsHttpClient = httpClient == null; http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/", UriKind.Absolute); http.Timeout = TimeSpan.FromSeconds(5); } public async Task CheckBanAsync( string steamId64, CancellationToken cancellationToken = default(CancellationToken)) { ValidateSteamId(steamId64); string json = await SendSignedAsync( HttpMethod.Get, "api/v1/bans/" + Uri.EscapeDataString(steamId64), null, null, cancellationToken).ConfigureAwait(false); return JsonConvert.DeserializeObject(json); } public async Task RegisterBanAsync( CreateBanRequest ban, string idempotencyKey, CancellationToken cancellationToken = default(CancellationToken)) { if (ban == null) throw new ArgumentNullException(nameof(ban)); ValidateSteamId(ban.SteamId); if (string.IsNullOrWhiteSpace(idempotencyKey) || idempotencyKey.Length < 16) throw new ArgumentException("Use uma chave de idempotência única com 16+ caracteres.", nameof(idempotencyKey)); Dictionary headers = new Dictionary { ["X-Idempotency-Key"] = idempotencyKey }; string json = await SendSignedAsync( HttpMethod.Post, "api/v1/bans", ban, headers, cancellationToken).ConfigureAwait(false); RegisterBanResponse response = JsonConvert.DeserializeObject(json); return response?.Ban; } private async Task SendSignedAsync( HttpMethod method, string relativePath, object payload, IDictionary extraHeaders, CancellationToken cancellationToken) { string body = payload == null ? string.Empty : JsonConvert.SerializeObject(payload, Formatting.None); string contentHash = Sha256Hex(body); string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); string nonce = Guid.NewGuid().ToString("N"); Uri requestUri = new Uri(http.BaseAddress, relativePath); string canonical = string.Join("\n", new[] { timestamp, nonce, method.Method.ToUpperInvariant(), requestUri.PathAndQuery, contentHash }); using (HttpRequestMessage request = new HttpRequestMessage(method, requestUri)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.TryAddWithoutValidation("X-ALS-Timestamp", timestamp); request.Headers.TryAddWithoutValidation("X-ALS-Nonce", nonce); request.Headers.TryAddWithoutValidation("X-ALS-Content-SHA256", contentHash); request.Headers.TryAddWithoutValidation("X-ALS-Signature", HmacSha256Hex(apiKey, canonical)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); if (extraHeaders != null) { foreach (KeyValuePair header in extraHeaders) request.Headers.TryAddWithoutValidation(header.Key, header.Value); } if (payload != null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); using (HttpResponseMessage response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false)) { string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new AlsSecurityApiException((int)response.StatusCode, Redact(responseBody)); return responseBody; } } } private static string Sha256Hex(string value) { using (SHA256 sha = SHA256.Create()) return ToHex(sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty))); } private static string HmacSha256Hex(string secret, string value) { using (HMACSHA256 hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret))) return ToHex(hmac.ComputeHash(Encoding.UTF8.GetBytes(value))); } private static string ToHex(byte[] bytes) { StringBuilder builder = new StringBuilder(bytes.Length * 2); foreach (byte value in bytes) builder.Append(value.ToString("x2", CultureInfo.InvariantCulture)); return builder.ToString(); } private static void ValidateSteamId(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length != 17 || !value.StartsWith("7656119", StringComparison.Ordinal)) throw new ArgumentException("SteamID64 inválido.", nameof(value)); for (int i = 0; i < value.Length; i++) if (value[i] < '0' || value[i] > '9') throw new ArgumentException("SteamID64 inválido.", nameof(value)); } private static string Redact(string value) { if (string.IsNullOrEmpty(value)) return "A API recusou a requisição."; return value.Length <= 1000 ? value : value.Substring(0, 1000); } public void Dispose() { if (ownsHttpClient) http.Dispose(); } } public sealed class CreateBanRequest { [JsonProperty("steamId")] public string SteamId { get; set; } [JsonProperty("playerName")] public string PlayerName { get; set; } [JsonProperty("reasonCode")] public string ReasonCode { get; set; } [JsonProperty("reasonDetail")] public string ReasonDetail { get; set; } [JsonProperty("confidence")] public int Confidence { get; set; } = 100; [JsonProperty("occurredAt")] public string OccurredAt { get; set; } = DateTime.UtcNow.ToString("O"); [JsonProperty("evidenceUrl", NullValueHandling = NullValueHandling.Ignore)] public string EvidenceUrl { get; set; } [JsonProperty("evidenceHash", NullValueHandling = NullValueHandling.Ignore)] public string EvidenceHash { get; set; } } public sealed class BanLookupResult { [JsonProperty("steamId")] public string SteamId { get; set; } [JsonProperty("matched")] public bool Matched { get; set; } [JsonProperty("reportCount")] public int ReportCount { get; set; } [JsonProperty("highestConfidence")] public int HighestConfidence { get; set; } [JsonProperty("reports")] public List Reports { get; set; } = new List(); } public sealed class BanRecord { [JsonProperty("id")] public string Id { get; set; } [JsonProperty("steamId")] public string SteamId { get; set; } [JsonProperty("playerName")] public string PlayerName { get; set; } [JsonProperty("avatarUrl")] public string AvatarUrl { get; set; } [JsonProperty("reasonCode")] public string ReasonCode { get; set; } [JsonProperty("reason")] public string Reason { get; set; } [JsonProperty("detail")] public string Detail { get; set; } [JsonProperty("confidence")] public int Confidence { get; set; } [JsonProperty("occurredAt")] public string OccurredAt { get; set; } } internal sealed class RegisterBanResponse { [JsonProperty("ban")] public BanRecord Ban { get; set; } } public sealed class AlsSecurityApiException : Exception { public int StatusCode { get; } public AlsSecurityApiException(int statusCode, string message) : base(message) { StatusCode = statusCode; } } }