From 3c5de319d166a1bdf3b7d1339e70ff14d03e7ef1 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Mon, 11 Aug 2025 16:39:43 -0400
Subject: [PATCH] feat(2fa): [PM-24425] Add email on failed 2FA attempt
* Added email on failed 2FA attempt.
* Added tests.
* Adjusted email verbiage.
* Added feature flag.
* Undid accidental change.
* Undid unintentional change to clean up PR.
* Linting
* Added attempted method to email.
* Changes to email templates.
* Linting.
* Email format changes.
* Email formatting changes.
---
...mptsModel.cs => FailedAuthAttemptModel.cs} | 4 +-
src/Core/Constants.cs | 1 +
.../Auth/FailedTwoFactorAttempt.html.hbs | 37 ++++++
.../Auth/FailedTwoFactorAttempt.text.hbs | 18 +++
src/Core/Services/IMailService.cs | 2 +
.../Implementations/HandlebarsMailService.cs | 20 ++++
.../NoopImplementations/NoopMailService.cs | 6 +
.../RequestValidators/BaseRequestValidator.cs | 14 ++-
.../CustomTokenRequestValidator.cs | 6 +-
.../ResourceOwnerPasswordValidator.cs | 6 +-
.../WebAuthnGrantValidator.cs | 6 +-
.../BaseRequestValidatorTests.cs | 105 ++++++++++++++++--
.../BaseRequestValidatorTestWrapper.cs | 6 +-
13 files changed, 212 insertions(+), 19 deletions(-)
rename src/Core/Auth/Models/Mail/{FailedAuthAttemptsModel.cs => FailedAuthAttemptModel.cs} (58%)
create mode 100644 src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs
create mode 100644 src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs
diff --git a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs
similarity index 58%
rename from src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs
rename to src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs
index e7b0b042a5..c67ac4a3d3 100644
--- a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs
+++ b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs
@@ -1,11 +1,13 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
+using Bit.Core.Auth.Enums;
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
-public class FailedAuthAttemptsModel : NewDeviceLoggedInModel
+public class FailedAuthAttemptModel : NewDeviceLoggedInModel
{
public string AffectedEmail { get; set; }
+ public TwoFactorProviderType TwoFactorType { get; set; }
}
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 08191ff356..5e54434a17 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -127,6 +127,7 @@ public static class FeatureFlagKeys
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
public const string Otp6Digits = "pm-18612-otp-6-digits";
+ public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs
new file mode 100644
index 0000000000..56052c7a0d
--- /dev/null
+++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs
@@ -0,0 +1,37 @@
+{{#>FullHtmlLayout}}
+
+
+ |
+ We've detected a failed login attempt
+ |
+
+
+
+
+ If you're having trouble with two-step login, please visit the Help Center.
+ |
+
+
+
+
+ If you did not recently try to log in, open the web app and take these immediate steps to secure your Bitwarden account:
+
+ - Deauthorize all devices
+ - Change your master password
+
+
+ |
+
+
+
+
+
+ Account: {{AffectedEmail}}
+ Two-Step Login Method: {{TwoFactorType}}
+ Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
+ IP Address: {{IpAddress}}
+ |
+
+
+
+{{/FullHtmlLayout}}
\ No newline at end of file
diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs
new file mode 100644
index 0000000000..4ad5dd32a3
--- /dev/null
+++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs
@@ -0,0 +1,18 @@
+{{#>BasicTextLayout}}
+We've detected a failed login attempt
+
+If you're having trouble with two-step login, please visit the Help Center (https://bitwarden.com/help/).
+
+If you did not recently try to log in, open the web app ({{{WebVaultUrl}}}) and take these immediate steps to secure your Bitwarden account:
+- Deauthorize all devices
+- Change your master password
+
+Account: {{AffectedEmail}}
+Two-Step Login Method: {{TwoFactorType}}
+Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
+IP Address: {{IpAddress}}
+
+{{/BasicTextLayout}}
+
+
+
diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs
index e5a7577770..32aaac84b7 100644
--- a/src/Core/Services/IMailService.cs
+++ b/src/Core/Services/IMailService.cs
@@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
@@ -29,6 +30,7 @@ public interface IMailService
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
+ Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs
index 254a0dd841..9dd2dffedf 100644
--- a/src/Core/Services/Implementations/HandlebarsMailService.cs
+++ b/src/Core/Services/Implementations/HandlebarsMailService.cs
@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Mail;
using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Mail;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Mail;
@@ -193,6 +194,25 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
+ public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
+ {
+ var message = CreateDefaultMessage("Failed two-step login attempt detected", email);
+ var model = new FailedAuthAttemptModel()
+ {
+ TheDate = utcNow.ToLongDateString(),
+ TheTime = utcNow.ToShortTimeString(),
+ TimeZone = _utcTimeZoneDisplay,
+ IpAddress = ip,
+ AffectedEmail = email,
+ TwoFactorType = failedType,
+ WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash
+
+ };
+ await AddMessageContentAsync(message, "Auth.FailedTwoFactorAttempt", model);
+ message.Category = "FailedTwoFactorAttempt";
+ await _mailDeliveryService.SendEmailAsync(message);
+ }
+
public async Task SendMasterPasswordHintEmailAsync(string email, string hint)
{
var message = CreateDefaultMessage("Your Master Password Hint", email);
diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs
index d8f2488088..5847aaf929 100644
--- a/src/Core/Services/NoopImplementations/NoopMailService.cs
+++ b/src/Core/Services/NoopImplementations/NoopMailService.cs
@@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
@@ -92,6 +93,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
+ public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
+ {
+ return Task.FromResult(0);
+ }
+
public Task SendWelcomeEmailAsync(User user)
{
return Task.FromResult(0);
diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
index 3317e18264..5a8cb8645e 100644
--- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
@@ -36,6 +36,7 @@ public abstract class BaseRequestValidator where T : class
private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
private readonly IAuthRequestRepository _authRequestRepository;
+ private readonly IMailService _mailService;
protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
@@ -61,7 +62,8 @@ public abstract class BaseRequestValidator where T : class
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery,
- IAuthRequestRepository authRequestRepository
+ IAuthRequestRepository authRequestRepository,
+ IMailService mailService
)
{
_userManager = userManager;
@@ -80,6 +82,7 @@ public abstract class BaseRequestValidator where T : class
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery;
_authRequestRepository = authRequestRepository;
+ _mailService = mailService;
}
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@@ -160,6 +163,7 @@ public abstract class BaseRequestValidator where T : class
}
else
{
+ await SendFailedTwoFactorEmail(user, twoFactorProviderType);
await UpdateFailedAuthDetailsAsync(user);
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
}
@@ -373,6 +377,14 @@ public abstract class BaseRequestValidator where T : class
await _userRepository.ReplaceAsync(user);
}
+ private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType)
+ {
+ if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
+ {
+ await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress);
+ }
+ }
+
private async Task GetMasterPasswordPolicyAsync(User user)
{
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not
diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
index c3d7908dc9..6223d8dc9c 100644
--- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
@@ -46,7 +46,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator();
_policyRequirementQuery = Substitute.For();
_authRequestRepository = Substitute.For();
+ _mailService = Substitute.For();
_sut = new BaseRequestValidatorTestWrapper(
_userManager,
@@ -88,7 +90,8 @@ public class BaseRequestValidatorTests
_ssoConfigRepository,
_userDecryptionOptionsBuilder,
_policyRequirementQuery,
- _authRequestRepository);
+ _authRequestRepository,
+ _mailService);
}
/* Logic path
@@ -278,6 +281,98 @@ public class BaseRequestValidatorTests
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any());
}
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = requestContext.User;
+
+ // 1 -> initial validation passes
+ _sut.isValid = true;
+
+ // 2 -> enable the FailedTwoFactorEmail feature flag
+ _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
+
+ // 3 -> set up 2FA as required
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(true, null)));
+
+ // 4 -> provide invalid 2FA token
+ tokenRequest.Raw["TwoFactorToken"] = "invalid_token";
+ tokenRequest.Raw["TwoFactorProvider"] = TwoFactorProviderType.Email.ToString();
+
+ // 5 -> set up 2FA verification to fail
+ _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Email, "invalid_token")
+ .Returns(Task.FromResult(false));
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ // Verify that the failed 2FA email was sent
+ await _mailService.Received(1)
+ .SendFailedTwoFactorAttemptEmailAsync(
+ user.Email,
+ TwoFactorProviderType.Email,
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = requestContext.User;
+
+ // 1 -> initial validation passes
+ _sut.isValid = true;
+
+ // 2 -> enable the FailedTwoFactorEmail feature flag
+ _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
+
+ // 3 -> set up 2FA as required
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(true, null)));
+
+ // 4 -> provide invalid remember token (remember token expired)
+ tokenRequest.Raw["TwoFactorToken"] = "expired_remember_token";
+ tokenRequest.Raw["TwoFactorProvider"] = "5"; // Remember provider
+
+ // 5 -> set up remember token verification to fail
+ _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Remember, "expired_remember_token")
+ .Returns(Task.FromResult(false));
+
+ // 6 -> set up dummy BuildTwoFactorResultAsync
+ var twoFactorResultDict = new Dictionary
+ {
+ { "TwoFactorProviders", new[] { "0", "1" } },
+ { "TwoFactorProviders2", new Dictionary() }
+ };
+ _twoFactorAuthenticationValidator
+ .BuildTwoFactorResultAsync(user, null)
+ .Returns(Task.FromResult(twoFactorResultDict));
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ // Verify that the failed 2FA email was NOT sent for remember token expiration
+ await _mailService.DidNotReceive()
+ .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
// Test grantTypes that require SSO when a user is in an organization that requires it
[Theory]
[BitAutoData("password")]
@@ -600,12 +695,4 @@ public class BaseRequestValidatorTests
Substitute.For(),
Substitute.For>>());
}
-
- private void AddValidDeviceToRequest(ValidatedTokenRequest request)
- {
- request.Raw["DeviceIdentifier"] = "DeviceIdentifier";
- request.Raw["DeviceType"] = "Android"; // must be valid device type
- request.Raw["DeviceName"] = "DeviceName";
- request.Raw["DevicePushToken"] = "DevicePushToken";
- }
}
diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
index 140e171309..db3deedf02 100644
--- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
+++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
@@ -63,7 +63,8 @@ IBaseRequestValidatorTestWrapper
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery,
- IAuthRequestRepository authRequestRepository) :
+ IAuthRequestRepository authRequestRepository,
+ IMailService mailService) :
base(
userManager,
userService,
@@ -80,7 +81,8 @@ IBaseRequestValidatorTestWrapper
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery,
- authRequestRepository)
+ authRequestRepository,
+ mailService)
{
}