feat: secure whatsapp token handling and keyword trigger parsing
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -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<ConnectWhatsAppAccountCommand, ConnectWhatsAppAccountResult>
|
||||
{
|
||||
private readonly IWhatsAppAccountRepository _repository;
|
||||
private readonly IAccessTokenEncryptionService _encryptionService;
|
||||
private readonly ILogger<ConnectWhatsAppAccountCommandHandler> _logger;
|
||||
|
||||
public ConnectWhatsAppAccountCommandHandler(
|
||||
IWhatsAppAccountRepository repository,
|
||||
IAccessTokenEncryptionService encryptionService,
|
||||
ILogger<ConnectWhatsAppAccountCommandHandler> 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<ConnectWhats
|
||||
return new ConnectWhatsAppAccountResult(false, null, "Phone number is already connected to another shop");
|
||||
}
|
||||
|
||||
// TODO: Encrypt access token before storing
|
||||
var encryptedToken = request.AccessToken; // Placeholder - should use encryption service
|
||||
var encryptedToken = _encryptionService.Encrypt(request.AccessToken);
|
||||
|
||||
var account = new WhatsAppAccount(
|
||||
request.ShopId,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
using WhatsAppService.Infrastructure.ExternalServices;
|
||||
using WhatsAppService.Infrastructure.Security;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
@@ -15,18 +16,21 @@ public class SendMessageCommandHandler : IRequestHandler<SendMessageCommand, Sen
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
private readonly IWhatsAppAccountRepository _accountRepository;
|
||||
private readonly IWhatsAppCloudApiClient _whatsAppClient;
|
||||
private readonly IAccessTokenEncryptionService _encryptionService;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
|
||||
public SendMessageCommandHandler(
|
||||
IConversationRepository conversationRepository,
|
||||
IWhatsAppAccountRepository accountRepository,
|
||||
IWhatsAppCloudApiClient whatsAppClient,
|
||||
IAccessTokenEncryptionService encryptionService,
|
||||
ILogger<SendMessageCommandHandler> 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<SendMessageResult> Handle(
|
||||
@@ -55,8 +59,7 @@ public class SendMessageCommandHandler : IRequestHandler<SendMessageCommand, Sen
|
||||
return new SendMessageResult(false, null, null, "WhatsApp account not available");
|
||||
}
|
||||
|
||||
// TODO: Decrypt access token
|
||||
var accessToken = account.AccessTokenEncrypted;
|
||||
var accessToken = _encryptionService.Decrypt(account.AccessTokenEncrypted);
|
||||
|
||||
// Create message content
|
||||
var content = CreateMessageContent(request);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
using WhatsAppService.Domain.Events;
|
||||
|
||||
@@ -39,23 +40,107 @@ public class AutomationTriggerHandler : INotificationHandler<MessageReceivedEven
|
||||
return;
|
||||
}
|
||||
|
||||
var messageText = notification.Content.Text?.ToLowerInvariant() ?? "";
|
||||
var messageText = notification.Content.Text?.Trim() ?? string.Empty;
|
||||
var hasMatchedFlow = false;
|
||||
|
||||
foreach (var flow in flows.OrderByDescending(f => 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<string> ExtractKeywords(JsonElement root)
|
||||
{
|
||||
var keywords = new List<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,5 +42,8 @@
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"Security": {
|
||||
"AccessTokenEncryptionKey": "replace-with-access-token-encryption-key"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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<IRequestManager, RequestManager>();
|
||||
services.AddSingleton<IAccessTokenEncryptionService, AccessTokenEncryptionService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for encrypting/decrypting WhatsApp access tokens.
|
||||
/// VI: Interface để mã hóa/giải mã access token WhatsApp.
|
||||
/// </summary>
|
||||
public interface IAccessTokenEncryptionService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Encrypt plain access token.
|
||||
/// VI: Mã hóa access token dạng plain text.
|
||||
/// </summary>
|
||||
string Encrypt(string plainTextToken);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Decrypt encrypted access token.
|
||||
/// VI: Giải mã access token đã mã hóa.
|
||||
/// </summary>
|
||||
string Decrypt(string encryptedToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: AES-GCM token encryption service.
|
||||
/// VI: Service mã hóa token sử dụng AES-GCM.
|
||||
/// </summary>
|
||||
public class AccessTokenEncryptionService : IAccessTokenEncryptionService
|
||||
{
|
||||
private const string TokenPrefix = "v1:";
|
||||
private readonly byte[] _keyBytes;
|
||||
private readonly ILogger<AccessTokenEncryptionService> _logger;
|
||||
|
||||
public AccessTokenEncryptionService(
|
||||
IConfiguration configuration,
|
||||
ILogger<AccessTokenEncryptionService> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user