From 2ac513e15a615eb19529a4c07d1b2397d6208d2a Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 4 May 2023 15:12:03 -0400 Subject: [PATCH] Defect/PM-1196 - SSO with Email 2FA Flow - Email Required error fixed (#2874) * PM-1196 - Created first draft solution for solving SSO with Email 2FA serverside. Per architectural review discussion, will be replacing OTP use with expiring tokenable implementation in order to decouple the OTP implementation from the need for an auth factor when arriving on the email 2FA screen post SSO. * PM-1196 - Refactored OTP solution to leverage newly created SsoEmail2faSessionTokenable. Working now but some code cleanup required. Might revisit whether or not we still send down email alongside the token or not to make the SendEmailLoginAsync method more streamlined. * PM-1196 - Send down email separately on token rejection b/c of 2FA required so that 2FA Controller send email login can be refactored to be much cleaner with email required. * PM-1196 - Fix lint issues w/ dotnet format. * PM-1196 - More formatting issue fixes. * PM-1196 - Remove unnecessary check as email is required again on TwoFactorEmailRequestModel * PM-1196 - Update SsoEmail2faSessionTokenable to expire after just over 2 min to match client side auth service expiration of 2 min with small buffer. * PM-1196 - Fix lint issue w/ dotnet format. * PM-1196 - Per PR feedback, move CustomTokenRequestValidator constructor param to new line * PM-1196 - Per PR feedback, update ThrowDelayedBadRequestExceptionAsync to return a task so that it can be awaited and so that the calling code can handle any exceptions that occur during its execution * PM-1196 - Per PR feedback, refactor SsoEmail2faSessionTokenable to leverage TimeSpan vs double for token expiration lifetime. --- .../Auth/Controllers/TwoFactorController.cs | 73 ++++++++++++++----- .../SecretVerificationRequestModel.cs | 2 +- .../Models/Request/TwoFactorRequestModels.cs | 12 ++- .../Tokenables/SsoEmail2faSessionTokenable.cs | 50 +++++++++++++ .../IdentityServer/BaseRequestValidator.cs | 71 ++++++++++-------- .../CustomTokenRequestValidator.cs | 16 ++-- .../ResourceOwnerPasswordValidator.cs | 7 +- .../Utilities/ServiceCollectionExtensions.cs | 6 ++ 8 files changed, 181 insertions(+), 56 deletions(-) create mode 100644 src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index c927138da..533afbbba 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -5,6 +5,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; @@ -12,6 +13,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Fido2NetLib; using Microsoft.AspNetCore.Authorization; @@ -31,6 +33,7 @@ public class TwoFactorController : Controller private readonly UserManager _userManager; private readonly ICurrentContext _currentContext; private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; + private readonly IDataProtectorTokenFactory _tokenDataFactory; public TwoFactorController( IUserService userService, @@ -39,7 +42,8 @@ public class TwoFactorController : Controller GlobalSettings globalSettings, UserManager userManager, ICurrentContext currentContext, - IVerifyAuthRequestCommand verifyAuthRequestCommand) + IVerifyAuthRequestCommand verifyAuthRequestCommand, + IDataProtectorTokenFactory tokenDataFactory) { _userService = userService; _organizationRepository = organizationRepository; @@ -48,6 +52,7 @@ public class TwoFactorController : Controller _userManager = userManager; _currentContext = currentContext; _verifyAuthRequestCommand = verifyAuthRequestCommand; + _tokenDataFactory = tokenDataFactory; } [HttpGet("")] @@ -85,7 +90,8 @@ public class TwoFactorController : Controller } [HttpPost("get-authenticator")] - public async Task GetAuthenticator([FromBody] SecretVerificationRequestModel model) + public async Task GetAuthenticator( + [FromBody] SecretVerificationRequestModel model) { var user = await CheckAsync(model, false); var response = new TwoFactorAuthenticatorResponseModel(user); @@ -101,7 +107,7 @@ public class TwoFactorController : Controller model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) { await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); @@ -158,7 +164,8 @@ public class TwoFactorController : Controller } catch (DuoException) { - throw new BadRequestException("Duo configuration settings are not valid. Please re-check the Duo Admin panel."); + throw new BadRequestException( + "Duo configuration settings are not valid. Please re-check the Duo Admin panel."); } model.ToUser(user); @@ -215,7 +222,8 @@ public class TwoFactorController : Controller } catch (DuoException) { - throw new BadRequestException("Duo configuration settings are not valid. Please re-check the Duo Admin panel."); + throw new BadRequestException( + "Duo configuration settings are not valid. Please re-check the Duo Admin panel."); } model.ToOrganization(organization); @@ -254,12 +262,14 @@ public class TwoFactorController : Controller { throw new BadRequestException("Unable to complete WebAuthn registration."); } + var response = new TwoFactorWebAuthnResponseModel(user); return response; } [HttpDelete("webauthn")] - public async Task DeleteWebAuthn([FromBody] TwoFactorWebAuthnDeleteRequestModel model) + public async Task DeleteWebAuthn( + [FromBody] TwoFactorWebAuthnDeleteRequestModel model) { var user = await CheckAsync(model, true); await _userService.DeleteWebAuthnKeyAsync(user, model.Id.Value); @@ -285,30 +295,46 @@ public class TwoFactorController : Controller [AllowAnonymous] [HttpPost("send-email-login")] - public async Task SendEmailLogin([FromBody] TwoFactorEmailRequestModel model) + public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailRequestModel requestModel) { - var user = await _userManager.FindByEmailAsync(model.Email.ToLowerInvariant()); + var user = await _userManager.FindByEmailAsync(requestModel.Email.ToLowerInvariant()); + if (user != null) { // check if 2FA email is from passwordless - if (!string.IsNullOrEmpty(model.AuthRequestAccessCode)) + if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { if (await _verifyAuthRequestCommand - .VerifyAuthRequestAsync(new Guid(model.AuthRequestId), model.AuthRequestAccessCode)) + .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), + requestModel.AuthRequestAccessCode)) { await _userService.SendTwoFactorEmailAsync(user); return; } } - else if (await _userService.VerifySecretAsync(user, model.Secret)) + else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) + { + if (this.ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) + { + await _userService.SendTwoFactorEmailAsync(user); + return; + } + else + { + await this.ThrowDelayedBadRequestExceptionAsync( + "Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.", + 2000); + } + } + else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) { await _userService.SendTwoFactorEmailAsync(user); return; } } - await Task.Delay(2000); - throw new BadRequestException("Cannot send two-factor email."); + await this.ThrowDelayedBadRequestExceptionAsync( + "Cannot send two-factor email.", 2000); } [HttpPut("email")] @@ -319,7 +345,7 @@ public class TwoFactorController : Controller model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) { await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); @@ -377,7 +403,7 @@ public class TwoFactorController : Controller public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) { if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode, - _organizationService)) + _organizationService)) { await Task.Delay(2000); throw new BadRequestException(string.Empty, "Invalid information. Try again."); @@ -393,7 +419,8 @@ public class TwoFactorController : Controller [Obsolete("Leaving this for backwards compatibilty on clients")] [HttpPut("device-verification-settings")] - public Task PutDeviceVerificationSettings([FromBody] DeviceVerificationRequestModel model) + public Task PutDeviceVerificationSettings( + [FromBody] DeviceVerificationRequestModel model) { return Task.FromResult(new DeviceVerificationResponseModel(false, false)); } @@ -428,7 +455,7 @@ public class TwoFactorController : Controller } if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey), value)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey), value)) { await Task.Delay(2000); throw new BadRequestException(name, $"{name} is invalid."); @@ -438,4 +465,16 @@ public class TwoFactorController : Controller await Task.Delay(500); } } + + private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user) + { + return _tokenDataFactory.TryUnprotect(ssoEmail2FaSessionToken, out var decryptedToken) && + decryptedToken.Valid && decryptedToken.TokenIsValid(user); + } + + private async Task ThrowDelayedBadRequestExceptionAsync(string message, int delayTime = 2000) + { + await Task.Delay(delayTime); + throw new BadRequestException(message); + } } diff --git a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs index ee6930128..c0191728f 100644 --- a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -14,7 +14,7 @@ public class SecretVerificationRequestModel : IValidatableObject { if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode)) { - yield return new ValidationResult("MasterPasswordHash, OTP or AccessCode must be supplied."); + yield return new ValidationResult("MasterPasswordHash, OTP, or AccessCode must be supplied."); } } } diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 9759fb70b..a9d5db225 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -202,9 +202,9 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel [EmailAddress] [StringLength(256)] public string Email { get; set; } - public string AuthRequestId { get; set; } - + // An auth session token used for obtaining email and as an authN factor for the sending of emailed 2FA OTPs. + public string SsoEmail2FaSessionToken { get; set; } public User ToUser(User extistingUser) { var providers = extistingUser.GetTwoFactorProviders(); @@ -225,6 +225,14 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel extistingUser.SetTwoFactorProviders(providers); return extistingUser; } + + public override IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode) && string.IsNullOrEmpty((SsoEmail2FaSessionToken))) + { + yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, or SsoEmail2faSessionToken must be supplied."); + } + } } public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs new file mode 100644 index 000000000..e8c8a33f5 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +// This token just provides a verifiable authN mechanism for the API service +// TwoFactorController.cs SendEmailLogin anonymous endpoint so it cannot be +// used maliciously. +public class SsoEmail2faSessionTokenable : ExpiringTokenable +{ + // Just over 2 min expiration (client expires session after 2 min) + private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(2.05); + public const string ClearTextPrefix = "BwSsoEmail2FaSessionToken_"; + public const string DataProtectorPurpose = "SsoEmail2faSessionTokenDataProtector"; + + public const string TokenIdentifier = "SsoEmail2faSessionToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid Id { get; set; } + public string Email { get; set; } + + + [JsonConstructor] + public SsoEmail2faSessionTokenable() + { + ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime); + } + + public SsoEmail2faSessionTokenable(User user) : this() + { + Id = user?.Id ?? default; + Email = user?.Email; + } + + public bool TokenIsValid(User user) + { + if (Id == default || Email == default || user == null) + { + return false; + } + + return Id == user.Id && + Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); + } + + // Validates deserialized + protected override bool TokenIsValid() => + Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email); +} diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index 54d971563..e3bf7bbd1 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -6,6 +6,7 @@ using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -16,6 +17,7 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using IdentityServer4.Validation; using Microsoft.AspNetCore.Identity; @@ -40,6 +42,7 @@ public abstract class BaseRequestValidator where T : class private readonly IPolicyRepository _policyRepository; private readonly IUserRepository _userRepository; private readonly IPolicyService _policyService; + private readonly IDataProtectorTokenFactory _tokenDataFactory; public BaseRequestValidator( UserManager userManager, @@ -57,7 +60,8 @@ public abstract class BaseRequestValidator where T : class GlobalSettings globalSettings, IPolicyRepository policyRepository, IUserRepository userRepository, - IPolicyService policyService) + IPolicyService policyService, + IDataProtectorTokenFactory tokenDataFactory) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -75,6 +79,7 @@ public abstract class BaseRequestValidator where T : class _policyRepository = policyRepository; _userRepository = userRepository; _policyService = policyService; + _tokenDataFactory = tokenDataFactory; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -92,7 +97,7 @@ public abstract class BaseRequestValidator where T : class var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString(); var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1"; var twoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && - !string.IsNullOrWhiteSpace(twoFactorProvider); + !string.IsNullOrWhiteSpace(twoFactorProvider); var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; @@ -100,6 +105,7 @@ public abstract class BaseRequestValidator where T : class { await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice); } + if (!valid || isBot) { await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); @@ -150,14 +156,16 @@ public abstract class BaseRequestValidator where T : class await BuildErrorResultAsync("No device information provided.", false, context, user); return; } + await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); } else { - SetSsoResult(context, new Dictionary - {{ - "ErrorModel", new ErrorResponseModel("SSO authentication is required.") - }}); + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); } } @@ -240,13 +248,23 @@ public abstract class BaseRequestValidator where T : class providers.Add(((byte)provider.Key).ToString(), infoDict); } - SetTwoFactorResult(context, - new Dictionary - { - { "TwoFactorProviders", providers.Keys }, - { "TwoFactorProviders2", providers }, - { "MasterPasswordPolicy", await GetMasterPasswordPolicy(user) } - }); + var twoFactorResultDict = new Dictionary + { + { "TwoFactorProviders", providers.Keys }, + { "TwoFactorProviders2", providers }, + { "MasterPasswordPolicy", await GetMasterPasswordPolicy(user) }, + }; + + // If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token + if (enabledProviders.Any(p => p.Key == TwoFactorProviderType.Email)) + { + twoFactorResultDict.Add("SsoEmail2faSessionToken", + _tokenDataFactory.Protect(new SsoEmail2faSessionTokenable(user))); + + twoFactorResultDict.Add("Email", user.Email); + } + + SetTwoFactorResult(context, twoFactorResultDict); if (enabledProviders.Count() == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email) { @@ -272,10 +290,7 @@ public abstract class BaseRequestValidator where T : class await Task.Delay(2000); // Delay for brute force. SetErrorResult(context, - new Dictionary - {{ - "ErrorModel", new ErrorResponseModel(message) - }}); + new Dictionary { { "ErrorModel", new ErrorResponseModel(message) } }); } protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); @@ -296,8 +311,8 @@ public abstract class BaseRequestValidator where T : class } var individualRequired = _userManager.SupportsUserTwoFactor && - await _userManager.GetTwoFactorEnabledAsync(user) && - (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + await _userManager.GetTwoFactorEnabledAsync(user) && + (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; Organization firstEnabledOrg = null; var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) @@ -346,7 +361,8 @@ public abstract class BaseRequestValidator where T : class PolicyType.RequireSso); // Owners and Admins are exempt from this policy if (orgPolicy != null && orgPolicy.Enabled && - (_globalSettings.Sso.EnforceSsoPolicyForAllUsers || (userOrg.Type != OrganizationUserType.Owner && userOrg.Type != OrganizationUserType.Admin))) + (_globalSettings.Sso.EnforceSsoPolicyForAllUsers || + (userOrg.Type != OrganizationUserType.Owner && userOrg.Type != OrganizationUserType.Admin))) { return false; } @@ -361,7 +377,7 @@ public abstract class BaseRequestValidator where T : class private bool OrgUsing2fa(IDictionary orgAbilities, Guid orgId) { return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; + orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; } private bool OrgCanUseSso(IDictionary orgAbilities, Guid orgId) @@ -408,6 +424,7 @@ public abstract class BaseRequestValidator where T : class { return false; } + return await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(type), token); case TwoFactorProviderType.OrganizationDuo: @@ -457,18 +474,13 @@ public abstract class BaseRequestValidator where T : class } else if (type == TwoFactorProviderType.Email) { - return new Dictionary - { - ["Email"] = token - }; + return new Dictionary { ["Email"] = token }; } else if (type == TwoFactorProviderType.YubiKey) { - return new Dictionary - { - ["Nfc"] = (bool)provider.MetaData["Nfc"] - }; + return new Dictionary { ["Nfc"] = (bool)provider.MetaData["Nfc"] }; } + return null; case TwoFactorProviderType.OrganizationDuo: if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization)) @@ -479,6 +491,7 @@ public abstract class BaseRequestValidator where T : class ["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user) }; } + return null; default: return null; diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index 02c48cb67..aef5f5c54 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -7,6 +8,7 @@ using Bit.Core.IdentityServer; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using IdentityModel; using IdentityServer4.Extensions; using IdentityServer4.Validation; @@ -37,11 +39,12 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory) : base(userManager, deviceRepository, deviceService, userService, eventService, - organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, - applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, - userRepository, policyService) + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, + userRepository, policyService, tokenDataFactory) { _userManager = userManager; _ssoConfigRepository = ssoConfigRepository; @@ -73,11 +76,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator claim.Type == JwtClaimTypes.Email)?.Value; + ?? context.Result.ValidatedRequest.ClientClaims + ?.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Email)?.Value; if (!string.IsNullOrWhiteSpace(email)) { validatorContext.User = await _userManager.FindByEmailAsync(email); } + return validatorContext.User != null; } @@ -111,6 +116,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, - userRepository, policyService) + userRepository, policyService, tokenDataFactory) { _userManager = userManager; _userService = userService; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index b01be7a28..b0a0f09ad 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -157,6 +157,12 @@ public static class ServiceCollectionExtensions SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + SsoEmail2faSessionTokenable.ClearTextPrefix, + SsoEmail2faSessionTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)