mirror of
https://github.com/bitwarden/server
synced 2026-01-02 16:43:25 +00:00
[PM-6666] Two factor Validator refactor (#4894)
* initial device removal * Unit Testing * Finalized tests * initial commit refactoring two factor * initial tests * Unit Tests * initial device removal * Unit Testing * Finalized tests * initial commit refactoring two factor * initial tests * Unit Tests * Fixing some tests * renaming and reorganizing * refactored two factor flows * fixed a possible issue with object mapping. * Update TwoFactorAuthenticationValidator.cs removed unused code
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public abstract class BaseRequestValidator<T> where T : class
|
||||
{
|
||||
private UserManager<User> _userManager;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IDeviceValidator _deviceValidator;
|
||||
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
protected ICurrentContext CurrentContext { get; }
|
||||
protected IPolicyService PolicyService { get; }
|
||||
protected IFeatureService FeatureService { get; }
|
||||
protected ISsoConfigRepository SsoConfigRepository { get; }
|
||||
protected IUserService _userService { get; }
|
||||
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
|
||||
|
||||
public BaseRequestValidator(
|
||||
UserManager<User> userManager,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IMailService mailService,
|
||||
ILogger logger,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IUserRepository userRepository,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userService = userService;
|
||||
_eventService = eventService;
|
||||
_deviceValidator = deviceValidator;
|
||||
_twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_mailService = mailService;
|
||||
_logger = logger;
|
||||
CurrentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
PolicyService = policyService;
|
||||
_userRepository = userRepository;
|
||||
FeatureService = featureService;
|
||||
SsoConfigRepository = ssoConfigRepository;
|
||||
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
|
||||
}
|
||||
|
||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
var isBot = validatorContext.CaptchaResponse?.IsBot ?? false;
|
||||
if (isBot)
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"Login attempt for {0} detected as a captcha bot with score {1}.",
|
||||
request.UserName, validatorContext.CaptchaResponse.Score);
|
||||
}
|
||||
|
||||
var valid = await ValidateContextAsync(context, validatorContext);
|
||||
var user = validatorContext.User;
|
||||
if (!valid)
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
|
||||
}
|
||||
|
||||
if (!valid || isBot)
|
||||
{
|
||||
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
|
||||
return;
|
||||
}
|
||||
|
||||
var (isTwoFactorRequired, twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
|
||||
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
|
||||
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
|
||||
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
|
||||
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
||||
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
||||
|
||||
if (isTwoFactorRequired)
|
||||
{
|
||||
// 2FA required and not provided response
|
||||
if (!validTwoFactorRequest ||
|
||||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
|
||||
{
|
||||
var resultDict = await _twoFactorAuthenticationValidator
|
||||
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
||||
if (resultDict == null)
|
||||
{
|
||||
await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Include Master Password Policy in 2FA response
|
||||
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
|
||||
SetTwoFactorResult(context, resultDict);
|
||||
return;
|
||||
}
|
||||
|
||||
var verified = await _twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
|
||||
|
||||
// 2FA required but request not valid or remember token expired response
|
||||
if (!verified || isBot)
|
||||
{
|
||||
if (twoFactorProviderType != TwoFactorProviderType.Remember)
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
|
||||
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
|
||||
}
|
||||
else if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
||||
{
|
||||
var resultDict = await _twoFactorAuthenticationValidator
|
||||
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
||||
|
||||
// Include Master Password Policy in 2FA response
|
||||
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
|
||||
SetTwoFactorResult(context, resultDict);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
validTwoFactorRequest = false;
|
||||
twoFactorRemember = false;
|
||||
}
|
||||
|
||||
// Force legacy users to the web for migration
|
||||
if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers))
|
||||
{
|
||||
if (UserService.IsLegacyUser(user) && request.ClientId != "web")
|
||||
{
|
||||
await FailAuthForLegacyUserAsync(user, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (await IsValidAuthTypeAsync(user, request.GrantType))
|
||||
{
|
||||
var device = await _deviceValidator.SaveDeviceAsync(user, request);
|
||||
if (device == null)
|
||||
{
|
||||
await BuildErrorResultAsync("No device information provided.", false, context, user);
|
||||
return;
|
||||
}
|
||||
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task FailAuthForLegacyUserAsync(User user, T context)
|
||||
{
|
||||
await BuildErrorResultAsync(
|
||||
$"Encryption key migration is required. Please log in to the web vault at {_globalSettings.BaseServiceUri.VaultWithHash}",
|
||||
false, context, user);
|
||||
}
|
||||
|
||||
protected abstract Task<bool> ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext);
|
||||
|
||||
protected async Task BuildSuccessResultAsync(User user, T context, Device device, bool sendRememberToken)
|
||||
{
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn);
|
||||
|
||||
var claims = new List<Claim>();
|
||||
|
||||
if (device != null)
|
||||
{
|
||||
claims.Add(new Claim(Claims.Device, device.Identifier));
|
||||
}
|
||||
|
||||
var customResponse = new Dictionary<string, object>();
|
||||
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
|
||||
{
|
||||
customResponse.Add("PrivateKey", user.PrivateKey);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Key))
|
||||
{
|
||||
customResponse.Add("Key", user.Key);
|
||||
}
|
||||
|
||||
customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
|
||||
customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
|
||||
customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
|
||||
customResponse.Add("Kdf", (byte)user.Kdf);
|
||||
customResponse.Add("KdfIterations", user.KdfIterations);
|
||||
customResponse.Add("KdfMemory", user.KdfMemory);
|
||||
customResponse.Add("KdfParallelism", user.KdfParallelism);
|
||||
customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
|
||||
|
||||
if (sendRememberToken)
|
||||
{
|
||||
var token = await _userManager.GenerateTwoFactorTokenAsync(user,
|
||||
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
|
||||
customResponse.Add("TwoFactorToken", token);
|
||||
}
|
||||
|
||||
await ResetFailedAuthDetailsAsync(user);
|
||||
await SetSuccessResult(context, user, claims, customResponse);
|
||||
}
|
||||
|
||||
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
|
||||
{
|
||||
if (user != null)
|
||||
{
|
||||
await _eventService.LogUserEventAsync(user.Id,
|
||||
twoFactorRequest ? EventType.User_FailedLogIn2fa : EventType.User_FailedLogIn);
|
||||
}
|
||||
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
_logger.LogWarning(Constants.BypassFiltersEventId,
|
||||
string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".",
|
||||
$" {CurrentContext.IpAddress}"));
|
||||
}
|
||||
|
||||
await Task.Delay(2000); // Delay for brute force.
|
||||
SetErrorResult(context,
|
||||
new Dictionary<string, object> { { "ErrorModel", new ErrorResponseModel(message) } });
|
||||
}
|
||||
|
||||
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
|
||||
|
||||
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
|
||||
|
||||
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
|
||||
Dictionary<string, object> customResponse);
|
||||
|
||||
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
||||
protected abstract ClaimsPrincipal GetSubject(T context);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
|
||||
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
|
||||
/// </summary>
|
||||
/// <param name="user">user trying to login</param>
|
||||
/// <param name="grantType">magic string identifying the grant type requested</param>
|
||||
/// <returns></returns>
|
||||
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType)
|
||||
{
|
||||
if (grantType == "authorization_code" || grantType == "client_credentials")
|
||||
{
|
||||
// Already using SSO to authorize, finish successfully
|
||||
// Or login via api key, skip SSO requirement
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user belongs to any organization with an active SSO policy
|
||||
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
if (anySsoPoliciesApplicableToUser)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default - continue validation process
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ResetFailedAuthDetailsAsync(User user)
|
||||
{
|
||||
// Early escape if db hit not necessary
|
||||
if (user == null || user.FailedLoginCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.FailedLoginCount = 0;
|
||||
user.RevisionDate = DateTime.UtcNow;
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
}
|
||||
|
||||
private async Task UpdateFailedAuthDetailsAsync(User user, bool twoFactorInvalid, bool unknownDevice)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var utcNow = DateTime.UtcNow;
|
||||
user.FailedLoginCount = ++user.FailedLoginCount;
|
||||
user.LastFailedLoginDate = user.RevisionDate = utcNow;
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
|
||||
if (ValidateFailedAuthEmailConditions(unknownDevice, user))
|
||||
{
|
||||
if (twoFactorInvalid)
|
||||
{
|
||||
await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// checks to see if a user is trying to log into a new device
|
||||
/// and has reached the maximum number of failed login attempts.
|
||||
/// </summary>
|
||||
/// <param name="unknownDevice">boolean</param>
|
||||
/// <param name="user">current user</param>
|
||||
/// <returns></returns>
|
||||
private bool ValidateFailedAuthEmailConditions(bool unknownDevice, User user)
|
||||
{
|
||||
var failedLoginCeiling = _globalSettings.Captcha.MaximumFailedLoginAttempts;
|
||||
var failedLoginCount = user?.FailedLoginCount ?? 0;
|
||||
return unknownDevice && failedLoginCeiling > 0 && failedLoginCount == failedLoginCeiling;
|
||||
}
|
||||
|
||||
private async Task<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicy(User user)
|
||||
{
|
||||
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not
|
||||
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
||||
.ToList();
|
||||
|
||||
if (!orgs.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user));
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
/// <summary>
|
||||
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
|
||||
/// </summary>
|
||||
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
|
||||
{
|
||||
var ssoConfig = await GetSsoConfigurationDataAsync(subject);
|
||||
return await UserDecryptionOptionsBuilder
|
||||
.ForUser(user)
|
||||
.WithDevice(device)
|
||||
.WithSso(ssoConfig)
|
||||
.BuildAsync();
|
||||
}
|
||||
|
||||
private async Task<SsoConfig?> GetSsoConfigurationDataAsync(ClaimsPrincipal subject)
|
||||
{
|
||||
var organizationClaim = subject?.FindFirstValue("organizationId");
|
||||
|
||||
if (organizationClaim == null || !Guid.TryParse(organizationClaim, out var organizationId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ssoConfig = await SsoConfigRepository.GetByOrganizationIdAsync(organizationId);
|
||||
if (ssoConfig == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ssoConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using HandlebarsDotNet;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
|
||||
ICustomTokenRequestValidator
|
||||
{
|
||||
private readonly UserManager<User> _userManager;
|
||||
|
||||
public CustomTokenRequestValidator(
|
||||
UserManager<User> userManager,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IMailService mailService,
|
||||
ILogger<CustomTokenRequestValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IUserRepository userRepository,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
|
||||
)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
organizationUserRepository,
|
||||
mailService,
|
||||
logger,
|
||||
currentContext,
|
||||
globalSettings,
|
||||
userRepository,
|
||||
policyService,
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder)
|
||||
{
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
if (context.Result.ValidatedRequest.GrantType == "refresh_token")
|
||||
{
|
||||
// Force legacy users to the web for migration
|
||||
if (await _userService.IsLegacyUser(GetSubject(context)?.GetSubjectId()) &&
|
||||
context.Result.ValidatedRequest.ClientId != "web")
|
||||
{
|
||||
await FailAuthForLegacyUserAsync(null, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string[] allowedGrantTypes = ["authorization_code", "client_credentials"];
|
||||
if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType)
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("organization")
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("installation")
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("internal")
|
||||
|| context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets))
|
||||
{
|
||||
if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) &&
|
||||
!string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
context.Result.CustomResponse = new Dictionary<string, object> { { "encrypted_payload", payload } };
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ValidateAsync(context, context.Result.ValidatedRequest,
|
||||
new CustomValidatorRequestContext { KnownDevice = true });
|
||||
}
|
||||
|
||||
protected async override Task<bool> ValidateContextAsync(CustomTokenRequestValidationContext context,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
var email = context.Result.ValidatedRequest.Subject?.GetDisplayName()
|
||||
?? 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;
|
||||
}
|
||||
|
||||
protected override Task SetSuccessResult(CustomTokenRequestValidationContext context, User user,
|
||||
List<Claim> claims, Dictionary<string, object> customResponse)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.CustomResponse = customResponse;
|
||||
if (claims?.Any() ?? false)
|
||||
{
|
||||
context.Result.ValidatedRequest.Client.AlwaysSendClientClaims = true;
|
||||
context.Result.ValidatedRequest.Client.ClientClaimsPrefix = string.Empty;
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
context.Result.ValidatedRequest.ClientClaims.Add(claim);
|
||||
}
|
||||
}
|
||||
if (context.Result.CustomResponse == null || user.MasterPassword != null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// KeyConnector responses below
|
||||
|
||||
// Apikey login
|
||||
if (context.Result.ValidatedRequest.GrantType == "client_credentials")
|
||||
{
|
||||
if (user.UsesKeyConnector)
|
||||
{
|
||||
// KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it
|
||||
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
|
||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Key connector data should have already been set in the decryption options
|
||||
// for backwards compatibility we set them this way too. We can eventually get rid of this
|
||||
// when all clients don't read them from the existing locations.
|
||||
if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) ||
|
||||
userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
if (userDecryptionOptions is { KeyConnectorOption: { } })
|
||||
{
|
||||
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
|
||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ClaimsPrincipal? GetSubject(CustomTokenRequestValidationContext context)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
return context.Result.ValidatedRequest.Subject;
|
||||
}
|
||||
|
||||
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.Error = "invalid_grant";
|
||||
context.Result.ErrorDescription = "Two factor required.";
|
||||
context.Result.IsError = true;
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
|
||||
protected override void SetSsoResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.Error = "invalid_grant";
|
||||
context.Result.ErrorDescription = "Single Sign on required.";
|
||||
context.Result.IsError = true;
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
|
||||
protected override void SetErrorResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.Error = "invalid_grant";
|
||||
context.Result.IsError = true;
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
}
|
||||
115
src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
Normal file
115
src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Reflection;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public interface IDeviceValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Save a device to the database. If the device is already known, it will be returned.
|
||||
/// </summary>
|
||||
/// <param name="user">The user is assumed NOT null, still going to check though</param>
|
||||
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
|
||||
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
|
||||
Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request);
|
||||
/// <summary>
|
||||
/// Check if a device is known to the user.
|
||||
/// </summary>
|
||||
/// <param name="user">current user trying to authenticate</param>
|
||||
/// <param name="request">contains raw information that is parsed about the device</param>
|
||||
/// <returns>true if the device is known, false if it is not</returns>
|
||||
Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request);
|
||||
}
|
||||
|
||||
public class DeviceValidator(
|
||||
IDeviceService deviceService,
|
||||
IDeviceRepository deviceRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IMailService mailService,
|
||||
ICurrentContext currentContext) : IDeviceValidator
|
||||
{
|
||||
private readonly IDeviceService _deviceService = deviceService;
|
||||
private readonly IDeviceRepository _deviceRepository = deviceRepository;
|
||||
private readonly GlobalSettings _globalSettings = globalSettings;
|
||||
private readonly IMailService _mailService = mailService;
|
||||
private readonly ICurrentContext _currentContext = currentContext;
|
||||
|
||||
/// <summary>
|
||||
/// Save a device to the database. If the device is already known, it will be returned.
|
||||
/// </summary>
|
||||
/// <param name="user">The user is assumed NOT null, still going to check though</param>
|
||||
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
|
||||
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
|
||||
public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request)
|
||||
{
|
||||
var device = GetDeviceFromRequest(request);
|
||||
if (device != null && user != null)
|
||||
{
|
||||
var existingDevice = await GetKnownDeviceAsync(user, device);
|
||||
if (existingDevice == null)
|
||||
{
|
||||
device.UserId = user.Id;
|
||||
await _deviceService.SaveAsync(device);
|
||||
|
||||
// This makes sure the user isn't sent a "new device" email on their first login
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - user.CreationDate > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
var deviceType = device.Type.GetType().GetMember(device.Type.ToString())
|
||||
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
|
||||
if (!_globalSettings.DisableEmailNewDevice)
|
||||
{
|
||||
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
|
||||
_currentContext.IpAddress);
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
return existingDevice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
|
||||
(await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default;
|
||||
|
||||
private async Task<Device> GetKnownDeviceAsync(User user, Device device)
|
||||
{
|
||||
if (user == null || device == null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
return await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id);
|
||||
}
|
||||
|
||||
private static Device GetDeviceFromRequest(ValidatedRequest request)
|
||||
{
|
||||
var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString();
|
||||
var requestDeviceType = request.Raw["DeviceType"]?.ToString();
|
||||
var deviceName = request.Raw["DeviceName"]?.ToString();
|
||||
var devicePushToken = request.Raw["DevicePushToken"]?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deviceIdentifier) ||
|
||||
string.IsNullOrWhiteSpace(requestDeviceType) ||
|
||||
string.IsNullOrWhiteSpace(deviceName) ||
|
||||
!Enum.TryParse(requestDeviceType, out DeviceType parsedDeviceType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Device
|
||||
{
|
||||
Identifier = deviceIdentifier,
|
||||
Name = deviceName,
|
||||
Type = parsedDeviceType,
|
||||
PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwnerPasswordValidationContext>,
|
||||
IResourceOwnerPasswordValidator
|
||||
{
|
||||
private UserManager<User> _userManager;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ICaptchaValidationService _captchaValidationService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IDeviceValidator _deviceValidator;
|
||||
public ResourceOwnerPasswordValidator(
|
||||
UserManager<User> userManager,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IMailService mailService,
|
||||
ILogger<ResourceOwnerPasswordValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
ICaptchaValidationService captchaValidationService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
organizationUserRepository,
|
||||
mailService,
|
||||
logger,
|
||||
currentContext,
|
||||
globalSettings,
|
||||
userRepository,
|
||||
policyService,
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
_captchaValidationService = captchaValidationService;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_deviceValidator = deviceValidator;
|
||||
}
|
||||
|
||||
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
if (!AuthEmailHeaderIsValid(context))
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant,
|
||||
"Auth-Email header invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant());
|
||||
var validatorContext = new CustomValidatorRequestContext
|
||||
{
|
||||
User = user,
|
||||
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request),
|
||||
};
|
||||
string bypassToken = null;
|
||||
if (!validatorContext.KnownDevice &&
|
||||
_captchaValidationService.RequireCaptchaValidation(_currentContext, user))
|
||||
{
|
||||
var captchaResponse = context.Request.Raw["captchaResponse"]?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(captchaResponse))
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Captcha required.",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ _captchaValidationService.SiteKeyResponseKeyName, _captchaValidationService.SiteKey },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validatorContext.CaptchaResponse = await _captchaValidationService.ValidateCaptchaResponseAsync(
|
||||
captchaResponse, _currentContext.IpAddress, user);
|
||||
if (!validatorContext.CaptchaResponse.Success)
|
||||
{
|
||||
await BuildErrorResultAsync("Captcha is invalid. Please refresh and try again", false, context, null);
|
||||
return;
|
||||
}
|
||||
bypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user);
|
||||
}
|
||||
|
||||
await ValidateAsync(context, context.Request, validatorContext);
|
||||
if (context.Result.CustomResponse != null && bypassToken != null)
|
||||
{
|
||||
context.Result.CustomResponse["CaptchaBypassToken"] = bypassToken;
|
||||
}
|
||||
}
|
||||
|
||||
protected async override Task<bool> ValidateContextAsync(ResourceOwnerPasswordValidationContext context,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.UserName) || validatorContext.User == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var authRequestId = context.Request.Raw["AuthRequest"]?.ToString()?.ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(authRequestId) && Guid.TryParse(authRequestId, out var authRequestGuid))
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestGuid);
|
||||
if (authRequest != null)
|
||||
{
|
||||
var requestAge = DateTime.UtcNow - authRequest.CreationDate;
|
||||
if (requestAge < TimeSpan.FromHours(1) &&
|
||||
CoreHelpers.FixedTimeEquals(authRequest.AccessCode, context.Password))
|
||||
{
|
||||
authRequest.AuthenticationDate = DateTime.UtcNow;
|
||||
await _authRequestRepository.ReplaceAsync(authRequest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await _userService.CheckPasswordAsync(validatorContext.User, context.Password))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override Task SetSuccessResult(ResourceOwnerPasswordValidationContext context, User user,
|
||||
List<Claim> claims, Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
|
||||
identityProvider: Constants.IdentityProvider,
|
||||
claims: claims.Count > 0 ? claims : null,
|
||||
customResponse: customResponse);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
|
||||
protected override ClaimsPrincipal GetSubject(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
return context.Result.Subject;
|
||||
}
|
||||
|
||||
private bool AuthEmailHeaderIsValid(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
if (!_currentContext.HttpContext.Request.Headers.ContainsKey("Auth-Email"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var authEmailHeader = _currentContext.HttpContext.Request.Headers["Auth-Email"];
|
||||
var authEmailDecoded = CoreHelpers.Base64UrlDecodeString(authEmailHeader);
|
||||
|
||||
if (authEmailDecoded != context.UserName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception e) when (e is System.InvalidOperationException || e is System.FormatException)
|
||||
{
|
||||
// Invalid B64 encoding
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
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.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public interface ITwoFactorAuthenticationValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if the user is required to use two-factor authentication to login. This is based on the user's
|
||||
/// enabled two-factor providers, the user's organizations enabled two-factor providers, and the grant type.
|
||||
/// Client credentials and webauthn grant types do not require two-factor authentication.
|
||||
/// </summary>
|
||||
/// <param name="user">the active user for the request</param>
|
||||
/// <param name="request">the request that contains the grant types</param>
|
||||
/// <returns>boolean</returns>
|
||||
Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request);
|
||||
/// <summary>
|
||||
/// Builds the two-factor authentication result for the user based on the available two-factor providers
|
||||
/// from either their user account or Organization.
|
||||
/// </summary>
|
||||
/// <param name="user">user trying to login</param>
|
||||
/// <param name="organization">organization associated with the user; Can be null</param>
|
||||
/// <returns>Dictionary with the TwoFactorProviderType as the Key and the Provider Metadata as the Value</returns>
|
||||
Task<Dictionary<string, object>> BuildTwoFactorResultAsync(User user, Organization organization);
|
||||
/// <summary>
|
||||
/// Uses the built in userManager methods to verify the two-factor token for the user. If the organization uses
|
||||
/// organization duo, it will use the organization duo token provider to verify the token.
|
||||
/// </summary>
|
||||
/// <param name="user">the active User</param>
|
||||
/// <param name="organization">organization of user; can be null</param>
|
||||
/// <param name="twoFactorProviderType">Two Factor Provider to use to verify the token</param>
|
||||
/// <param name="token">secret passed from the user and consumed by the two-factor provider's verify method</param>
|
||||
/// <returns>boolean</returns>
|
||||
Task<bool> VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token);
|
||||
}
|
||||
|
||||
public class TwoFactorAuthenticationValidator(
|
||||
IUserService userService,
|
||||
UserManager<User> userManager,
|
||||
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
|
||||
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmail2faSessionTokeFactory,
|
||||
ICurrentContext currentContext) : ITwoFactorAuthenticationValidator
|
||||
{
|
||||
private readonly IUserService _userService = userService;
|
||||
private readonly UserManager<User> _userManager = userManager;
|
||||
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider;
|
||||
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService = duoWebV4SDKService;
|
||||
private readonly IFeatureService _featureService = featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService = applicationCacheService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository = organizationRepository;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmail2faSessionTokeFactory = ssoEmail2faSessionTokeFactory;
|
||||
private readonly ICurrentContext _currentContext = currentContext;
|
||||
|
||||
public async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
|
||||
{
|
||||
if (request.GrantType == "client_credentials" || request.GrantType == "webauthn")
|
||||
{
|
||||
/*
|
||||
Do not require MFA for api key logins.
|
||||
We consider Fido2 userVerification a second factor, so we don't require a second factor here.
|
||||
*/
|
||||
return new Tuple<bool, Organization>(false, null);
|
||||
}
|
||||
|
||||
var individualRequired = _userManager.SupportsUserTwoFactor &&
|
||||
await _userManager.GetTwoFactorEnabledAsync(user) &&
|
||||
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
|
||||
|
||||
Organization firstEnabledOrg = null;
|
||||
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)).ToList();
|
||||
if (orgs.Count > 0)
|
||||
{
|
||||
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
var twoFactorOrgs = orgs.Where(o => OrgUsing2fa(orgAbilities, o.Id));
|
||||
if (twoFactorOrgs.Any())
|
||||
{
|
||||
var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
firstEnabledOrg = userOrgs.FirstOrDefault(
|
||||
o => orgs.Any(om => om.Id == o.Id) && o.TwoFactorIsEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
return new Tuple<bool, Organization>(individualRequired || firstEnabledOrg != null, firstEnabledOrg);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> BuildTwoFactorResultAsync(User user, Organization organization)
|
||||
{
|
||||
var enabledProviders = await GetEnabledTwoFactorProvidersAsync(user, organization);
|
||||
if (enabledProviders.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providers = new Dictionary<string, Dictionary<string, object>>();
|
||||
foreach (var provider in enabledProviders)
|
||||
{
|
||||
var twoFactorParams = await BuildTwoFactorParams(organization, user, provider.Key, provider.Value);
|
||||
providers.Add(((byte)provider.Key).ToString(), twoFactorParams);
|
||||
}
|
||||
|
||||
var twoFactorResultDict = new Dictionary<string, object>
|
||||
{
|
||||
{ "TwoFactorProviders", null },
|
||||
{ "TwoFactorProviders2", providers }, // backwards compatibility
|
||||
};
|
||||
|
||||
// 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",
|
||||
_ssoEmail2faSessionTokeFactory.Protect(new SsoEmail2faSessionTokenable(user)));
|
||||
|
||||
twoFactorResultDict.Add("Email", user.Email);
|
||||
}
|
||||
|
||||
if (enabledProviders.Count == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email)
|
||||
{
|
||||
// Send email now if this is their only 2FA method
|
||||
await _userService.SendTwoFactorEmailAsync(user);
|
||||
}
|
||||
|
||||
return twoFactorResultDict;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyTwoFactor(
|
||||
User user,
|
||||
Organization organization,
|
||||
TwoFactorProviderType type,
|
||||
string token)
|
||||
{
|
||||
if (organization != null && type == TwoFactorProviderType.OrganizationDuo)
|
||||
{
|
||||
if (organization.TwoFactorProviderIsEnabled(type))
|
||||
{
|
||||
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
|
||||
{
|
||||
if (!token.Contains(':'))
|
||||
{
|
||||
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
|
||||
}
|
||||
}
|
||||
return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case TwoFactorProviderType.Authenticator:
|
||||
case TwoFactorProviderType.Email:
|
||||
case TwoFactorProviderType.Duo:
|
||||
case TwoFactorProviderType.YubiKey:
|
||||
case TwoFactorProviderType.WebAuthn:
|
||||
case TwoFactorProviderType.Remember:
|
||||
if (type != TwoFactorProviderType.Remember &&
|
||||
!await _userService.TwoFactorProviderIsEnabledAsync(type, user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
|
||||
{
|
||||
if (type == TwoFactorProviderType.Duo)
|
||||
{
|
||||
if (!token.Contains(':'))
|
||||
{
|
||||
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
return await _userManager.VerifyTwoFactorTokenAsync(user,
|
||||
CoreHelpers.CustomProviderName(type), token);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>> GetEnabledTwoFactorProvidersAsync(
|
||||
User user, Organization organization)
|
||||
{
|
||||
var enabledProviders = new List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>();
|
||||
var organizationTwoFactorProviders = organization?.GetTwoFactorProviders();
|
||||
if (organizationTwoFactorProviders != null)
|
||||
{
|
||||
enabledProviders.AddRange(
|
||||
organizationTwoFactorProviders.Where(
|
||||
p => (p.Value?.Enabled ?? false) && organization.Use2fa));
|
||||
}
|
||||
|
||||
var userTwoFactorProviders = user.GetTwoFactorProviders();
|
||||
var userCanAccessPremium = await _userService.CanAccessPremium(user);
|
||||
if (userTwoFactorProviders != null)
|
||||
{
|
||||
enabledProviders.AddRange(
|
||||
userTwoFactorProviders.Where(p =>
|
||||
// Providers that do not require premium
|
||||
(p.Value.Enabled && !TwoFactorProvider.RequiresPremium(p.Key)) ||
|
||||
// Providers that require premium and the User has Premium
|
||||
(p.Value.Enabled && TwoFactorProvider.RequiresPremium(p.Key) && userCanAccessPremium)));
|
||||
}
|
||||
|
||||
return enabledProviders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the parameters for the two-factor authentication
|
||||
/// </summary>
|
||||
/// <param name="organization">We need the organization for Organization Duo Provider type</param>
|
||||
/// <param name="user">The user for which the token is being generated</param>
|
||||
/// <param name="type">Provider Type</param>
|
||||
/// <param name="provider">Raw data that is used to create the response</param>
|
||||
/// <returns>a dictionary with the correct provider configuration or null if the provider is not configured properly</returns>
|
||||
private async Task<Dictionary<string, object>> BuildTwoFactorParams(Organization organization, User user,
|
||||
TwoFactorProviderType type, TwoFactorProvider provider)
|
||||
{
|
||||
// We will always return this dictionary. If none of the criteria is met then it will return null.
|
||||
var twoFactorParams = new Dictionary<string, object>();
|
||||
|
||||
// OrganizationDuo is odd since it doesn't use the UserManager built-in TwoFactor flows
|
||||
/*
|
||||
Note: Duo is in the midst of being updated to use the UserManager built-in TwoFactor class
|
||||
in the future the `AuthUrl` will be the generated "token" - PM-8107
|
||||
*/
|
||||
if (type == TwoFactorProviderType.OrganizationDuo &&
|
||||
await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
|
||||
{
|
||||
twoFactorParams.Add("Host", provider.MetaData["Host"]);
|
||||
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
|
||||
|
||||
return twoFactorParams;
|
||||
}
|
||||
|
||||
// Individual 2FA providers use the UserManager built-in TwoFactor flow so we can generate the token before building the params
|
||||
var token = await _userManager.GenerateTwoFactorTokenAsync(user,
|
||||
CoreHelpers.CustomProviderName(type));
|
||||
switch (type)
|
||||
{
|
||||
/*
|
||||
Note: Duo is in the midst of being updated to use the UserManager built-in TwoFactor class
|
||||
in the future the `AuthUrl` will be the generated "token" - PM-8107
|
||||
*/
|
||||
case TwoFactorProviderType.Duo:
|
||||
twoFactorParams.Add("Host", provider.MetaData["Host"]);
|
||||
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
|
||||
break;
|
||||
case TwoFactorProviderType.WebAuthn:
|
||||
if (token != null)
|
||||
{
|
||||
twoFactorParams = JsonSerializer.Deserialize<Dictionary<string, object>>(token);
|
||||
}
|
||||
break;
|
||||
case TwoFactorProviderType.Email:
|
||||
var twoFactorEmail = (string)provider.MetaData["Email"];
|
||||
var redactedEmail = CoreHelpers.RedactEmailAddress(twoFactorEmail);
|
||||
twoFactorParams.Add("Email", redactedEmail);
|
||||
break;
|
||||
case TwoFactorProviderType.YubiKey:
|
||||
twoFactorParams.Add("Nfc", (bool)provider.MetaData["Nfc"]);
|
||||
break;
|
||||
}
|
||||
|
||||
// return null if the dictionary is empty
|
||||
return twoFactorParams.Count > 0 ? twoFactorParams : null;
|
||||
}
|
||||
|
||||
private bool OrgUsing2fa(IDictionary<Guid, OrganizationAbility> orgAbilities, Guid orgId)
|
||||
{
|
||||
return orgAbilities != null && orgAbilities.ContainsKey(orgId) &&
|
||||
orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator
|
||||
{
|
||||
public const string GrantType = "webauthn";
|
||||
|
||||
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
|
||||
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
|
||||
private readonly IDeviceValidator _deviceValidator;
|
||||
|
||||
public WebAuthnGrantValidator(
|
||||
UserManager<User> userManager,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IDeviceValidator deviceValidator,
|
||||
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IMailService mailService,
|
||||
ILogger<CustomTokenRequestValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserRepository userRepository,
|
||||
IPolicyService policyService,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
|
||||
IFeatureService featureService,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
|
||||
)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
eventService,
|
||||
deviceValidator,
|
||||
twoFactorAuthenticationValidator,
|
||||
organizationUserRepository,
|
||||
mailService,
|
||||
logger,
|
||||
currentContext,
|
||||
globalSettings,
|
||||
userRepository,
|
||||
policyService,
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder)
|
||||
{
|
||||
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
||||
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
|
||||
_deviceValidator = deviceValidator;
|
||||
}
|
||||
|
||||
string IExtensionGrantValidator.GrantType => "webauthn";
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||
{
|
||||
var rawToken = context.Request.Raw.Get("token");
|
||||
var rawDeviceResponse = context.Request.Raw.Get("deviceResponse");
|
||||
if (string.IsNullOrWhiteSpace(rawToken) || string.IsNullOrWhiteSpace(rawDeviceResponse))
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
|
||||
return;
|
||||
}
|
||||
|
||||
var verified = _assertionOptionsDataProtector.TryUnprotect(rawToken, out var token) &&
|
||||
token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication);
|
||||
var deviceResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(rawDeviceResponse);
|
||||
|
||||
if (!verified)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
var (user, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(token.Options, deviceResponse);
|
||||
var validatorContext = new CustomValidatorRequestContext
|
||||
{
|
||||
User = user,
|
||||
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request)
|
||||
};
|
||||
|
||||
UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);
|
||||
|
||||
await ValidateAsync(context, context.Request, validatorContext);
|
||||
}
|
||||
|
||||
protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
if (validatorContext.User == null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user,
|
||||
List<Claim> claims, Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
|
||||
identityProvider: Constants.IdentityProvider,
|
||||
claims: claims.Count > 0 ? claims : null,
|
||||
customResponse: customResponse);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context)
|
||||
{
|
||||
return context.Result.Subject;
|
||||
}
|
||||
|
||||
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetSsoResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetErrorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user