1
0
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:
Kyle Spearrin
2025-08-21 10:44:08 -07:00
committed by GitHub
parent 982aaf6f76
commit 1c98e59003
2 changed files with 105 additions and 3 deletions

View File

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

View File

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