1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-20 19:14:32 -05:00

[PM-5518] Refactor Email Token Providers (#3784)

* new email token providers

* move email redaction to core helpers

* make token options configurable

* protected setters on options

* fix email token provider tests

* fix core tests

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Kyle Spearrin 2024-07-11 14:39:27 -04:00 committed by GitHub
parent 1292736f54
commit d2567dd42d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 131 additions and 78 deletions

View File

@ -1,7 +1,6 @@
using Bit.Core.Auth.Enums; using System.Text;
using Bit.Core.Auth.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Services; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -10,100 +9,55 @@ namespace Bit.Core.Auth.Identity;
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User> public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
{ {
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 IDistributedCache _distributedCache;
private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions; private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions;
public EmailTokenProvider( public EmailTokenProvider(
IServiceProvider serviceProvider,
[FromKeyedServices("persistent")] [FromKeyedServices("persistent")]
IDistributedCache distributedCache) IDistributedCache distributedCache)
{ {
_serviceProvider = serviceProvider;
_distributedCache = distributedCache; _distributedCache = distributedCache;
_distributedCacheEntryOptions = new DistributedCacheEntryOptions _distributedCacheEntryOptions = new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
}; };
} }
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user) public int TokenLength { get; protected set; } = 8;
public bool TokenAlpha { get; protected set; } = false;
public bool TokenNumeric { get; protected set; } = true;
public virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{ {
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); return Task.FromResult(!string.IsNullOrEmpty(user.Email));
if (!HasProperMetaData(provider))
{
return false;
} }
return await _serviceProvider.GetRequiredService<IUserService>(). public virtual async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user);
}
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{ {
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); var code = CoreHelpers.SecureRandomString(TokenLength, TokenAlpha, true, false, TokenNumeric, false);
if (!HasProperMetaData(provider)) var cacheKey = string.Format(CacheKeyFormat, user.Id, user.SecurityStamp, purpose);
{ await _distributedCache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(code), _distributedCacheEntryOptions);
return null; return code;
}
return Task.FromResult(RedactEmail((string)provider.MetaData["Email"]));
} }
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user) public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> 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); var cachedValue = await _distributedCache.GetAsync(cacheKey);
if (cachedValue != null) if (cachedValue == null)
{ {
return false; return false;
} }
var valid = await _serviceProvider.GetRequiredService<IUserService>().VerifyTwoFactorEmailAsync(user, token); var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
if (valid) if (valid)
{ {
await _distributedCache.SetAsync(cacheKey, [1], _distributedCacheEntryOptions); await _distributedCache.RemoveAsync(cacheKey);
} }
return valid; 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]}";
}
} }

View File

@ -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<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email);
if (!HasProperMetaData(provider))
{
return false;
}
return await _serviceProvider.GetRequiredService<IUserService>().
TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user);
}
public override Task<string> GenerateAsync(string purpose, UserManager<User> 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"]);
}
}

View File

@ -325,9 +325,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
} }
var email = ((string)provider.MetaData["Email"]).ToLowerInvariant(); var email = ((string)provider.MetaData["Email"]).ToLowerInvariant();
var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, var token = await base.GenerateTwoFactorTokenAsync(user,
"2faEmail:" + email); CoreHelpers.CustomProviderName(TwoFactorProviderType.Email));
await _mailService.SendTwoFactorEmailAsync(email, token); await _mailService.SendTwoFactorEmailAsync(email, token);
} }
@ -340,8 +339,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
} }
var email = ((string)provider.MetaData["Email"]).ToLowerInvariant(); var email = ((string)provider.MetaData["Email"]).ToLowerInvariant();
return await base.VerifyUserTokenAsync(user, TokenOptions.DefaultEmailProvider, return await base.VerifyTwoFactorTokenAsync(user,
"2faEmail:" + email, token); CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), token);
} }
public async Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user) public async Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user)

View File

@ -876,4 +876,40 @@ public static class CoreHelpers
{ {
return _whiteSpaceRegex.Replace(input, newValue); 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]}";
}
} }

View File

@ -511,7 +511,9 @@ public abstract class BaseRequestValidator<T> where T : class
} }
else if (type == TwoFactorProviderType.Email) else if (type == TwoFactorProviderType.Email)
{ {
return new Dictionary<string, object> { ["Email"] = token }; var twoFactorEmail = (string)provider.MetaData["Email"];
var redactedEmail = CoreHelpers.RedactEmailAddress(twoFactorEmail);
return new Dictionary<string, object> { ["Email"] = redactedEmail };
} }
else if (type == TwoFactorProviderType.YubiKey) else if (type == TwoFactorProviderType.YubiKey)
{ {

View File

@ -14,6 +14,12 @@
"internalVault": "https://localhost:8080", "internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822" "internalSso": "http://localhost:51822"
}, },
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
},
"attachment": { "attachment": {
"connectionString": "UseDevelopmentStorage=true" "connectionString": "UseDevelopmentStorage=true"
}, },

View File

@ -400,7 +400,7 @@ public static class ServiceCollectionExtensions
.AddTokenProvider<DataProtectorTokenProvider<User>>(TokenOptions.DefaultProvider) .AddTokenProvider<DataProtectorTokenProvider<User>>(TokenOptions.DefaultProvider)
.AddTokenProvider<AuthenticatorTokenProvider>( .AddTokenProvider<AuthenticatorTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator)) CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator))
.AddTokenProvider<EmailTokenProvider>( .AddTokenProvider<EmailTwoFactorTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)) CoreHelpers.CustomProviderName(TwoFactorProviderType.Email))
.AddTokenProvider<YubicoOtpTokenProvider>( .AddTokenProvider<YubicoOtpTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey)) CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey))
@ -408,7 +408,7 @@ public static class ServiceCollectionExtensions
CoreHelpers.CustomProviderName(TwoFactorProviderType.Duo)) CoreHelpers.CustomProviderName(TwoFactorProviderType.Duo))
.AddTokenProvider<TwoFactorRememberTokenProvider>( .AddTokenProvider<TwoFactorRememberTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)) CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember))
.AddTokenProvider<EmailTokenProvider<User>>(TokenOptions.DefaultEmailProvider) .AddTokenProvider<EmailTokenProvider>(TokenOptions.DefaultEmailProvider)
.AddTokenProvider<WebAuthnTokenProvider>( .AddTokenProvider<WebAuthnTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.WebAuthn)); CoreHelpers.CustomProviderName(TwoFactorProviderType.WebAuthn));

View File

@ -7,7 +7,7 @@ using Xunit;
namespace Bit.Core.Test.Auth.Identity; namespace Bit.Core.Test.Auth.Identity;
public class EmailTokenProviderTests : BaseTokenProviderTests<EmailTokenProvider> public class EmailTwoFactorTokenProviderTests : BaseTokenProviderTests<EmailTwoFactorTokenProvider>
{ {
public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Email; public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Email;
@ -38,7 +38,7 @@ public class EmailTokenProviderTests : BaseTokenProviderTests<EmailTokenProvider
[Theory, BitMemberAutoData(nameof(CanGenerateTwoFactorTokenAsyncData))] [Theory, BitMemberAutoData(nameof(CanGenerateTwoFactorTokenAsyncData))]
public override async Task RunCanGenerateTwoFactorTokenAsync(Dictionary<string, object> metaData, bool expectedResponse, public override async Task RunCanGenerateTwoFactorTokenAsync(Dictionary<string, object> metaData, bool expectedResponse,
User user, SutProvider<EmailTokenProvider> sutProvider) User user, SutProvider<EmailTwoFactorTokenProvider> sutProvider)
{ {
await base.RunCanGenerateTwoFactorTokenAsync(metaData, expectedResponse, user, sutProvider); await base.RunCanGenerateTwoFactorTokenAsync(metaData, expectedResponse, user, sutProvider);
} }

View File

@ -89,10 +89,10 @@ public class UserServiceTests
.CanGenerateTwoFactorTokenAsync(Arg.Any<UserManager<User>>(), user) .CanGenerateTwoFactorTokenAsync(Arg.Any<UserManager<User>>(), user)
.Returns(Task.FromResult(true)); .Returns(Task.FromResult(true));
userTwoFactorTokenProvider userTwoFactorTokenProvider
.GenerateAsync("2faEmail:" + email, Arg.Any<UserManager<User>>(), user) .GenerateAsync("TwoFactor", Arg.Any<UserManager<User>>(), user)
.Returns(Task.FromResult(token)); .Returns(Task.FromResult(token));
sutProvider.Sut.RegisterTokenProvider("Email", userTwoFactorTokenProvider); sutProvider.Sut.RegisterTokenProvider("Custom_Email", userTwoFactorTokenProvider);
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{ {