1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +00:00

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.

(cherry picked from commit 3c5de319d1)
This commit is contained in:
Todd Martin
2025-08-11 16:39:43 -04:00
committed by Todd Martin
parent e88c9b3525
commit 3b23f45cba
13 changed files with 212 additions and 19 deletions

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; font-weight: bold;" valign="top">
We've detected a failed login attempt
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
If you're having trouble with two-step login, please visit the <a target="_blank" clicktracking=off href="https://help.bitwarden.com" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #175DDC; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; text-decoration: underline;">Help Center</a>.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
If you did not recently try to log in, open the <a target="_blank" clicktracking=off href="{{{WebVaultUrl}}}" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #175DDC; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; text-decoration: underline;">web app</a> and take these immediate steps to secure your Bitwarden account:
<ul>
<li style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Deauthorize all devices</li>
<li style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Change your master password</li>
</ul>
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<hr />
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Account:</b> {{AffectedEmail}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Two-Step Login Method:</b> {{TwoFactorType}} <br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Date:</b> {{TheDate}} at {{TheTime}} {{TimeZone}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">IP Address:</b> {{IpAddress}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ public abstract class BaseRequestValidator<T> 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<T> 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<T> 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<T> 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<T> 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<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicyAsync(User user)
{
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not

View File

@@ -46,7 +46,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IUpdateInstallationCommand updateInstallationCommand,
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository)
IAuthRequestRepository authRequestRepository,
IMailService mailService)
: base(
userManager,
userService,
@@ -63,7 +64,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery,
authRequestRepository)
authRequestRepository,
mailService)
{
_userManager = userManager;
_updateInstallationCommand = updateInstallationCommand;

View File

@@ -40,7 +40,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
IMailService mailService)
: base(
userManager,
userService,
@@ -57,7 +58,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery,
authRequestRepository)
authRequestRepository,
mailService)
{
_userManager = userManager;
_currentContext = currentContext;

View File

@@ -49,7 +49,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository)
IAuthRequestRepository authRequestRepository,
IMailService mailService)
: base(
userManager,
userService,
@@ -66,7 +67,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery,
authRequestRepository)
authRequestRepository,
mailService)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;

View File

@@ -50,6 +50,7 @@ public class BaseRequestValidatorTests
private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IMailService _mailService;
private readonly BaseRequestValidatorTestWrapper _sut;
@@ -71,6 +72,7 @@ public class BaseRequestValidatorTests
_userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
_mailService = Substitute.For<IMailService>();
_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<AuthRequest>());
}
[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<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(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<DateTime>(),
Arg.Any<string>());
}
[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<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(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<string, object>
{
{ "TwoFactorProviders", new[] { "0", "1" } },
{ "TwoFactorProviders2", new Dictionary<string, object>() }
};
_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<string>(), Arg.Any<TwoFactorProviderType>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
// 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<IServiceProvider>(),
Substitute.For<ILogger<UserManager<User>>>());
}
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";
}
}

View File

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