diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs index 48e08be4..73b1764c 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.Extensions.Logging; using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; +using WhatsAppService.Infrastructure.Security; namespace WhatsAppService.API.Application.Commands; @@ -11,13 +12,16 @@ namespace WhatsAppService.API.Application.Commands; public class ConnectWhatsAppAccountCommandHandler : IRequestHandler { private readonly IWhatsAppAccountRepository _repository; + private readonly IAccessTokenEncryptionService _encryptionService; private readonly ILogger _logger; public ConnectWhatsAppAccountCommandHandler( IWhatsAppAccountRepository repository, + IAccessTokenEncryptionService encryptionService, ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _encryptionService = encryptionService ?? throw new ArgumentNullException(nameof(encryptionService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -41,8 +45,7 @@ public class ConnectWhatsAppAccountCommandHandler : IRequestHandler _logger; public SendMessageCommandHandler( IConversationRepository conversationRepository, IWhatsAppAccountRepository accountRepository, IWhatsAppCloudApiClient whatsAppClient, + IAccessTokenEncryptionService encryptionService, ILogger logger) { - _conversationRepository = conversationRepository; - _accountRepository = accountRepository; - _whatsAppClient = whatsAppClient; - _logger = logger; + _conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository)); + _accountRepository = accountRepository ?? throw new ArgumentNullException(nameof(accountRepository)); + _whatsAppClient = whatsAppClient ?? throw new ArgumentNullException(nameof(whatsAppClient)); + _encryptionService = encryptionService ?? throw new ArgumentNullException(nameof(encryptionService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task Handle( @@ -55,8 +59,7 @@ public class SendMessageCommandHandler : IRequestHandler f.Priority)) { - // TODO: Parse trigger config and check for keyword matches - // This is a placeholder - actual implementation would parse TriggerConfig JSON - _logger.LogDebug( - "Checking flow {FlowId} for keyword triggers on message from {CustomerWaId}", - flow.Id, notification.CustomerWaId); + if (!IsKeywordMatch(flow.TriggerConfig, messageText)) + { + continue; + } + + hasMatchedFlow = true; + flow.IncrementExecutionCount(); + flowRepository.Update(flow); + + _logger.LogInformation( + "Matched keyword automation flow {FlowId} for customer {CustomerWaId}.", + flow.Id, + notification.CustomerWaId); // If matched, execute flow steps // await ExecuteFlowAsync(flow, notification, cancellationToken); } + + if (hasMatchedFlow) + { + await flowRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } } catch (Exception ex) { _logger.LogError(ex, "Error processing automation triggers for message {MessageId}", notification.MessageId); } } + + private static bool IsKeywordMatch(string triggerConfigJson, string messageText) + { + if (string.IsNullOrWhiteSpace(messageText) || string.IsNullOrWhiteSpace(triggerConfigJson)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(triggerConfigJson); + var root = doc.RootElement; + + var keywords = ExtractKeywords(root); + if (keywords.Count == 0) + { + return false; + } + + var normalizedMessage = messageText.Trim().ToLowerInvariant(); + var matchType = root.TryGetProperty("matchType", out var matchTypeElement) + ? matchTypeElement.GetString()?.Trim().ToLowerInvariant() + : "contains"; + + return matchType switch + { + "exact" => keywords.Any(keyword => normalizedMessage.Equals(keyword, StringComparison.Ordinal)), + _ => keywords.Any(keyword => normalizedMessage.Contains(keyword, StringComparison.Ordinal)), + }; + } + catch (JsonException) + { + return false; + } + } + + private static List ExtractKeywords(JsonElement root) + { + var keywords = new List(); + + if (root.TryGetProperty("keyword", out var keywordElement) && + keywordElement.ValueKind == JsonValueKind.String) + { + var keyword = keywordElement.GetString()?.Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keywords.Add(keyword); + } + } + + if (root.TryGetProperty("keywords", out var keywordsElement) && + keywordsElement.ValueKind == JsonValueKind.Array) + { + foreach (var element in keywordsElement.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String) + { + continue; + } + + var keyword = element.GetString()?.Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keywords.Add(keyword); + } + } + } + + return keywords.Distinct(StringComparer.Ordinal).ToList(); + } } diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/appsettings.json b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/appsettings.json index 523dc0fc..6aee2aa7 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/appsettings.json +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/appsettings.json @@ -42,5 +42,8 @@ "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 }, + "Security": { + "AccessTokenEncryptionKey": "replace-with-access-token-encryption-key" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs index efd16d74..07977184 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs @@ -12,6 +12,7 @@ using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; using WhatsAppService.Infrastructure.ExternalServices; using WhatsAppService.Infrastructure.Idempotency; using WhatsAppService.Infrastructure.Repositories; +using WhatsAppService.Infrastructure.Security; namespace WhatsAppService.Infrastructure; @@ -73,6 +74,7 @@ public static class DependencyInjection // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); + services.AddSingleton(); return services; } diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Security/AccessTokenEncryptionService.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Security/AccessTokenEncryptionService.cs new file mode 100644 index 00000000..d62227f5 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Security/AccessTokenEncryptionService.cs @@ -0,0 +1,100 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace WhatsAppService.Infrastructure.Security; + +/// +/// EN: Interface for encrypting/decrypting WhatsApp access tokens. +/// VI: Interface để mã hóa/giải mã access token WhatsApp. +/// +public interface IAccessTokenEncryptionService +{ + /// + /// EN: Encrypt plain access token. + /// VI: Mã hóa access token dạng plain text. + /// + string Encrypt(string plainTextToken); + + /// + /// EN: Decrypt encrypted access token. + /// VI: Giải mã access token đã mã hóa. + /// + string Decrypt(string encryptedToken); +} + +/// +/// EN: AES-GCM token encryption service. +/// VI: Service mã hóa token sử dụng AES-GCM. +/// +public class AccessTokenEncryptionService : IAccessTokenEncryptionService +{ + private const string TokenPrefix = "v1:"; + private readonly byte[] _keyBytes; + private readonly ILogger _logger; + + public AccessTokenEncryptionService( + IConfiguration configuration, + ILogger logger) + { + _logger = logger; + var rawKey = configuration["Security:AccessTokenEncryptionKey"] + ?? configuration["ACCESS_TOKEN_ENCRYPTION_KEY"] + ?? throw new InvalidOperationException( + "Security:AccessTokenEncryptionKey (or ACCESS_TOKEN_ENCRYPTION_KEY) is required."); + + _keyBytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); + } + + public string Encrypt(string plainTextToken) + { + if (string.IsNullOrWhiteSpace(plainTextToken)) + throw new ArgumentException("Token cannot be empty.", nameof(plainTextToken)); + + var nonce = RandomNumberGenerator.GetBytes(12); + var plainBytes = Encoding.UTF8.GetBytes(plainTextToken); + var cipherBytes = new byte[plainBytes.Length]; + var tagBytes = new byte[16]; + + using var aes = new AesGcm(_keyBytes, 16); + aes.Encrypt(nonce, plainBytes, cipherBytes, tagBytes); + + var payload = new byte[nonce.Length + tagBytes.Length + cipherBytes.Length]; + Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length); + Buffer.BlockCopy(tagBytes, 0, payload, nonce.Length, tagBytes.Length); + Buffer.BlockCopy(cipherBytes, 0, payload, nonce.Length + tagBytes.Length, cipherBytes.Length); + + return $"{TokenPrefix}{Convert.ToBase64String(payload)}"; + } + + public string Decrypt(string encryptedToken) + { + if (string.IsNullOrWhiteSpace(encryptedToken)) + throw new ArgumentException("Encrypted token cannot be empty.", nameof(encryptedToken)); + + // EN: Backward compatibility for legacy plain-text records. + // VI: Tương thích ngược với bản ghi legacy lưu plain-text. + if (!encryptedToken.StartsWith(TokenPrefix, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Decrypt called with legacy plain token format. Consider re-encrypting stored access token."); + return encryptedToken; + } + + var base64 = encryptedToken[TokenPrefix.Length..]; + var payload = Convert.FromBase64String(base64); + if (payload.Length <= 28) + throw new InvalidOperationException("Encrypted token payload is invalid."); + + var nonce = payload[..12]; + var tag = payload[12..28]; + var cipherBytes = payload[28..]; + var plainBytes = new byte[cipherBytes.Length]; + + using var aes = new AesGcm(_keyBytes, 16); + aes.Decrypt(nonce, cipherBytes, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); + } +}