feat: secure whatsapp token handling and keyword trigger parsing

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 12:27:39 +00:00
parent 68627dc011
commit 6baca17249
6 changed files with 210 additions and 14 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -42,5 +42,8 @@
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
},
"Security": {
"AccessTokenEncryptionKey": "replace-with-access-token-encryption-key"
},
"AllowedHosts": "*"
}

View File

@@ -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;
}

View File

@@ -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);
}
}