diff --git a/src/Core/Auth/Identity/EmailTokenProvider.cs b/src/Core/Auth/Identity/EmailTokenProvider.cs index 6ef473c4b..1db9e13ee 100644 --- a/src/Core/Auth/Identity/EmailTokenProvider.cs +++ b/src/Core/Auth/Identity/EmailTokenProvider.cs @@ -1,7 +1,6 @@ -using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models; +using System.Text; using Bit.Core.Entities; -using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; @@ -10,100 +9,55 @@ namespace Bit.Core.Auth.Identity; public class EmailTokenProvider : IUserTwoFactorTokenProvider { - private const string CacheKeyFormat = "Email_TOTP_{0}_{1}"; + private const string CacheKeyFormat = "EmailToken_{0}_{1}_{2}"; - private readonly IServiceProvider _serviceProvider; private readonly IDistributedCache _distributedCache; private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions; public EmailTokenProvider( - IServiceProvider serviceProvider, [FromKeyedServices("persistent")] IDistributedCache distributedCache) { - _serviceProvider = serviceProvider; _distributedCache = distributedCache; _distributedCacheEntryOptions = new DistributedCacheEntryOptions { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20) + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; } - public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) - { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (!HasProperMetaData(provider)) - { - return false; - } + public int TokenLength { get; protected set; } = 8; + public bool TokenAlpha { get; protected set; } = false; + public bool TokenNumeric { get; protected set; } = true; - return await _serviceProvider.GetRequiredService(). - TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user); + public virtual Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + { + return Task.FromResult(!string.IsNullOrEmpty(user.Email)); } - public Task GenerateAsync(string purpose, UserManager manager, User user) + public virtual async Task GenerateAsync(string purpose, UserManager manager, User user) { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (!HasProperMetaData(provider)) - { - return null; - } - - return Task.FromResult(RedactEmail((string)provider.MetaData["Email"])); + var code = CoreHelpers.SecureRandomString(TokenLength, TokenAlpha, true, false, TokenNumeric, false); + var cacheKey = string.Format(CacheKeyFormat, user.Id, user.SecurityStamp, purpose); + await _distributedCache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(code), _distributedCacheEntryOptions); + return code; } public async Task ValidateAsync(string purpose, string token, UserManager manager, User user) { - var cacheKey = string.Format(CacheKeyFormat, user.Id, token); + var cacheKey = string.Format(CacheKeyFormat, user.Id, user.SecurityStamp, purpose); var cachedValue = await _distributedCache.GetAsync(cacheKey); - if (cachedValue != null) + if (cachedValue == null) { return false; } - var valid = await _serviceProvider.GetRequiredService().VerifyTwoFactorEmailAsync(user, token); + var code = Encoding.UTF8.GetString(cachedValue); + var valid = string.Equals(token, code); if (valid) { - await _distributedCache.SetAsync(cacheKey, [1], _distributedCacheEntryOptions); + await _distributedCache.RemoveAsync(cacheKey); } return valid; } - - private bool HasProperMetaData(TwoFactorProvider provider) - { - return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") && - !string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]); - } - - private static string RedactEmail(string email) - { - var emailParts = email.Split('@'); - - string shownPart = null; - if (emailParts[0].Length > 2 && emailParts[0].Length <= 4) - { - shownPart = emailParts[0].Substring(0, 1); - } - else if (emailParts[0].Length > 4) - { - shownPart = emailParts[0].Substring(0, 2); - } - else - { - shownPart = string.Empty; - } - - string redactedPart = null; - if (emailParts[0].Length > 4) - { - redactedPart = new string('*', emailParts[0].Length - 2); - } - else - { - redactedPart = new string('*', emailParts[0].Length - shownPart.Length); - } - - return $"{shownPart}{redactedPart}@{emailParts[1]}"; - } } diff --git a/src/Core/Auth/Identity/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/EmailTwoFactorTokenProvider.cs new file mode 100644 index 000000000..607d86a13 --- /dev/null +++ b/src/Core/Auth/Identity/EmailTwoFactorTokenProvider.cs @@ -0,0 +1,56 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Auth.Identity; + +public class EmailTwoFactorTokenProvider : EmailTokenProvider +{ + private readonly IServiceProvider _serviceProvider; + + public EmailTwoFactorTokenProvider( + IServiceProvider serviceProvider, + [FromKeyedServices("persistent")] + IDistributedCache distributedCache) : + base(distributedCache) + { + _serviceProvider = serviceProvider; + + TokenAlpha = false; + TokenNumeric = true; + TokenLength = 6; + } + + public override async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); + if (!HasProperMetaData(provider)) + { + return false; + } + + return await _serviceProvider.GetRequiredService(). + TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user); + } + + public override Task GenerateAsync(string purpose, UserManager manager, User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); + if (!HasProperMetaData(provider)) + { + return null; + } + + return base.GenerateAsync(purpose, manager, user); + } + + private static bool HasProperMetaData(TwoFactorProvider provider) + { + return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") && + !string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]); + } +} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 9239b3a2b..c0f52fe97 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -325,9 +325,8 @@ public class UserService : UserManager, IUserService, IDisposable } var email = ((string)provider.MetaData["Email"]).ToLowerInvariant(); - var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, - "2faEmail:" + email); - + var token = await base.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); await _mailService.SendTwoFactorEmailAsync(email, token); } @@ -340,8 +339,8 @@ public class UserService : UserManager, IUserService, IDisposable } var email = ((string)provider.MetaData["Email"]).ToLowerInvariant(); - return await base.VerifyUserTokenAsync(user, TokenOptions.DefaultEmailProvider, - "2faEmail:" + email, token); + return await base.VerifyTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), token); } public async Task StartWebAuthnRegistrationAsync(User user) diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index c263ccdbe..c4f7595c2 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -876,4 +876,40 @@ public static class CoreHelpers { return _whiteSpaceRegex.Replace(input, newValue); } + + public static string RedactEmailAddress(string email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return null; + } + + var emailParts = email.Split('@'); + + string shownPart; + if (emailParts[0].Length > 2 && emailParts[0].Length <= 4) + { + shownPart = emailParts[0].Substring(0, 1); + } + else if (emailParts[0].Length > 4) + { + shownPart = emailParts[0].Substring(0, 2); + } + else + { + shownPart = string.Empty; + } + + string redactedPart; + if (emailParts[0].Length > 4) + { + redactedPart = new string('*', emailParts[0].Length - 2); + } + else + { + redactedPart = new string('*', emailParts[0].Length - shownPart.Length); + } + + return $"{shownPart}{redactedPart}@{emailParts[1]}"; + } } diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index b9a262389..a7e7254b8 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -511,7 +511,9 @@ public abstract class BaseRequestValidator where T : class } else if (type == TwoFactorProviderType.Email) { - return new Dictionary { ["Email"] = token }; + var twoFactorEmail = (string)provider.MetaData["Email"]; + var redactedEmail = CoreHelpers.RedactEmailAddress(twoFactorEmail); + return new Dictionary { ["Email"] = redactedEmail }; } else if (type == TwoFactorProviderType.YubiKey) { diff --git a/src/Identity/appsettings.Development.json b/src/Identity/appsettings.Development.json index 2eaecdfa3..0fdc9db98 100644 --- a/src/Identity/appsettings.Development.json +++ b/src/Identity/appsettings.Development.json @@ -14,6 +14,12 @@ "internalVault": "https://localhost:8080", "internalSso": "http://localhost:51822" }, + "mail": { + "smtp": { + "host": "localhost", + "port": 10250 + } + }, "attachment": { "connectionString": "UseDevelopmentStorage=true" }, diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 3f5b464b5..3de33e52a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -400,7 +400,7 @@ public static class ServiceCollectionExtensions .AddTokenProvider>(TokenOptions.DefaultProvider) .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator)) - .AddTokenProvider( + .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)) .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey)) @@ -408,7 +408,7 @@ public static class ServiceCollectionExtensions CoreHelpers.CustomProviderName(TwoFactorProviderType.Duo)) .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)) - .AddTokenProvider>(TokenOptions.DefaultEmailProvider) + .AddTokenProvider(TokenOptions.DefaultEmailProvider) .AddTokenProvider( CoreHelpers.CustomProviderName(TwoFactorProviderType.WebAuthn)); diff --git a/test/Core.Test/Auth/Identity/EmailTokenProviderTests.cs b/test/Core.Test/Auth/Identity/EmailTwoFactorTokenProviderTests.cs similarity index 88% rename from test/Core.Test/Auth/Identity/EmailTokenProviderTests.cs rename to test/Core.Test/Auth/Identity/EmailTwoFactorTokenProviderTests.cs index 4e85380d7..c5855c234 100644 --- a/test/Core.Test/Auth/Identity/EmailTokenProviderTests.cs +++ b/test/Core.Test/Auth/Identity/EmailTwoFactorTokenProviderTests.cs @@ -7,7 +7,7 @@ using Xunit; namespace Bit.Core.Test.Auth.Identity; -public class EmailTokenProviderTests : BaseTokenProviderTests +public class EmailTwoFactorTokenProviderTests : BaseTokenProviderTests { public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Email; @@ -38,7 +38,7 @@ public class EmailTokenProviderTests : BaseTokenProviderTests metaData, bool expectedResponse, - User user, SutProvider sutProvider) + User user, SutProvider sutProvider) { await base.RunCanGenerateTwoFactorTokenAsync(metaData, expectedResponse, user, sutProvider); } diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index e66de5698..19ef6991d 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -89,10 +89,10 @@ public class UserServiceTests .CanGenerateTwoFactorTokenAsync(Arg.Any>(), user) .Returns(Task.FromResult(true)); userTwoFactorTokenProvider - .GenerateAsync("2faEmail:" + email, Arg.Any>(), user) + .GenerateAsync("TwoFactor", Arg.Any>(), user) .Returns(Task.FromResult(token)); - sutProvider.Sut.RegisterTokenProvider("Email", userTwoFactorTokenProvider); + sutProvider.Sut.RegisterTokenProvider("Custom_Email", userTwoFactorTokenProvider); user.SetTwoFactorProviders(new Dictionary {