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