mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-25050] limit failed 2fa emails to once per hour (#6227)
* limit failed 2fa emails to once per hour * Linting. --------- Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
@@ -24,6 +24,7 @@ using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Core.Auth.Enums;
|
||||
using HandlebarsDotNet;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
@@ -31,10 +32,12 @@ public class HandlebarsMailService : IMailService
|
||||
{
|
||||
private const string Namespace = "Bit.Core.MailTemplates.Handlebars";
|
||||
private const string _utcTimeZoneDisplay = "UTC";
|
||||
private const string FailedTwoFactorAttemptCacheKeyFormat = "FailedTwoFactorAttemptEmail_{0}";
|
||||
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailDeliveryService _mailDeliveryService;
|
||||
private readonly IMailEnqueuingService _mailEnqueuingService;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache = new();
|
||||
|
||||
private bool _registeredHelpersAndPartials = false;
|
||||
@@ -42,11 +45,13 @@ public class HandlebarsMailService : IMailService
|
||||
public HandlebarsMailService(
|
||||
GlobalSettings globalSettings,
|
||||
IMailDeliveryService mailDeliveryService,
|
||||
IMailEnqueuingService mailEnqueuingService)
|
||||
IMailEnqueuingService mailEnqueuingService,
|
||||
IDistributedCache distributedCache)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_mailDeliveryService = mailDeliveryService;
|
||||
_mailEnqueuingService = mailEnqueuingService;
|
||||
_distributedCache = distributedCache;
|
||||
}
|
||||
|
||||
public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string token)
|
||||
@@ -196,6 +201,16 @@ public class HandlebarsMailService : IMailService
|
||||
|
||||
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||
{
|
||||
// Check if we've sent this email within the last hour
|
||||
var cacheKey = string.Format(FailedTwoFactorAttemptCacheKeyFormat, email);
|
||||
var cachedValue = await _distributedCache.GetAsync(cacheKey);
|
||||
|
||||
if (cachedValue != null)
|
||||
{
|
||||
// Email was already sent within the last hour, skip sending
|
||||
return;
|
||||
}
|
||||
|
||||
var message = CreateDefaultMessage("Failed two-step login attempt detected", email);
|
||||
var model = new FailedAuthAttemptModel()
|
||||
{
|
||||
@@ -211,6 +226,13 @@ public class HandlebarsMailService : IMailService
|
||||
await AddMessageContentAsync(message, "Auth.FailedTwoFactorAttempt", model);
|
||||
message.Category = "FailedTwoFactorAttempt";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
|
||||
// Set cache entry with 1 hour expiration to prevent sending again
|
||||
var cacheOptions = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
|
||||
};
|
||||
await _distributedCache.SetAsync(cacheKey, [1], cacheOptions);
|
||||
}
|
||||
|
||||
public async Task SendMasterPasswordHintEmailAsync(string email, string hint)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@@ -19,17 +22,93 @@ public class HandlebarsMailServiceTests
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailDeliveryService _mailDeliveryService;
|
||||
private readonly IMailEnqueuingService _mailEnqueuingService;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
|
||||
public HandlebarsMailServiceTests()
|
||||
{
|
||||
_globalSettings = new GlobalSettings();
|
||||
_mailDeliveryService = Substitute.For<IMailDeliveryService>();
|
||||
_mailEnqueuingService = Substitute.For<IMailEnqueuingService>();
|
||||
_distributedCache = Substitute.For<IDistributedCache>();
|
||||
|
||||
_sut = new HandlebarsMailService(
|
||||
_globalSettings,
|
||||
_mailDeliveryService,
|
||||
_mailEnqueuingService
|
||||
_mailEnqueuingService,
|
||||
_distributedCache
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendFailedTwoFactorAttemptEmailAsync_FirstCall_SendsEmail()
|
||||
{
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var failedType = TwoFactorProviderType.Email;
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var ip = "192.168.1.1";
|
||||
|
||||
_distributedCache.GetAsync(Arg.Any<string>()).Returns((byte[])null);
|
||||
|
||||
// Act
|
||||
await _sut.SendFailedTwoFactorAttemptEmailAsync(email, failedType, utcNow, ip);
|
||||
|
||||
// Assert
|
||||
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
|
||||
await _distributedCache.Received(1).SetAsync(
|
||||
Arg.Is<string>(key => key == $"FailedTwoFactorAttemptEmail_{email}"),
|
||||
Arg.Any<byte[]>(),
|
||||
Arg.Any<DistributedCacheEntryOptions>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendFailedTwoFactorAttemptEmailAsync_SecondCallWithinHour_DoesNotSendEmail()
|
||||
{
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var failedType = TwoFactorProviderType.Email;
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var ip = "192.168.1.1";
|
||||
|
||||
// Simulate cache hit (email was already sent)
|
||||
_distributedCache.GetAsync(Arg.Any<string>()).Returns([1]);
|
||||
|
||||
// Act
|
||||
await _sut.SendFailedTwoFactorAttemptEmailAsync(email, failedType, utcNow, ip);
|
||||
|
||||
// Assert
|
||||
await _mailDeliveryService.DidNotReceive().SendEmailAsync(Arg.Any<MailMessage>());
|
||||
await _distributedCache.DidNotReceive().SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendFailedTwoFactorAttemptEmailAsync_DifferentEmails_SendsBothEmails()
|
||||
{
|
||||
// Arrange
|
||||
var email1 = "test1@example.com";
|
||||
var email2 = "test2@example.com";
|
||||
var failedType = TwoFactorProviderType.Email;
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var ip = "192.168.1.1";
|
||||
|
||||
_distributedCache.GetAsync(Arg.Any<string>()).Returns((byte[])null);
|
||||
|
||||
// Act
|
||||
await _sut.SendFailedTwoFactorAttemptEmailAsync(email1, failedType, utcNow, ip);
|
||||
await _sut.SendFailedTwoFactorAttemptEmailAsync(email2, failedType, utcNow, ip);
|
||||
|
||||
// Assert
|
||||
await _mailDeliveryService.Received(2).SendEmailAsync(Arg.Any<MailMessage>());
|
||||
await _distributedCache.Received(1).SetAsync(
|
||||
Arg.Is<string>(key => key == $"FailedTwoFactorAttemptEmail_{email1}"),
|
||||
Arg.Any<byte[]>(),
|
||||
Arg.Any<DistributedCacheEntryOptions>()
|
||||
);
|
||||
await _distributedCache.Received(1).SetAsync(
|
||||
Arg.Is<string>(key => key == $"FailedTwoFactorAttemptEmail_{email2}"),
|
||||
Arg.Any<byte[]>(),
|
||||
Arg.Any<DistributedCacheEntryOptions>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,8 +216,9 @@ public class HandlebarsMailServiceTests
|
||||
};
|
||||
|
||||
var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For<ILogger<MailKitSmtpMailDeliveryService>>());
|
||||
var distributedCache = Substitute.For<IDistributedCache>();
|
||||
|
||||
var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService());
|
||||
var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService(), distributedCache);
|
||||
|
||||
var sendMethods = typeof(IMailService).GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(m => m.Name.StartsWith("Send") && m.Name != "SendEnqueuedMailMessageAsync");
|
||||
|
||||
Reference in New Issue
Block a user