Przeglądaj źródła

ExpiredMessageCleanupWorker and Anti-spam check: prevent rapid OTP requests

ducnt 3 tygodni temu
rodzic
commit
417006a6f0

+ 30 - 6
EsimLao/Esim.Apis/Business/User/UserBusinessImpl.cs

@@ -94,26 +94,50 @@ namespace Esim.Apis.Business
                     customerId = newCustomerId;
                 }
 
-                // Check if there's an existing OTP record for this email (unused, not expired)
+                // Check if there's an existing OTP record for this email (ANY record, used or unused)
                 int otpExpireMinutes = 5;
+                int minSecondsBetweenRequests = 60; // Anti-spam: minimum 60 seconds between requests
+                
                 var existingOtp = dbContext.OtpVerifications
-                    .Where(o => o.UserEmail == request.email && o.IsUsed == false)
+                    .Where(o => o.UserEmail == request.email)
                     .OrderByDescending(o => o.CreatedDate)
                     .FirstOrDefault();
 
                 if (existingOtp != null)
                 {
-                    // UPDATE existing record instead of creating new one
+                    // Anti-spam check: prevent rapid OTP requests
+                    var secondsSinceLastRequest = (DateTime.Now - (existingOtp.CreatedDate ?? DateTime.Now)).TotalSeconds;
+                    if (secondsSinceLastRequest < minSecondsBetweenRequests)
+                    {
+                        var waitSeconds = (int)(minSecondsBetweenRequests - secondsSinceLastRequest);
+                        log.Warn($"Spam prevention: OTP request too soon for {request.email}. Last request {secondsSinceLastRequest:F0}s ago.");
+                        
+                        return DotnetLib.Http.HttpResponse.BuildResponse(
+                            log,
+                            url,
+                            json,
+                            CommonErrorCode.OtpTooManyRequests,
+                            $"Please wait {waitSeconds} seconds before requesting a new OTP.",
+                            new { 
+                                waitSeconds = waitSeconds,
+                                canRequestAt = existingOtp.CreatedDate?.AddSeconds(minSecondsBetweenRequests)
+                            }
+                        );
+                    }
+                    
+                    // UPDATE existing record - reuse for this email to prevent table bloat
                     existingOtp.OtpCode = otpCode;
                     existingOtp.ExpiredAt = DateTime.Now.AddMinutes(otpExpireMinutes);
                     existingOtp.AttemptCount = 0; // Reset attempt count
+                    existingOtp.IsUsed = false;   // Reset to unused (allow reuse)
                     existingOtp.CustomerId = customerId; // Update customer ID if changed
+                    existingOtp.CreatedDate = DateTime.Now; // Update to current time
                     
-                    log.Info($"Updated existing OTP record ID={existingOtp.Id} for {request.email}");
+                    log.Info($"Updated existing OTP record ID={existingOtp.Id} for {request.email} (was {(existingOtp.IsUsed == true ? "used" : "unused")})");
                 }
                 else
                 {
-                    // INSERT new record only if none exists
+                    // INSERT new record only for first-time email
                     var otpId = (int)await Database.DbLogic.GenIdAsync(dbContext, "OTP_VERIFICATION_SEQ");
                     var otpVerification = new OtpVerification
                     {
@@ -129,7 +153,7 @@ namespace Esim.Apis.Business
                     };
 
                     dbContext.OtpVerifications.Add(otpVerification);
-                    log.Info($"Created new OTP record for {request.email}");
+                    log.Info($"Created new OTP record for {request.email} (first time)");
                 }
 
                 await dbContext.SaveChangesAsync();

+ 10 - 3
EsimLao/Esim.Apis/Program.cs

@@ -38,8 +38,10 @@ builder.Services.AddCors(options =>
                 "http://localhost:3000",      // React development
                 "http://localhost:5173",      // Vite development
                 "http://localhost:4200",      // Angular development
-                "https://infigate.vn",        // Production domain
-                "https://www.infigate.vn"     // Production www domain
+                "http://simgetgo.vn",        // Production domain
+                "http://simgetgo.com",     // Production www domain
+                "https://simgetgo.vn",    // Production www domain
+                "https://simgetgo.com"     // Production www domain
             )
             .AllowAnyMethod()
             .AllowAnyHeader()
@@ -110,7 +112,12 @@ app.UseSwaggerUI();
 //    app.UseHsts();
 //}
 
-app.UseHttpsRedirection();
+// Only redirect to HTTPS in production
+if (!app.Environment.IsDevelopment())
+{
+    app.UseHttpsRedirection();
+}
+
 app.UseRouting();
 
 // Enable CORS - MUST be after UseRouting and before UseAuthentication

+ 7 - 0
EsimLao/Esim.Apis/appsettings.json

@@ -17,6 +17,13 @@
       "Http": {
         "Url": "http://0.0.0.0:9106"
       }
+      //"Https": {
+      //  "Url": "https://0.0.0.0:9107",
+      //  "Certificate": {
+      //    "Path": "/path/to/certificate.pfx",
+      //    "Password": "your-password"
+      //  }
+      //}
     }
   }
 }

+ 176 - 0
EsimLao/Esim.SendMail/ExpiredMessageCleanupWorker.cs

@@ -0,0 +1,176 @@
+using Database.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Esim.SendMail;
+
+/// <summary>
+/// Background worker to clean up expired messages from MESSAGE_QUEUE.
+/// Messages older than configured expiration days are moved to MESSAGE_QUEUE_HIS with failed status and deleted.
+/// </summary>
+public class ExpiredMessageCleanupWorker : BackgroundService
+{
+    private readonly ILogger<ExpiredMessageCleanupWorker> _logger;
+    private readonly IServiceProvider _serviceProvider;
+    private readonly int _intervalMinutes;
+    private readonly int _expirationDays;
+
+    // Message statuses
+    private const int STATUS_PENDING = 0;
+    private const int STATUS_PROCESSING = 1;
+    private const int STATUS_FAILED = 3;
+
+    public ExpiredMessageCleanupWorker(
+        ILogger<ExpiredMessageCleanupWorker> logger,
+        IServiceProvider serviceProvider,
+        IConfiguration configuration)
+    {
+        _logger = logger;
+        _serviceProvider = serviceProvider;
+        _intervalMinutes = int.Parse(configuration["Cleanup:IntervalMinutes"] ?? "60"); // Default: 1 hour
+        _expirationDays = int.Parse(configuration["Cleanup:ExpirationDays"] ?? "1"); // Default: 1 day
+    }
+
+    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+    {
+        _logger.LogInformation("ExpiredMessageCleanupWorker started. Interval: {Interval} minutes, Expiration: {Days} day(s)",
+            _intervalMinutes, _expirationDays);
+
+        // Wait a bit for initialization
+        await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
+
+        while (!stoppingToken.IsCancellationRequested)
+        {
+            try
+            {
+                await CleanupExpiredMessagesAsync(stoppingToken);
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error in cleanup loop");
+            }
+
+            try
+            {
+                await Task.Delay(TimeSpan.FromMinutes(_intervalMinutes), stoppingToken);
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                break;
+            }
+        }
+
+        _logger.LogInformation("ExpiredMessageCleanupWorker stopped.");
+    }
+
+    private async Task CleanupExpiredMessagesAsync(CancellationToken stoppingToken)
+    {
+        var startTime = DateTime.Now;
+
+        using var scope = _serviceProvider.CreateScope();
+        var dbContext = scope.ServiceProvider.GetRequiredService<ModelContext>();
+
+        try
+        {
+            // Find messages older than expiration days that are still pending or processing
+            var expirationDate = DateTime.Now.AddDays(-_expirationDays);
+            
+            var expiredMessages = await dbContext.MessageQueues
+                .Where(m => m.CreatedDate <= expirationDate
+                    && (m.Status == STATUS_PENDING || m.Status == STATUS_PROCESSING))
+                .OrderBy(m => m.CreatedDate)
+                .ToListAsync(stoppingToken);
+
+            if (!expiredMessages.Any())
+            {
+                _logger.LogDebug("No expired messages found");
+                return;
+            }
+
+            _logger.LogWarning("Found {Count} expired messages (older than {Days} day(s)). Moving to history and deleting...", 
+                expiredMessages.Count, _expirationDays);
+
+            // Log sample of expired messages for audit
+            var sampleSize = Math.Min(5, expiredMessages.Count);
+            for (int i = 0; i < sampleSize; i++)
+            {
+                var msg = expiredMessages[i];
+                var age = DateTime.Now - (msg.CreatedDate ?? DateTime.Now);
+                var subjectPreview = msg.Subject != null 
+                    ? msg.Subject.Substring(0, Math.Min(50, msg.Subject.Length))
+                    : "(no subject)";
+                _logger.LogInformation(
+                    "Expired message sample: ID={Id}, Recipient={Recipient}, Subject={Subject}, Age={Age:F1}h, Status={Status}, Retries={Retry}/{Max}",
+                    msg.Id, msg.Recipient, subjectPreview,
+                    age.TotalHours, msg.Status, msg.RetryCount ?? 0, msg.MaxRetry ?? 3);
+            }
+            if (expiredMessages.Count > sampleSize)
+            {
+                _logger.LogInformation("... and {More} more expired messages", expiredMessages.Count - sampleSize);
+            }
+
+            // Move to MESSAGE_QUEUE_HIS with failed status
+            await MoveToHistoryAsync(dbContext, expiredMessages, stoppingToken);
+
+            // Delete from MESSAGE_QUEUE
+            var ids = expiredMessages.Select(m => m.Id).ToList();
+            var deleteSql = $"DELETE FROM MESSAGE_QUEUE WHERE ID IN ({string.Join(",", ids)})";
+            await dbContext.Database.ExecuteSqlRawAsync(deleteSql, stoppingToken);
+
+            var elapsed = DateTime.Now - startTime;
+            _logger.LogInformation("Successfully cleaned up {Count} expired messages in {Elapsed:F0}ms. Messages archived to MESSAGE_QUEUE_HIS with FAILED status.",
+                expiredMessages.Count, elapsed.TotalMilliseconds);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error cleaning up expired messages");
+        }
+    }
+
+    private async Task MoveToHistoryAsync(
+        ModelContext dbContext,
+        List<MessageQueue> messages,
+        CancellationToken stoppingToken)
+    {
+        if (!messages.Any()) return;
+
+        try
+        {
+            var ids = string.Join(",", messages.Select(m => m.Id));
+
+            // Insert to history with STATUS = 3 (FAILED - EXPIRED)
+            var insertSql = $@"
+                INSERT INTO MESSAGE_QUEUE_HIS 
+                (ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA, 
+                 PRIORITY, STATUS, SCHEDULED_AT, PROCESSED_AT, RETRY_COUNT, MAX_RETRY, 
+                 ERROR_MESSAGE, CREATED_BY, CREATED_DATE, MOVED_DATE)
+                SELECT 
+                    ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA,
+                    PRIORITY, {STATUS_FAILED} AS STATUS, SCHEDULED_AT, SYSDATE AS PROCESSED_AT, RETRY_COUNT, MAX_RETRY,
+                    'EXPIRED: Message older than {_expirationDays} day(s) - Auto-deleted to prevent spam and table bloat. Created: ' || TO_CHAR(CREATED_DATE, 'YYYY-MM-DD HH24:MI:SS') AS ERROR_MESSAGE, 
+                    CREATED_BY, CREATED_DATE, SYSDATE
+                FROM MESSAGE_QUEUE
+                WHERE ID IN ({ids})";
+
+            await dbContext.Database.ExecuteSqlRawAsync(insertSql, stoppingToken);
+
+            _logger.LogDebug("Moved {Count} expired messages to MESSAGE_QUEUE_HIS with FAILED status", messages.Count);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Failed to move expired messages to history");
+            throw; // Re-throw to prevent deletion if archival fails
+        }
+    }
+
+    public override async Task StopAsync(CancellationToken cancellationToken)
+    {
+        _logger.LogInformation("ExpiredMessageCleanupWorker stopping...");
+        await base.StopAsync(cancellationToken);
+        _logger.LogInformation("ExpiredMessageCleanupWorker stopped gracefully");
+    }
+}

+ 45 - 23
EsimLao/Esim.SendMail/MessageQueueWorker.cs

@@ -1,6 +1,7 @@
 using Database.Database;
 using Esim.SendMail.Services;
 using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
 using Microsoft.Extensions.Logging;
 
 namespace Esim.SendMail;
@@ -178,13 +179,11 @@ public class MessageQueueWorker : BackgroundService
         ModelContext dbContext, 
         CancellationToken stoppingToken)
     {
-        // Using Oracle's FOR UPDATE SKIP LOCKED to prevent duplicate processing
-        // This allows multiple workers to run safely
-        var sql = $@"
-            SELECT ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA,
-                   PRIORITY, STATUS, SCHEDULED_AT, PROCESSED_AT, RETRY_COUNT, MAX_RETRY,
-                   ERROR_MESSAGE, CREATED_BY, CREATED_DATE
-            FROM MESSAGE_QUEUE
+        // Step 1: Lock rows and get IDs using raw SQL with FOR UPDATE SKIP LOCKED
+        // This prevents duplicate processing across multiple workers
+        var lockSql = $@"
+            SELECT ID
+            FROM {dbContext.Model.FindEntityType(typeof(MessageQueue))?.GetSchemaQualifiedTableName() ?? "MESSAGE_QUEUE"}
             WHERE STATUS = {STATUS_PENDING}
               AND (SCHEDULED_AT IS NULL OR SCHEDULED_AT <= SYSDATE)
               AND (RETRY_COUNT IS NULL OR RETRY_COUNT < NVL(MAX_RETRY, 3))
@@ -194,27 +193,50 @@ public class MessageQueueWorker : BackgroundService
 
         try
         {
-            var messages = await dbContext.MessageQueues
-                .FromSqlRaw(sql)
-                .ToListAsync(stoppingToken);
-
-            // Mark as processing immediately
-            if (messages.Any())
+            // Execute raw SQL to lock rows and get IDs
+            var connection = dbContext.Database.GetDbConnection();
+            await dbContext.Database.OpenConnectionAsync(stoppingToken);
+            
+            var lockedIds = new List<int>();
+            using (var command = connection.CreateCommand())
             {
-                foreach (var msg in messages)
+                command.CommandText = lockSql;
+                command.Transaction = dbContext.Database.CurrentTransaction?.GetDbTransaction();
+                
+                using (var reader = await command.ExecuteReaderAsync(stoppingToken))
                 {
-                    msg.Status = STATUS_PROCESSING;
+                    while (await reader.ReadAsync(stoppingToken))
+                    {
+                        lockedIds.Add(reader.GetInt32(0));
+                    }
                 }
-                await dbContext.SaveChangesAsync(stoppingToken);
             }
 
+            if (!lockedIds.Any())
+            {
+                return new List<MessageQueue>();
+            }
+
+            // Step 2: Query the full entities using EF Core with the locked IDs
+            var messages = await dbContext.MessageQueues
+                .Where(m => lockedIds.Contains(m.Id))
+                .ToListAsync(stoppingToken);
+
+            // Step 3: Mark as processing immediately while rows are still locked
+            foreach (var msg in messages)
+            {
+                msg.Status = STATUS_PROCESSING;
+            }
+            await dbContext.SaveChangesAsync(stoppingToken);
+            
+            _logger.LogDebug("Locked and fetched {Count} messages for processing", messages.Count);
             return messages;
         }
         catch (Exception ex)
         {
-            _logger.LogWarning("Failed to get messages with row-locking, falling back: {Message}", ex.Message);
+            _logger.LogWarning(ex, "Failed to lock messages with FOR UPDATE, falling back to simple query");
             
-            // Fallback to simple query if FOR UPDATE fails
+            // Fallback: Simple EF Core query without locking
             var messages = await dbContext.MessageQueues
                 .Where(m => m.Status == STATUS_PENDING
                     && (m.ScheduledAt == null || m.ScheduledAt <= DateTime.Now)
@@ -224,13 +246,13 @@ public class MessageQueueWorker : BackgroundService
                 .Take(_maxMessagesPerRun)
                 .ToListAsync(stoppingToken);
 
-            // Mark as processing
             if (messages.Any())
             {
-                var ids = string.Join(",", messages.Select(m => m.Id));
-                await dbContext.Database.ExecuteSqlRawAsync(
-                    $"UPDATE MESSAGE_QUEUE SET STATUS = {STATUS_PROCESSING} WHERE ID IN ({ids})",
-                    stoppingToken);
+                foreach (var msg in messages)
+                {
+                    msg.Status = STATUS_PROCESSING;
+                }
+                await dbContext.SaveChangesAsync(stoppingToken);
             }
 
             return messages;

+ 2 - 1
EsimLao/Esim.SendMail/Program.cs

@@ -27,8 +27,9 @@ public class Program
         // Add Email Service as singleton for connection pooling
         builder.Services.AddSingleton<IEmailService, HighPerformanceEmailService>();
 
-        // Add the background worker service
+        // Add the background worker services
         builder.Services.AddHostedService<MessageQueueWorker>();
+        builder.Services.AddHostedService<ExpiredMessageCleanupWorker>();
 
         var host = builder.Build();
 

+ 4 - 0
EsimLao/Esim.SendMail/appsettings.json

@@ -18,6 +18,10 @@
     "MaxMessagesPerRun": 500,
     "MetricsLogIntervalSeconds": 60
   },
+  "Cleanup": {
+    "IntervalMinutes": 60,
+    "ExpirationDays": 1
+  },
   "Logging": {
     "LogLevel": {
       "Default": "Information",