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:
parent
1292736f54
commit
d2567dd42d
@ -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;
|
||||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email);
|
public bool TokenNumeric { get; protected set; } = true;
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _serviceProvider.GetRequiredService<IUserService>().
|
public virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||||
TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Email, user);
|
{
|
||||||
|
return Task.FromResult(!string.IsNullOrEmpty(user.Email));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
public virtual async 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]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
56
src/Core/Auth/Identity/EmailTwoFactorTokenProvider.cs
Normal file
56
src/Core/Auth/Identity/EmailTwoFactorTokenProvider.cs
Normal 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"]);
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -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]}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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));
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
@ -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>
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user