mirror of
https://github.com/bitwarden/server
synced 2026-01-04 01:23:25 +00:00
Merge branch 'main' into km/pm-10600
# Conflicts: # src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
This commit is contained in:
@@ -15,6 +15,7 @@ public enum PolicyType : byte
|
||||
DisablePersonalVaultExport = 10,
|
||||
ActivateAutofill = 11,
|
||||
AutomaticAppLogIn = 12,
|
||||
FreeFamiliesSponsorshipPolicy = 13
|
||||
}
|
||||
|
||||
public static class PolicyTypeExtensions
|
||||
@@ -40,6 +41,7 @@ public static class PolicyTypeExtensions
|
||||
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
|
||||
PolicyType.ActivateAutofill => "Active auto-fill",
|
||||
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
||||
|
||||
public class PolicyResponseModel : ResponseModel
|
||||
{
|
||||
public PolicyResponseModel(Policy policy, string obj = "policy")
|
||||
: base(obj)
|
||||
{
|
||||
if (policy == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(policy));
|
||||
}
|
||||
|
||||
Id = policy.Id;
|
||||
OrganizationId = policy.OrganizationId;
|
||||
Type = policy.Type;
|
||||
Enabled = policy.Enabled;
|
||||
if (!string.IsNullOrWhiteSpace(policy.Data))
|
||||
{
|
||||
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
|
||||
}
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public PolicyType Type { get; set; }
|
||||
public Dictionary<string, object> Data { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
|
||||
public class GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
GroupOperationRequirement requirement, OrganizationScope organizationScope)
|
||||
{
|
||||
var authorized = requirement switch
|
||||
{
|
||||
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
|
||||
await CanReadAllAsync(organizationScope),
|
||||
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
|
||||
await CanViewGroupDetailsAsync(organizationScope),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (requirement is not null && authorized)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
|
||||
currentContext.GetOrganization(organizationScope) is not null
|
||||
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||
|
||||
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
|
||||
currentContext.GetOrganization(organizationScope) is
|
||||
{ Type: OrganizationUserType.Owner } or
|
||||
{ Type: OrganizationUserType.Admin } or
|
||||
{
|
||||
Permissions: { ManageGroups: true } or
|
||||
{ ManageUsers: true }
|
||||
} ||
|
||||
await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||
|
||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||
{
|
||||
public GroupOperationRequirement(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupOperations
|
||||
{
|
||||
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
|
||||
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
|
||||
}
|
||||
@@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||
|
||||
public VerifyOrganizationDomainCommand(
|
||||
@@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
IGlobalSettings globalSettings,
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
IOrganizationService organizationService,
|
||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||
{
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
@@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
||||
_globalSettings = globalSettings;
|
||||
_policyService = policyService;
|
||||
_featureService = featureService;
|
||||
_organizationService = organizationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
@@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||
{
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICurrentContext currentContext)
|
||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||
{
|
||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||
.Where(requiredPolicyType =>
|
||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||
.ToList();
|
||||
|
||||
if (missingRequiredPolicyTypes.Count != 0)
|
||||
|
||||
@@ -18,5 +18,6 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
||||
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
public class FreeFamiliesForEnterprisePolicyValidator(
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository)
|
||||
: IPolicyValidator
|
||||
{
|
||||
public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy;
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
|
||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
|
||||
{
|
||||
await NotifiesUserWithApplicablePoliciesAsync(policyUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifiesUserWithApplicablePoliciesAsync(PolicyUpdate policy)
|
||||
{
|
||||
var organizationSponsorships = (await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId))
|
||||
.Where(p => p.SponsoredOrganizationId is not null)
|
||||
.ToList();
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(policy.OrganizationId);
|
||||
var organizationName = organization?.Name;
|
||||
|
||||
foreach (var org in organizationSponsorships)
|
||||
{
|
||||
var offerAcceptanceDate = org.ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
|
||||
await mailService.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(org.FriendlyName, offerAcceptanceDate,
|
||||
org.SponsoredOrganizationId.ToString(), organizationName);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
|
||||
public SingleOrgPolicyValidator(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ICurrentContext currentContext,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
||||
IFeatureService featureService,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
}
|
||||
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
@@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
||||
if (policyUpdate is not { Enabled: true })
|
||||
{
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
||||
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||
|
||||
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
|
||||
{
|
||||
return validateDecryptionErrorMessage;
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||
{
|
||||
return "The Single organization policy is required for organizations that have enabled domain verification.";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
||||
@@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
@@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!consolidatedBillingEnabled)
|
||||
{
|
||||
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
|
||||
}
|
||||
|
||||
var plan = StaticStore.GetPlan(signup.Plan);
|
||||
|
||||
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
||||
@@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
if (provider is { Enabled: true })
|
||||
{
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
|
||||
provider.Status == ProviderStatusType.Billable)
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
||||
{
|
||||
throw new BadRequestException("Organization has verified domains.");
|
||||
throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Utilities.Duo;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DuoWebTokenProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
|
||||
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
|
||||
return signatureRequest;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
|
||||
_globalSettings.Duo.AKey, token);
|
||||
|
||||
return response == user.Email;
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Utilities.Duo;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
|
||||
|
||||
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
|
||||
&& HasProperMetaData(provider);
|
||||
return Task.FromResult(canGenerate);
|
||||
}
|
||||
|
||||
public Task<string> GenerateAsync(Organization organization, User user)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return Task.FromResult<string>(null);
|
||||
}
|
||||
|
||||
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
|
||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
|
||||
return Task.FromResult(signatureRequest);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateAsync(string token, Organization organization, User user)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
|
||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
|
||||
|
||||
return Task.FromResult(response == user.Email);
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
|
||||
/*
|
||||
PM-5156 addresses tech debt
|
||||
Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows.
|
||||
This service is to support SDK v4 flows for Duo. At some time in the future we will need
|
||||
to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4.
|
||||
*/
|
||||
public interface ITemporaryDuoWebV4SDKService
|
||||
{
|
||||
Task<string> GenerateAsync(TwoFactorProvider provider, User user);
|
||||
Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user);
|
||||
}
|
||||
|
||||
public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory;
|
||||
private readonly ILogger<TemporaryDuoWebV4SDKService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK
|
||||
/// </summary>
|
||||
/// <param name="currentContext">used to fetch initiating Client</param>
|
||||
/// <param name="globalSettings">used to fetch vault URL for Redirect URL</param>
|
||||
public TemporaryDuoWebV4SDKService(
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
ILogger<TemporaryDuoWebV4SDKService> logger)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_tokenDataFactory = tokenDataFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL
|
||||
/// </summary>
|
||||
/// <param name="provider">Either Duo or OrganizationDuo</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <returns>AuthUrl for DUO SDK v4</returns>
|
||||
public async Task<string> GenerateAsync(TwoFactorProvider provider, User user)
|
||||
{
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
if (!HasProperMetaData_SDKV2(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var duoClient = await BuildDuoClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
||||
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Duo SDK v4 response
|
||||
/// </summary>
|
||||
/// <param name="token">response form Duo</param>
|
||||
/// <param name="provider">TwoFactorProviderType Duo or OrganizationDuo</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <returns>true or false depending on result of verification</returns>
|
||||
public async Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user)
|
||||
{
|
||||
if (!HasProperMetaData(provider))
|
||||
{
|
||||
if (!HasProperMetaData_SDKV2(provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var duoClient = await BuildDuoClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = token.Split("|");
|
||||
var authCode = parts[0];
|
||||
var state = parts[1];
|
||||
|
||||
_tokenDataFactory.TryUnprotect(state, out var tokenable);
|
||||
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
||||
// their authCode with a victims credentials
|
||||
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
||||
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
||||
return res.AuthResult.Result == "allow";
|
||||
}
|
||||
|
||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") &&
|
||||
provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the metadata for SDK V2 is present.
|
||||
/// Transitional method to support Duo during v4 database rename
|
||||
/// </summary>
|
||||
/// <param name="provider">The TwoFactorProvider object to check.</param>
|
||||
/// <returns>True if the provider has the proper metadata; otherwise, false.</returns>
|
||||
private bool HasProperMetaData_SDKV2(TwoFactorProvider provider)
|
||||
{
|
||||
if (provider?.MetaData != null &&
|
||||
provider.MetaData.TryGetValue("IKey", out var iKey) &&
|
||||
provider.MetaData.TryGetValue("SKey", out var sKey) &&
|
||||
provider.MetaData.ContainsKey("Host"))
|
||||
{
|
||||
provider.MetaData.Add("ClientId", iKey);
|
||||
provider.MetaData.Add("ClientSecret", sKey);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation
|
||||
/// </summary>
|
||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||
/// <returns>Duo.Client object or null</returns>
|
||||
private async Task<Duo.Client> BuildDuoClientAsync(TwoFactorProvider provider)
|
||||
{
|
||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||
// to redirect back to the initiating client
|
||||
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
||||
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
||||
|
||||
var client = new Duo.ClientBuilder(
|
||||
(string)provider.MetaData["ClientId"],
|
||||
(string)provider.MetaData["ClientSecret"],
|
||||
(string)provider.MetaData["Host"],
|
||||
redirectUri).Build();
|
||||
|
||||
if (!await client.DoHealthCheck(true))
|
||||
{
|
||||
_logger.LogError("Unable to connect to Duo. Health check failed.");
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OtpNet;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
@@ -0,0 +1,102 @@
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class DuoUniversalTokenProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
/// <summary>
|
||||
/// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance
|
||||
/// occurring between IUserService, which extends the UserManager<User>, and the usage of the
|
||||
/// UserManager<User> within this class. Trying to resolve the IUserService using the DI pipeline
|
||||
/// will not allow the server to start and it will hang and give no helpful indication as to the problem.
|
||||
/// </summary>
|
||||
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
|
||||
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||
if (provider == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(user);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(user);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the Duo Two Factor Provider for the user if they have access to Duo
|
||||
/// </summary>
|
||||
/// <param name="user">Active User</param>
|
||||
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||
private async Task<TwoFactorProvider> GetDuoTwoFactorProvider(User user, IUserService userService)
|
||||
{
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the User to fetch a valid TwoFactorProvider and use it to create a Duo.Client
|
||||
/// </summary>
|
||||
/// <param name="user">active user</param>
|
||||
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||
private async Task<Duo.Client> GetDuoClientAsync(User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||
if (provider == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return duoClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
/// <summary>
|
||||
/// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will
|
||||
/// have this class injected to utilize these methods
|
||||
/// </summary>
|
||||
public interface IDuoUniversalTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This
|
||||
/// Auth URL also lets the Duo Service know where to redirect the user back to after
|
||||
/// the 2FA process is complete.
|
||||
/// </summary>
|
||||
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||
/// <param name="tokenDataFactory">This service creates the state token for added security</param>
|
||||
/// <param name="user">currently active user</param>
|
||||
/// <returns>a URL in string format</returns>
|
||||
string GenerateAuthUrl(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user);
|
||||
|
||||
/// <summary>
|
||||
/// Makes the request to Duo to validate the authCode and state token
|
||||
/// </summary>
|
||||
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||
/// <param name="tokenDataFactory">Factory for decrypting the state</param>
|
||||
/// <param name="user">self</param>
|
||||
/// <param name="token">token received from the client</param>
|
||||
/// <returns>boolean based on result from Duo</returns>
|
||||
Task<bool> RequestDuoValidationAsync(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user,
|
||||
string token);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration
|
||||
/// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration.
|
||||
/// Throws exception if configuration is invalid.
|
||||
/// </summary>
|
||||
/// <param name="clientSecret">Duo client Secret</param>
|
||||
/// <param name="clientId">Duo client Id</param>
|
||||
/// <param name="host">Duo host</param>
|
||||
/// <returns>Boolean</returns>
|
||||
Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host);
|
||||
|
||||
/// <summary>
|
||||
/// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data.
|
||||
/// it is assumed to be correct. The only way to have the data written to the Database is after verification
|
||||
/// occurs.
|
||||
/// </summary>
|
||||
/// <param name="provider">Host being checked for proper data</param>
|
||||
/// <returns>true if all three are present; false if one is missing or the host is incorrect</returns>
|
||||
bool HasProperDuoMetadata(TwoFactorProvider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation.
|
||||
/// This method is made public so that it is easier to test. If the method was private then there would not be an
|
||||
/// easy way to mock the response. Since this makes a web request it is difficult to mock.
|
||||
/// </summary>
|
||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||
/// <returns>Duo.Client object or null</returns>
|
||||
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
|
||||
}
|
||||
|
||||
public class DuoUniversalTokenService(
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings) : IDuoUniversalTokenService
|
||||
{
|
||||
private readonly ICurrentContext _currentContext = currentContext;
|
||||
private readonly GlobalSettings _globalSettings = globalSettings;
|
||||
|
||||
public string GenerateAuthUrl(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user)
|
||||
{
|
||||
var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
||||
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public async Task<bool> RequestDuoValidationAsync(
|
||||
Duo.Client duoClient,
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
User user,
|
||||
string token)
|
||||
{
|
||||
var parts = token.Split("|");
|
||||
var authCode = parts[0];
|
||||
var state = parts[1];
|
||||
tokenDataFactory.TryUnprotect(state, out var tokenable);
|
||||
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
||||
// their authCode with a victims credentials
|
||||
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
||||
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
||||
return res.AuthResult.Result == "allow";
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host)
|
||||
{
|
||||
// Do some simple checks to ensure data integrity
|
||||
if (!ValidDuoHost(host) ||
|
||||
string.IsNullOrWhiteSpace(clientSecret) ||
|
||||
string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The AuthURI is not important for this health check so we pass in a non-empty string
|
||||
var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build();
|
||||
|
||||
// This could throw an exception, the false flag will allow the exception to bubble up
|
||||
return await client.DoHealthCheck(false);
|
||||
}
|
||||
|
||||
public bool HasProperDuoMetadata(TwoFactorProvider provider)
|
||||
{
|
||||
return provider?.MetaData != null &&
|
||||
provider.MetaData.ContainsKey("ClientId") &&
|
||||
provider.MetaData.ContainsKey("ClientSecret") &&
|
||||
provider.MetaData.ContainsKey("Host") &&
|
||||
ValidDuoHost((string)provider.MetaData["Host"]);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client.
|
||||
/// </summary>
|
||||
/// <param name="host">string representing the Duo Host</param>
|
||||
/// <returns>true if the host is valid false otherwise</returns>
|
||||
public static bool ValidDuoHost(string host)
|
||||
{
|
||||
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
|
||||
{
|
||||
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
|
||||
uri.Host.StartsWith("api-") &&
|
||||
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
|
||||
{
|
||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||
// to redirect back to the initiating client
|
||||
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
||||
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
||||
|
||||
var client = new Duo.ClientBuilder(
|
||||
(string)provider.MetaData["ClientId"],
|
||||
(string)provider.MetaData["ClientSecret"],
|
||||
(string)provider.MetaData["Host"],
|
||||
redirectUri).Build();
|
||||
|
||||
if (!await client.DoHealthCheck(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class EmailTwoFactorTokenProvider : EmailTokenProvider
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public interface IOrganizationTwoFactorTokenProvider
|
||||
{
|
||||
@@ -0,0 +1,81 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tokens;
|
||||
using Duo = DuoUniversal;
|
||||
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public interface IOrganizationDuoUniversalTokenProvider : IOrganizationTwoFactorTokenProvider { }
|
||||
|
||||
public class OrganizationDuoUniversalTokenProvider(
|
||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||
IDuoUniversalTokenService duoUniversalTokenService) : IOrganizationDuoUniversalTokenProvider
|
||||
{
|
||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
|
||||
|
||||
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
|
||||
{
|
||||
var provider = GetDuoTwoFactorProvider(organization);
|
||||
if (provider != null && provider.Enabled)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(Organization organization, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(organization);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(string token, Organization organization, User user)
|
||||
{
|
||||
var duoClient = await GetDuoClientAsync(organization);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
|
||||
}
|
||||
|
||||
private TwoFactorProvider GetDuoTwoFactorProvider(Organization organization)
|
||||
{
|
||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private async Task<Duo.Client> GetDuoClientAsync(Organization organization)
|
||||
{
|
||||
var provider = GetDuoTwoFactorProvider(organization);
|
||||
if (provider == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
|
||||
if (duoClient == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return duoClient;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class TwoFactorRememberTokenProvider : DataProtectorTokenProvider<User>
|
||||
{
|
||||
@@ -10,7 +10,7 @@ using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using YubicoDotNetClient;
|
||||
|
||||
namespace Bit.Core.Auth.Identity;
|
||||
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||
|
||||
public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
{
|
||||
@@ -24,7 +24,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||
{
|
||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||
if (!(await userService.CanAccessPremium(user)))
|
||||
if (!await userService.CanAccessPremium(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
/*
|
||||
Original source modified from https://github.com/duosecurity/duo_api_csharp
|
||||
|
||||
=============================================================================
|
||||
=============================================================================
|
||||
|
||||
Copyright (c) 2018 Duo Security
|
||||
All rights reserved
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using Bit.Core.Models.Api.Response.Duo;
|
||||
|
||||
namespace Bit.Core.Auth.Utilities;
|
||||
|
||||
public class DuoApi
|
||||
{
|
||||
private const string UrlScheme = "https";
|
||||
private const string UserAgent = "Bitwarden_DuoAPICSharp/1.0 (.NET Core)";
|
||||
|
||||
private readonly string _host;
|
||||
private readonly string _ikey;
|
||||
private readonly string _skey;
|
||||
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
public DuoApi(string ikey, string skey, string host)
|
||||
{
|
||||
_ikey = ikey;
|
||||
_skey = skey;
|
||||
_host = host;
|
||||
|
||||
if (!ValidHost(host))
|
||||
{
|
||||
throw new DuoException("Invalid Duo host configured.", new ArgumentException(nameof(host)));
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ValidHost(string host)
|
||||
{
|
||||
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
|
||||
{
|
||||
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
|
||||
uri.Host.StartsWith("api-") &&
|
||||
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string CanonicalizeParams(Dictionary<string, string> parameters)
|
||||
{
|
||||
var ret = new List<string>();
|
||||
foreach (var pair in parameters)
|
||||
{
|
||||
var p = string.Format("{0}={1}", HttpUtility.UrlEncode(pair.Key), HttpUtility.UrlEncode(pair.Value));
|
||||
// Signatures require upper-case hex digits.
|
||||
p = Regex.Replace(p, "(%[0-9A-Fa-f][0-9A-Fa-f])", c => c.Value.ToUpperInvariant());
|
||||
// Escape only the expected characters.
|
||||
p = Regex.Replace(p, "([!'()*])", c => "%" + Convert.ToByte(c.Value[0]).ToString("X"));
|
||||
p = p.Replace("%7E", "~");
|
||||
// UrlEncode converts space (" ") to "+". The
|
||||
// signature algorithm requires "%20" instead. Actual
|
||||
// + has already been replaced with %2B.
|
||||
p = p.Replace("+", "%20");
|
||||
ret.Add(p);
|
||||
}
|
||||
|
||||
ret.Sort(StringComparer.Ordinal);
|
||||
return string.Join("&", ret.ToArray());
|
||||
}
|
||||
|
||||
protected string CanonicalizeRequest(string method, string path, string canonParams, string date)
|
||||
{
|
||||
string[] lines = {
|
||||
date,
|
||||
method.ToUpperInvariant(),
|
||||
_host.ToLower(),
|
||||
path,
|
||||
canonParams,
|
||||
};
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public string Sign(string method, string path, string canonParams, string date)
|
||||
{
|
||||
var canon = CanonicalizeRequest(method, path, canonParams, date);
|
||||
var sig = HmacSign(canon);
|
||||
var auth = string.Concat(_ikey, ':', sig);
|
||||
return string.Concat("Basic ", Encode64(auth));
|
||||
}
|
||||
|
||||
/// <param name="timeout">The request timeout, in milliseconds.
|
||||
/// Specify 0 to use the system-default timeout. Use caution if
|
||||
/// you choose to specify a custom timeout - some API
|
||||
/// calls (particularly in the Auth APIs) will not
|
||||
/// return a response until an out-of-band authentication process
|
||||
/// has completed. In some cases, this may take as much as a
|
||||
/// small number of minutes.</param>
|
||||
private async Task<(string result, HttpStatusCode statusCode)> ApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
|
||||
{
|
||||
if (parameters == null)
|
||||
{
|
||||
parameters = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var canonParams = CanonicalizeParams(parameters);
|
||||
var query = string.Empty;
|
||||
if (!method.Equals("POST") && !method.Equals("PUT"))
|
||||
{
|
||||
if (parameters.Count > 0)
|
||||
{
|
||||
query = "?" + canonParams;
|
||||
}
|
||||
}
|
||||
var url = $"{UrlScheme}://{_host}{path}{query}";
|
||||
|
||||
var dateString = RFC822UtcNow();
|
||||
var auth = Sign(method, path, canonParams, dateString);
|
||||
|
||||
var request = new HttpRequestMessage
|
||||
{
|
||||
Method = new HttpMethod(method),
|
||||
RequestUri = new Uri(url),
|
||||
};
|
||||
request.Headers.Add("Authorization", auth);
|
||||
request.Headers.Add("X-Duo-Date", dateString);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
|
||||
if (timeout > 0)
|
||||
{
|
||||
_httpClient.Timeout = TimeSpan.FromMilliseconds(timeout);
|
||||
}
|
||||
|
||||
if (method.Equals("POST") || method.Equals("PUT"))
|
||||
{
|
||||
request.Content = new StringContent(canonParams, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = response.StatusCode;
|
||||
return (result, statusCode);
|
||||
}
|
||||
|
||||
public async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters = null)
|
||||
{
|
||||
return await JSONApiCall(method, path, parameters, 0);
|
||||
}
|
||||
|
||||
/// <param name="timeout">The request timeout, in milliseconds.
|
||||
/// Specify 0 to use the system-default timeout. Use caution if
|
||||
/// you choose to specify a custom timeout - some API
|
||||
/// calls (particularly in the Auth APIs) will not
|
||||
/// return a response until an out-of-band authentication process
|
||||
/// has completed. In some cases, this may take as much as a
|
||||
/// small number of minutes.</param>
|
||||
private async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
|
||||
{
|
||||
var (res, statusCode) = await ApiCall(method, path, parameters, timeout);
|
||||
try
|
||||
{
|
||||
var obj = JsonSerializer.Deserialize<DuoResponseModel>(res);
|
||||
if (obj.Stat == "OK")
|
||||
{
|
||||
return obj.Response;
|
||||
}
|
||||
|
||||
throw new ApiException(obj.Code ?? 0, (int)statusCode, obj.Message, obj.MessageDetail);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new BadResponseException((int)statusCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
private int? ToNullableInt(string s)
|
||||
{
|
||||
int i;
|
||||
if (int.TryParse(s, out i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string HmacSign(string data)
|
||||
{
|
||||
var keyBytes = Encoding.ASCII.GetBytes(_skey);
|
||||
var dataBytes = Encoding.ASCII.GetBytes(data);
|
||||
|
||||
using (var hmac = new HMACSHA1(keyBytes))
|
||||
{
|
||||
var hash = hmac.ComputeHash(dataBytes);
|
||||
var hex = BitConverter.ToString(hash);
|
||||
return hex.Replace("-", string.Empty).ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Encode64(string plaintext)
|
||||
{
|
||||
var plaintextBytes = Encoding.ASCII.GetBytes(plaintext);
|
||||
return Convert.ToBase64String(plaintextBytes);
|
||||
}
|
||||
|
||||
private static string RFC822UtcNow()
|
||||
{
|
||||
// Can't use the "zzzz" format because it adds a ":"
|
||||
// between the offset's hours and minutes.
|
||||
var dateString = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
var offset = 0;
|
||||
var zone = "+" + offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0');
|
||||
dateString += " " + zone.PadRight(5, '0');
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
public class DuoException : Exception
|
||||
{
|
||||
public int HttpStatus { get; private set; }
|
||||
|
||||
public DuoException(string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{ }
|
||||
|
||||
public DuoException(int httpStatus, string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
HttpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiException : DuoException
|
||||
{
|
||||
public int Code { get; private set; }
|
||||
public string ApiMessage { get; private set; }
|
||||
public string ApiMessageDetail { get; private set; }
|
||||
|
||||
public ApiException(int code, int httpStatus, string apiMessage, string apiMessageDetail)
|
||||
: base(httpStatus, FormatMessage(code, apiMessage, apiMessageDetail), null)
|
||||
{
|
||||
Code = code;
|
||||
ApiMessage = apiMessage;
|
||||
ApiMessageDetail = apiMessageDetail;
|
||||
}
|
||||
|
||||
private static string FormatMessage(int code, string apiMessage, string apiMessageDetail)
|
||||
{
|
||||
return string.Format("Duo API Error {0}: '{1}' ('{2}')", code, apiMessage, apiMessageDetail);
|
||||
}
|
||||
}
|
||||
|
||||
public class BadResponseException : DuoException
|
||||
{
|
||||
public BadResponseException(int httpStatus, Exception inner)
|
||||
: base(httpStatus, FormatMessage(httpStatus, inner), inner)
|
||||
{ }
|
||||
|
||||
private static string FormatMessage(int httpStatus, Exception inner)
|
||||
{
|
||||
var innerMessage = "(null)";
|
||||
if (inner != null)
|
||||
{
|
||||
innerMessage = string.Format("'{0}'", inner.Message);
|
||||
}
|
||||
return string.Format("Got error {0} with HTTP Status {1}", innerMessage, httpStatus);
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
Original source modified from https://github.com/duosecurity/duo_dotnet
|
||||
|
||||
=============================================================================
|
||||
=============================================================================
|
||||
|
||||
ref: https://github.com/duosecurity/duo_dotnet/blob/master/LICENSE
|
||||
|
||||
Copyright (c) 2011, Duo Security, Inc.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. The name of the author may not be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Bit.Core.Auth.Utilities.Duo;
|
||||
|
||||
public static class DuoWeb
|
||||
{
|
||||
private const string DuoProfix = "TX";
|
||||
private const string AppPrefix = "APP";
|
||||
private const string AuthPrefix = "AUTH";
|
||||
private const int DuoExpire = 300;
|
||||
private const int AppExpire = 3600;
|
||||
private const int IKeyLength = 20;
|
||||
private const int SKeyLength = 40;
|
||||
private const int AKeyLength = 40;
|
||||
|
||||
public static string ErrorUser = "ERR|The username passed to sign_request() is invalid.";
|
||||
public static string ErrorIKey = "ERR|The Duo integration key passed to sign_request() is invalid.";
|
||||
public static string ErrorSKey = "ERR|The Duo secret key passed to sign_request() is invalid.";
|
||||
public static string ErrorAKey = "ERR|The application secret key passed to sign_request() must be at least " +
|
||||
"40 characters.";
|
||||
public static string ErrorUnknown = "ERR|An unknown error has occurred.";
|
||||
|
||||
// throw on invalid bytes
|
||||
private static Encoding _encoding = new UTF8Encoding(false, true);
|
||||
private static DateTime _epoc = new DateTime(1970, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a signed request for Duo authentication.
|
||||
/// The returned value should be passed into the Duo.init() call
|
||||
/// in the rendered web page used for Duo authentication.
|
||||
/// </summary>
|
||||
/// <param name="ikey">Duo integration key</param>
|
||||
/// <param name="skey">Duo secret key</param>
|
||||
/// <param name="akey">Application secret key</param>
|
||||
/// <param name="username">Primary-authenticated username</param>
|
||||
/// <param name="currentTime">(optional) The current UTC time</param>
|
||||
/// <returns>signed request</returns>
|
||||
public static string SignRequest(string ikey, string skey, string akey, string username,
|
||||
DateTime? currentTime = null)
|
||||
{
|
||||
string duoSig;
|
||||
string appSig;
|
||||
|
||||
var currentTimeValue = currentTime ?? DateTime.UtcNow;
|
||||
|
||||
if (username == string.Empty)
|
||||
{
|
||||
return ErrorUser;
|
||||
}
|
||||
if (username.Contains("|"))
|
||||
{
|
||||
return ErrorUser;
|
||||
}
|
||||
if (ikey.Length != IKeyLength)
|
||||
{
|
||||
return ErrorIKey;
|
||||
}
|
||||
if (skey.Length != SKeyLength)
|
||||
{
|
||||
return ErrorSKey;
|
||||
}
|
||||
if (akey.Length < AKeyLength)
|
||||
{
|
||||
return ErrorAKey;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
duoSig = SignVals(skey, username, ikey, DuoProfix, DuoExpire, currentTimeValue);
|
||||
appSig = SignVals(akey, username, ikey, AppPrefix, AppExpire, currentTimeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ErrorUnknown;
|
||||
}
|
||||
|
||||
return $"{duoSig}:{appSig}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate the signed response returned from Duo.
|
||||
/// Returns the username of the authenticated user, or null.
|
||||
/// </summary>
|
||||
/// <param name="ikey">Duo integration key</param>
|
||||
/// <param name="skey">Duo secret key</param>
|
||||
/// <param name="akey">Application secret key</param>
|
||||
/// <param name="sigResponse">The signed response POST'ed to the server</param>
|
||||
/// <param name="currentTime">(optional) The current UTC time</param>
|
||||
/// <returns>authenticated username, or null</returns>
|
||||
public static string VerifyResponse(string ikey, string skey, string akey, string sigResponse,
|
||||
DateTime? currentTime = null)
|
||||
{
|
||||
string authUser = null;
|
||||
string appUser = null;
|
||||
var currentTimeValue = currentTime ?? DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var sigs = sigResponse.Split(':');
|
||||
var authSig = sigs[0];
|
||||
var appSig = sigs[1];
|
||||
|
||||
authUser = ParseVals(skey, authSig, AuthPrefix, ikey, currentTimeValue);
|
||||
appUser = ParseVals(akey, appSig, AppPrefix, ikey, currentTimeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authUser != appUser)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return authUser;
|
||||
}
|
||||
|
||||
private static string SignVals(string key, string username, string ikey, string prefix, long expire,
|
||||
DateTime currentTime)
|
||||
{
|
||||
var ts = (long)(currentTime - _epoc).TotalSeconds;
|
||||
expire = ts + expire;
|
||||
var val = $"{username}|{ikey}|{expire.ToString()}";
|
||||
var cookie = $"{prefix}|{Encode64(val)}";
|
||||
var sig = Sign(key, cookie);
|
||||
return $"{cookie}|{sig}";
|
||||
}
|
||||
|
||||
private static string ParseVals(string key, string val, string prefix, string ikey, DateTime currentTime)
|
||||
{
|
||||
var ts = (long)(currentTime - _epoc).TotalSeconds;
|
||||
|
||||
var parts = val.Split('|');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var uPrefix = parts[0];
|
||||
var uB64 = parts[1];
|
||||
var uSig = parts[2];
|
||||
|
||||
var sig = Sign(key, $"{uPrefix}|{uB64}");
|
||||
if (Sign(key, sig) != Sign(key, uSig))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uPrefix != prefix)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cookie = Decode64(uB64);
|
||||
var cookieParts = cookie.Split('|');
|
||||
if (cookieParts.Length != 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = cookieParts[0];
|
||||
var uIKey = cookieParts[1];
|
||||
var expire = cookieParts[2];
|
||||
|
||||
if (uIKey != ikey)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expireTs = Convert.ToInt32(expire);
|
||||
if (ts >= expireTs)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
private static string Sign(string skey, string data)
|
||||
{
|
||||
var keyBytes = Encoding.ASCII.GetBytes(skey);
|
||||
var dataBytes = Encoding.ASCII.GetBytes(data);
|
||||
|
||||
using (var hmac = new HMACSHA1(keyBytes))
|
||||
{
|
||||
var hash = hmac.ComputeHash(dataBytes);
|
||||
var hex = BitConverter.ToString(hash);
|
||||
return hex.Replace("-", "").ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Encode64(string plaintext)
|
||||
{
|
||||
var plaintextBytes = _encoding.GetBytes(plaintext);
|
||||
return Convert.ToBase64String(plaintextBytes);
|
||||
}
|
||||
|
||||
private static string Decode64(string encoded)
|
||||
{
|
||||
var plaintextBytes = Convert.FromBase64String(encoded);
|
||||
return _encoding.GetString(plaintextBytes);
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,29 @@ namespace Bit.Core.Billing.Extensions;
|
||||
public static class BillingExtensions
|
||||
{
|
||||
public static bool IsBillable(this Provider provider) =>
|
||||
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||
provider is
|
||||
{
|
||||
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||
Status: ProviderStatusType.Billable
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
public static bool IsValidClient(this Organization organization)
|
||||
=> organization is
|
||||
{
|
||||
Seats: not null,
|
||||
Status: OrganizationStatusType.Managed,
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
public static bool IsStripeEnabled(this ISubscriber subscriber)
|
||||
=> !string.IsNullOrEmpty(subscriber.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId);
|
||||
=> subscriber is
|
||||
{
|
||||
GatewayCustomerId: not null and not "",
|
||||
GatewaySubscriptionId: not null and not ""
|
||||
};
|
||||
|
||||
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
|
||||
setupIntent is
|
||||
@@ -43,5 +50,5 @@ public static class BillingExtensions
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public record OrganizationMetadata(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid);
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
@@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IProviderBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
|
||||
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
|
||||
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
|
||||
/// <see cref="PlanType"/>.
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
|
||||
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
|
||||
/// <param name="seats">The number of seats to assign to the client organization.</param>
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
||||
@@ -44,18 +35,6 @@ public interface IProviderBillingService
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
|
||||
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
|
||||
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
|
||||
Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
||||
@@ -69,6 +48,22 @@ public interface IProviderBillingService
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
|
||||
/// Seat adjustments that result in purchases include:
|
||||
/// <list type="bullet">
|
||||
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to check seat adjustments for.</param>
|
||||
/// <param name="planType">The plan type to check seat adjustments for.</param>
|
||||
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
|
||||
Task<bool> SeatAdjustmentResultsInPurchase(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||
/// </summary>
|
||||
@@ -90,12 +85,5 @@ public interface IProviderBillingService
|
||||
Task<Subscription> SetupSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the assigned provider plan for the provider.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to change the provider plan.</param>
|
||||
/// <returns></returns>
|
||||
Task ChangePlan(ChangeProviderPlanCommand command);
|
||||
|
||||
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
|
||||
}
|
||||
|
||||
@@ -62,18 +62,25 @@ public class OrganizationBillingService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false,
|
||||
false, false);
|
||||
}
|
||||
|
||||
var customer = await subscriberService.GetCustomer(organization,
|
||||
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
|
||||
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
|
||||
var hasSubscription = true;
|
||||
|
||||
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
|
||||
isSubscriptionUnpaid);
|
||||
isSubscriptionUnpaid, hasSubscription);
|
||||
}
|
||||
|
||||
public async Task UpdatePaymentMethod(
|
||||
|
||||
@@ -523,8 +523,9 @@ public class SubscriberService(
|
||||
|
||||
var metadata = customer.Metadata;
|
||||
|
||||
if (metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value))
|
||||
{
|
||||
metadata[BraintreeCustomerIdOldKey] = value;
|
||||
metadata[BraintreeCustomerIdKey] = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Bit.Core.Billing;
|
||||
public static class Utilities
|
||||
{
|
||||
public const string BraintreeCustomerIdKey = "btCustomerId";
|
||||
public const string BraintreeCustomerIdOldKey = "btCustomerId_old";
|
||||
|
||||
public static async Task<SubscriptionSuspension> GetSubscriptionSuspensionAsync(
|
||||
IStripeAdapter stripeAdapter,
|
||||
|
||||
@@ -22,6 +22,7 @@ public static class Constants
|
||||
public const int OrganizationSelfHostSubscriptionGracePeriodDays = 60;
|
||||
|
||||
public const string Fido2KeyCipherMinimumVersion = "2023.10.0";
|
||||
public const string SSHKeyCipherMinimumVersion = "2024.12.0";
|
||||
|
||||
/// <summary>
|
||||
/// Used by IdentityServer to identify our own provider.
|
||||
@@ -100,18 +101,15 @@ public static class AuthenticationSchemes
|
||||
|
||||
public static class FeatureFlagKeys
|
||||
{
|
||||
public const string DisplayEuEnvironment = "display-eu-environment";
|
||||
public const string BrowserFilelessImport = "browser-fileless-import";
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
|
||||
public const string ItemShare = "item-share";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||
public const string EmailVerification = "email-verification";
|
||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
||||
public const string ExtensionRefresh = "extension-refresh";
|
||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
@@ -123,8 +121,9 @@ public static class FeatureFlagKeys
|
||||
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
|
||||
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
|
||||
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||
public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub";
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
|
||||
@@ -141,6 +140,7 @@ public static class FeatureFlagKeys
|
||||
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
||||
public const string TrialPayment = "PM-8163-trial-payment";
|
||||
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
||||
public const string SecureOrgGroupDetails = "pm-3479-secure-org-group-details";
|
||||
public const string AccessIntelligence = "pm-13227-access-intelligence";
|
||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
|
||||
@@ -148,6 +148,13 @@ public static class FeatureFlagKeys
|
||||
public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split";
|
||||
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
||||
public const string NewDeviceVerification = "new-device-verification";
|
||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||
public const string IntegrationPage = "pm-14505-admin-console-integration-page";
|
||||
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
|
||||
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
|
||||
public const string SecurityTasks = "security-tasks";
|
||||
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
|
||||
public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Context;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
@@ -39,6 +40,7 @@ public class CurrentContext : ICurrentContext
|
||||
public virtual int? BotScore { get; set; }
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual Version ClientVersion { get; set; }
|
||||
public virtual bool ClientVersionIsPrerelease { get; set; }
|
||||
public virtual IdentityClientType IdentityClientType { get; set; }
|
||||
public virtual Guid? ServiceAccountOrganizationId { get; set; }
|
||||
|
||||
@@ -97,6 +99,11 @@ public class CurrentContext : ICurrentContext
|
||||
{
|
||||
ClientVersion = cVersion;
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("Is-Prerelease", out var clientVersionIsPrerelease))
|
||||
{
|
||||
ClientVersionIsPrerelease = clientVersionIsPrerelease == "1";
|
||||
}
|
||||
}
|
||||
|
||||
public async virtual Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings)
|
||||
@@ -362,9 +369,9 @@ public class CurrentContext : ICurrentContext
|
||||
|
||||
public async Task<bool> ViewSubscription(Guid orgId)
|
||||
{
|
||||
var orgManagedByMspProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType == ProviderType.Msp);
|
||||
var isManagedByBillableProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType.SupportsConsolidatedBilling());
|
||||
|
||||
return orgManagedByMspProvider
|
||||
return isManagedByBillableProvider
|
||||
? await ProviderUserForOrgAsync(orgId)
|
||||
: await OrganizationOwner(orgId);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,13 @@ public interface ICurrentContext
|
||||
int? BotScore { get; set; }
|
||||
string ClientId { get; set; }
|
||||
Version ClientVersion { get; set; }
|
||||
bool ClientVersionIsPrerelease { get; set; }
|
||||
|
||||
Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings);
|
||||
Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings);
|
||||
|
||||
Task SetContextAsync(ClaimsPrincipal user);
|
||||
|
||||
|
||||
Task<bool> OrganizationUser(Guid orgId);
|
||||
Task<bool> OrganizationAdmin(Guid orgId);
|
||||
Task<bool> OrganizationOwner(Guid orgId);
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.30" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.37" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.47" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; line-height: 25px; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; box-sizing: border-box; line-height: 25px; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
{{SponsoringOrgName}} has removed the Free Bitwarden Families plan sponsorship.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; line-height: 25px; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<strong>Here’s what that means:</strong></br>
|
||||
Your Free Bitwarden Families sponsorship will charge your stored payment method on {{OfferAcceptanceDate}}. To avoid any disruption in your service, please ensure your payment method on the <a target="_blank" clicktracking=off href="{{SubscriptionUrl}}" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #175DDC; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; text-decoration: underline;">Subscription page</a> is up to date.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; line-height: 25px; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
Contact your organization administrators for more information.
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{#>BasicTextLayout}}
|
||||
{{SponsoringOrgName}} has removed the Free Bitwarden Families plan sponsorship.
|
||||
Here’s what that means:
|
||||
Your Free Bitwarden Families sponsorship will charge your stored payment method on {{OfferAcceptanceDate}}. To avoid any disruption in your service, please ensure your payment method on the Subscription page is up to date. Or click the following link: {{{SubscriptionUrl}}}
|
||||
Contact your organization administrators for more information.
|
||||
{{/BasicTextLayout}}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||
|
||||
public record PreValidateSponsorshipResponseModel(
|
||||
bool IsTokenValid,
|
||||
bool IsFreeFamilyPolicyEnabled)
|
||||
{
|
||||
public static PreValidateSponsorshipResponseModel From(bool validToken, bool policyStatus)
|
||||
=> new(validToken, policyStatus);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Models.Mail.FamiliesForEnterprise;
|
||||
|
||||
public class FamiliesForEnterpriseRemoveOfferViewModel : BaseMailModel
|
||||
{
|
||||
public string SponsoringOrgName { get; set; }
|
||||
public string SponsoredOrganizationId { get; set; }
|
||||
public string OfferAcceptanceDate { get; set; }
|
||||
public string SubscriptionUrl =>
|
||||
$"{WebVaultUrl}/organizations/{SponsoredOrganizationId}/billing/subscription";
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.NotificationHub;
|
||||
|
||||
@@ -61,25 +60,10 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
|
||||
switch (type)
|
||||
{
|
||||
case DeviceType.Android:
|
||||
await using (var serviceScope = _serviceProvider.CreateAsyncScope())
|
||||
{
|
||||
var featureService = serviceScope.ServiceProvider.GetRequiredService<IFeatureService>();
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AnhFcmv1Migration))
|
||||
{
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
}
|
||||
else
|
||||
{
|
||||
payloadTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.Fcm;
|
||||
}
|
||||
}
|
||||
|
||||
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
|
||||
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
break;
|
||||
case DeviceType.iOS:
|
||||
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
||||
|
||||
@@ -89,5 +89,7 @@ public interface IMailService
|
||||
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
|
||||
Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token);
|
||||
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
|
||||
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
|
||||
string organizationName);
|
||||
}
|
||||
|
||||
|
||||
@@ -1095,6 +1095,22 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
|
||||
string organizationName)
|
||||
{
|
||||
var message = CreateDefaultMessage("Removal of Free Bitwarden Families plan", email);
|
||||
var model = new FamiliesForEnterpriseRemoveOfferViewModel
|
||||
{
|
||||
SponsoredOrganizationId = organizationId,
|
||||
SponsoringOrgName = CoreHelpers.SanitizeForEmail(organizationName),
|
||||
OfferAcceptanceDate = offerAcceptanceDate,
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash
|
||||
};
|
||||
await AddMessageContentAsync(message, "FamiliesForEnterprise.FamiliesForEnterpriseRemovedFromFamilyUser", model);
|
||||
message.Category = "FamiliesForEnterpriseRemovedFromFamilyUser";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
private static string GetUserIdentifier(string email, string userName)
|
||||
{
|
||||
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
|
||||
|
||||
@@ -20,6 +20,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
||||
private const string _contextKindServiceAccount = "service-account";
|
||||
|
||||
private const string _contextAttributeClientVersion = "client-version";
|
||||
private const string _contextAttributeClientVersionIsPrerelease = "client-version-is-prerelease";
|
||||
private const string _contextAttributeDeviceType = "device-type";
|
||||
private const string _contextAttributeClientType = "client-type";
|
||||
private const string _contextAttributeOrganizations = "organizations";
|
||||
@@ -145,6 +146,7 @@ public class LaunchDarklyFeatureService : IFeatureService
|
||||
if (_currentContext.ClientVersion != null)
|
||||
{
|
||||
builder.Set(_contextAttributeClientVersion, _currentContext.ClientVersion.ToString());
|
||||
builder.Set(_contextAttributeClientVersionIsPrerelease, _currentContext.ClientVersionIsPrerelease);
|
||||
}
|
||||
|
||||
if (_currentContext.DeviceType.HasValue)
|
||||
|
||||
@@ -1360,9 +1360,9 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
if (braintreeCustomer?.Id != stripeCustomerMetadata["btCustomerId"])
|
||||
{
|
||||
var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow);
|
||||
stripeCustomerMetadata.Add($"btCustomerId_{nowSec}", stripeCustomerMetadata["btCustomerId"]);
|
||||
stripeCustomerMetadata["btCustomerId_old"] = stripeCustomerMetadata["btCustomerId"];
|
||||
}
|
||||
|
||||
stripeCustomerMetadata["btCustomerId"] = braintreeCustomer?.Id;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(braintreeCustomer?.Id))
|
||||
|
||||
@@ -296,5 +296,12 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
public Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent) => throw new NotImplementedException();
|
||||
|
||||
public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate,
|
||||
string organizationId,
|
||||
string organizationName)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Tools.Entities;
|
||||
|
||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string? Uri { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
43
src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
Normal file
43
src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace Bit.Core.Tools.Models.Data;
|
||||
|
||||
public class MemberAccessDetails
|
||||
{
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The CipherIds associated with the group/collection access
|
||||
/// </summary>
|
||||
public IEnumerable<string> CollectionCipherIds { get; set; }
|
||||
}
|
||||
|
||||
public class MemberAccessCipherDetails
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public bool AccountRecoveryEnabled { get; set; }
|
||||
public int GroupsCount { get; set; }
|
||||
public int CollectionsCount { get; set; }
|
||||
public int TotalItemCount { get; set; }
|
||||
public Guid? UserGuid { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The details for the member's collection access depending
|
||||
/// on the collections and groups they are assigned to
|
||||
/// </summary>
|
||||
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A distinct list of the cipher ids associated with
|
||||
/// the organization member
|
||||
/// </summary>
|
||||
public IEnumerable<string> CipherIds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand
|
||||
{
|
||||
private IOrganizationRepository _organizationRepo;
|
||||
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
|
||||
|
||||
public AddPasswordHealthReportApplicationCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepository)
|
||||
{
|
||||
_organizationRepo = organizationRepository;
|
||||
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepository;
|
||||
}
|
||||
|
||||
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request)
|
||||
{
|
||||
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
|
||||
if (!IsValid)
|
||||
{
|
||||
throw new BadRequestException(errorMessage);
|
||||
}
|
||||
|
||||
var passwordHealthReportApplication = new PasswordHealthReportApplication
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
Uri = request.Url,
|
||||
};
|
||||
|
||||
passwordHealthReportApplication.SetNewId();
|
||||
|
||||
var data = await _passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
|
||||
return data;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests)
|
||||
{
|
||||
var requestsList = requests.ToList();
|
||||
|
||||
// create tasks to validate each request
|
||||
var tasks = requestsList.Select(async request =>
|
||||
{
|
||||
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
|
||||
if (!IsValid)
|
||||
{
|
||||
throw new BadRequestException(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// run validations and allow exceptions to bubble
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// create PasswordHealthReportApplication entities
|
||||
var passwordHealthReportApplications = requestsList.Select(request =>
|
||||
{
|
||||
var pwdHealthReportApplication = new PasswordHealthReportApplication
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
Uri = request.Url,
|
||||
};
|
||||
pwdHealthReportApplication.SetNewId();
|
||||
return pwdHealthReportApplication;
|
||||
});
|
||||
|
||||
// create and return the entities
|
||||
var response = new List<PasswordHealthReportApplication>();
|
||||
foreach (var record in passwordHealthReportApplications)
|
||||
{
|
||||
var data = await _passwordHealthReportApplicationRepo.CreateAsync(record);
|
||||
response.Add(data);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<Tuple<AddPasswordHealthReportApplicationRequest, bool, string>> ValidateRequestAsync(
|
||||
AddPasswordHealthReportApplicationRequest request)
|
||||
{
|
||||
// verify that the organization exists
|
||||
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "Invalid Organization");
|
||||
}
|
||||
|
||||
// ensure that we have a URL
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
{
|
||||
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "URL is required");
|
||||
}
|
||||
|
||||
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, true, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery
|
||||
{
|
||||
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
|
||||
|
||||
public GetPasswordHealthReportApplicationQuery(
|
||||
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo)
|
||||
{
|
||||
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepo;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId)
|
||||
{
|
||||
if (organizationId == Guid.Empty)
|
||||
{
|
||||
throw new BadRequestException("OrganizationId is required.");
|
||||
}
|
||||
|
||||
return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IAddPasswordHealthReportApplicationCommand
|
||||
{
|
||||
Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request);
|
||||
Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IGetPasswordHealthReportApplicationQuery
|
||||
{
|
||||
Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId);
|
||||
}
|
||||
208
src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
Normal file
208
src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Queries;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
||||
{
|
||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public MemberAccessCipherDetailsQuery(
|
||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||
IGroupRepository groupRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
||||
)
|
||||
{
|
||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||
_groupRepository = groupRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||
{
|
||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
||||
new OrganizationUserUserDetailsQueryRequest
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
IncludeCollections = true,
|
||||
IncludeGroups = true
|
||||
});
|
||||
|
||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
|
||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
|
||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
|
||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||
|
||||
var memberAccessCipherDetails = GenerateAccessData(
|
||||
orgGroups,
|
||||
orgCollectionsWithAccess,
|
||||
orgItems,
|
||||
organizationUsersTwoFactorEnabled,
|
||||
orgAbility
|
||||
);
|
||||
|
||||
return memberAccessCipherDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a report for all members of an organization. Containing summary information
|
||||
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
|
||||
/// Child collection includes detailed information on the user and group collections along
|
||||
/// with their permissions.
|
||||
/// </summary>
|
||||
/// <param name="orgGroups">Organization groups collection</param>
|
||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
||||
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
|
||||
private IEnumerable<MemberAccessCipherDetails> GenerateAccessData(
|
||||
ICollection<Group> orgGroups,
|
||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
||||
OrganizationAbility orgAbility)
|
||||
{
|
||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
||||
// Create a dictionary to lookup the group names later.
|
||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
||||
// Get collections grouped and into a dictionary for counts
|
||||
var collectionItems = orgItems
|
||||
.SelectMany(x => x.CollectionIds,
|
||||
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
|
||||
.GroupBy(y => y.CollectionId,
|
||||
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
|
||||
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()));
|
||||
|
||||
// Loop through the org users and populate report and access data
|
||||
var memberAccessCipherDetails = new List<MemberAccessCipherDetails>();
|
||||
foreach (var user in orgUsers)
|
||||
{
|
||||
var groupAccessDetails = new List<MemberAccessDetails>();
|
||||
var userCollectionAccessDetails = new List<MemberAccessDetails>();
|
||||
foreach (var tCollect in orgCollectionsWithAccess)
|
||||
{
|
||||
var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items);
|
||||
var collectionCiphers = hasItems ? items.Select(x => x) : null;
|
||||
|
||||
var itemCounts = hasItems ? collectionCiphers.Count() : 0;
|
||||
if (tCollect.Item2.Groups.Count() > 0)
|
||||
{
|
||||
|
||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
||||
new MemberAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
GroupId = x.Id,
|
||||
GroupName = groupNameDictionary[x.Id],
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
CollectionCipherIds = items
|
||||
});
|
||||
|
||||
groupAccessDetails.AddRange(groupDetails);
|
||||
}
|
||||
|
||||
// All collections assigned to users and their permissions
|
||||
if (tCollect.Item2.Users.Count() > 0)
|
||||
{
|
||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
||||
new MemberAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
CollectionCipherIds = items
|
||||
});
|
||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
||||
}
|
||||
}
|
||||
|
||||
var report = new MemberAccessCipherDetails
|
||||
{
|
||||
UserName = user.Name,
|
||||
Email = user.Email,
|
||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||
UserGuid = user.Id,
|
||||
UsesKeyConnector = user.UsesKeyConnector
|
||||
};
|
||||
|
||||
var userAccessDetails = new List<MemberAccessDetails>();
|
||||
if (user.Groups.Any())
|
||||
{
|
||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
||||
userAccessDetails.AddRange(userGroups);
|
||||
}
|
||||
|
||||
// There can be edge cases where groups don't have a collection
|
||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
||||
if (groupsWithoutCollections.Count() > 0)
|
||||
{
|
||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
|
||||
{
|
||||
GroupId = x,
|
||||
GroupName = groupNameDictionary[x],
|
||||
ItemCount = 0
|
||||
});
|
||||
userAccessDetails.AddRange(emptyGroups);
|
||||
}
|
||||
|
||||
if (user.Collections.Any())
|
||||
{
|
||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
||||
userAccessDetails.AddRange(userCollections);
|
||||
}
|
||||
report.AccessDetails = userAccessDetails;
|
||||
|
||||
var userCiphers =
|
||||
report.AccessDetails
|
||||
.Where(x => x.ItemCount > 0)
|
||||
.SelectMany(y => y.CollectionCipherIds)
|
||||
.Distinct();
|
||||
report.CipherIds = userCiphers;
|
||||
report.TotalItemCount = userCiphers.Count();
|
||||
|
||||
// Distinct items only
|
||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
||||
report.CollectionsCount = distinctItems.Count();
|
||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
||||
memberAccessCipherDetails.Add(report);
|
||||
}
|
||||
return memberAccessCipherDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
|
||||
public interface IMemberAccessCipherDetailsQuery
|
||||
{
|
||||
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
public static class ReportingServiceCollectionExtensions
|
||||
{
|
||||
public static void AddReportingServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
|
||||
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
|
||||
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Tools.ReportFeatures.Requests;
|
||||
|
||||
public class MemberAccessCipherDetailsRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Entities;
|
||||
|
||||
namespace Bit.Core.Tools.Repositories;
|
||||
|
||||
public interface IPasswordHealthReportApplicationRepository : IRepository<PasswordHealthReportApplication, Guid>
|
||||
{
|
||||
Task<ICollection<PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Tools.Requests;
|
||||
|
||||
public class AddPasswordHealthReportApplicationRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
20
src/Core/Vault/Entities/SecurityTask.cs
Normal file
20
src/Core/Vault/Entities/SecurityTask.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Vault.Entities;
|
||||
|
||||
public class SecurityTask : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid? CipherId { get; set; }
|
||||
public Enums.SecurityTaskType Type { get; set; }
|
||||
public Enums.SecurityTaskStatus Status { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ public enum CipherType : byte
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SSHKey = 5,
|
||||
}
|
||||
|
||||
18
src/Core/Vault/Enums/SecurityTaskStatus.cs
Normal file
18
src/Core/Vault/Enums/SecurityTaskStatus.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Vault.Enums;
|
||||
|
||||
public enum SecurityTaskStatus : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Default status for newly created tasks that have not been completed.
|
||||
/// </summary>
|
||||
[Display(Name = "Pending")]
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Status when a task is considered complete and has no remaining actions
|
||||
/// </summary>
|
||||
[Display(Name = "Completed")]
|
||||
Completed = 1,
|
||||
}
|
||||
12
src/Core/Vault/Enums/SecurityTaskType.cs
Normal file
12
src/Core/Vault/Enums/SecurityTaskType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Vault.Enums;
|
||||
|
||||
public enum SecurityTaskType : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Task to update a cipher's password that was found to be at-risk by an administrator
|
||||
/// </summary>
|
||||
[Display(Name = "Update at-risk credential")]
|
||||
UpdateAtRiskCredential = 0
|
||||
}
|
||||
10
src/Core/Vault/Models/Data/CipherSSHKeyData.cs
Normal file
10
src/Core/Vault/Models/Data/CipherSSHKeyData.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Vault.Models.Data;
|
||||
|
||||
public class CipherSSHKeyData : CipherData
|
||||
{
|
||||
public CipherSSHKeyData() { }
|
||||
|
||||
public string PrivateKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
public string KeyFingerprint { get; set; }
|
||||
}
|
||||
9
src/Core/Vault/Repositories/ISecurityTaskRepository.cs
Normal file
9
src/Core/Vault/Repositories/ISecurityTaskRepository.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Core.Vault.Repositories;
|
||||
|
||||
public interface ISecurityTaskRepository : IRepository<SecurityTask, Guid>
|
||||
{
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user