Skip to content

Commit

Permalink
Merge branch 'main' into auth/pm-12995/user-cache-buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
ike-kottlowski committed Dec 17, 2024
2 parents 01c8be1 + 2e8f2df commit dfbb182
Show file tree
Hide file tree
Showing 27 changed files with 1,048 additions and 15 deletions.
1 change: 1 addition & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"BitPay.Light",
"Braintree",
"coverlet.collector",
"CsvHelper",
"FluentAssertions",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
Expand Down
8 changes: 8 additions & 0 deletions src/Api/Auth/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,14 @@ public async Task VerifyOTP([FromBody] VerifyOTPRequestModel model)
}
}

[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
[AllowAnonymous]
[HttpPost("resend-new-device-otp")]
public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificatioRequestModel request)
{
await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret);
}

private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
{
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;

namespace Bit.Api.Auth.Models.Request.Accounts;

public class UnauthenticatedSecretVerificatioRequestModel : SecretVerificationRequestModel
{
[Required]
[StrictEmailAddress]
[StringLength(256)]
public string Email { get; set; }
}
44 changes: 44 additions & 0 deletions src/Api/Billing/Public/Controllers/OrganizationController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using Bit.Api.Billing.Public.Models;
using Bit.Api.Models.Public.Response;
using Bit.Core.Context;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
Expand Down Expand Up @@ -35,6 +36,49 @@ public OrganizationController(
_logger = logger;
}

/// <summary>
/// Retrieves the subscription details for the current organization.
/// </summary>
/// <returns>
/// Returns an object containing the subscription details if successful.
/// </returns>
[HttpGet("subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
[ProducesResponseType(typeof(OrganizationSubscriptionDetailsResponseModel), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.NotFound)]
public async Task<IActionResult> GetSubscriptionAsync()
{
try
{
var organizationId = _currentContext.OrganizationId.Value;
var organization = await _organizationRepository.GetByIdAsync(organizationId);

var subscriptionDetails = new OrganizationSubscriptionDetailsResponseModel
{
PasswordManager = new PasswordManagerSubscriptionDetails
{
Seats = organization.Seats,
MaxAutoScaleSeats = organization.MaxAutoscaleSeats,
Storage = organization.MaxStorageGb
},
SecretsManager = new SecretsManagerSubscriptionDetails
{
Seats = organization.SmSeats,
MaxAutoScaleSeats = organization.MaxAutoscaleSmSeats,
ServiceAccounts = organization.SmServiceAccounts,
MaxAutoScaleServiceAccounts = organization.MaxAutoscaleSmServiceAccounts
}
};

return Ok(subscriptionDetails);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error while retrieving the subscription details");
return StatusCode(500, new { Message = "An error occurred while retrieving the subscription details." });
}
}

/// <summary>
/// Update the organization's current subscription for Password Manager and/or Secrets Manager.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Billing.Public.Models;

public class OrganizationSubscriptionDetailsResponseModel : IValidatableObject
{
public PasswordManagerSubscriptionDetails PasswordManager { get; set; }
public SecretsManagerSubscriptionDetails SecretsManager { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (PasswordManager == null && SecretsManager == null)
{
yield return new ValidationResult("At least one of PasswordManager or SecretsManager must be provided.");
}

yield return ValidationResult.Success;
}
}
public class PasswordManagerSubscriptionDetails
{
public int? Seats { get; set; }
public int? MaxAutoScaleSeats { get; set; }
public short? Storage { get; set; }
}

public class SecretsManagerSubscriptionDetails
{
public int? Seats { get; set; }
public int? MaxAutoScaleSeats { get; set; }
public int? ServiceAccounts { get; set; }
public int? MaxAutoScaleServiceAccounts { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#nullable enable
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.KeyManagement.Controllers;

[Route("accounts/key-management")]
[Authorize("Application")]
public class AccountsKeyManagementController : Controller
{
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
private readonly IFeatureService _featureService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
private readonly IUserService _userService;

public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand)
{
_userService = userService;
_featureService = featureService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
_organizationUserRepository = organizationUserRepository;
_emergencyAccessRepository = emergencyAccessRepository;
}

[HttpPost("regenerate-keys")]
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
{
throw new NotFoundException();
}

var user = await _userService.GetUserByPrincipalAsync(User) ?? throw new UnauthorizedAccessException();
var usersOrganizationAccounts = await _organizationUserRepository.GetManyByUserAsync(user.Id);
var designatedEmergencyAccess = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(user.Id);
await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
usersOrganizationAccounts, designatedEmergencyAccess);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#nullable enable
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;

namespace Bit.Api.KeyManagement.Models.Requests;

public class KeyRegenerationRequestModel
{
public required string UserPublicKey { get; set; }

[EncryptedString]
public required string UserKeyEncryptedUserPrivateKey { get; set; }

public UserAsymmetricKeys ToUserAsymmetricKeys(Guid userId)
{
return new UserAsymmetricKeys
{
UserId = userId,
PublicKey = UserPublicKey,
UserKeyEncryptedPrivateKey = UserKeyEncryptedUserPrivateKey,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,22 @@ private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser
return;
}

var allRevocableUserOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(
currentActiveRevocableOrganizationUsers.Select(ou => ou.UserId!.Value));
var usersToRevoke = currentActiveRevocableOrganizationUsers.Where(ou =>
allRevocableUserOrgs.Any(uo => uo.UserId == ou.UserId &&
uo.OrganizationId != organizationId &&
uo.Status != OrganizationUserStatusType.Invited)).ToList();

var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync(
new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy));
new RevokeOrganizationUsersRequest(organizationId, usersToRevoke, performedBy));

if (commandResult.HasErrors)
{
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
}

await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
await Task.WhenAll(usersToRevoke.Select(x =>
_mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email)));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,17 +519,29 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey,
string privateKey)
{
if (license.LicenseType != LicenseType.Organization)
{
throw new BadRequestException("Premium licenses cannot be applied to an organization. " +
"Upload this license from your personal account settings page.");
}

var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception);

if (!canUse)
{
throw new BadRequestException(exception);
}

if (license.PlanType != PlanType.Custom &&
StaticStore.Plans.FirstOrDefault(p => p.Type == license.PlanType && !p.Disabled) == null)
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == license.PlanType);
if (plan is null)
{
throw new BadRequestException("Plan not found.");
throw new BadRequestException($"Server must be updated to support {license.Plan}.");
}

if (license.PlanType != PlanType.Custom && plan.Disabled)
{
throw new BadRequestException($"Plan {plan.Name} is disabled.");
}

var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync();
Expand Down
3 changes: 3 additions & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ public static class FeatureFlagKeys
public const string PM12443RemovePagingLogic = "pm-12443-remove-paging-logic";
public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor";
public const string PromoteProviderServiceUserTool = "pm-15128-promote-provider-service-user-tool";
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
public const string AuthenticatorSynciOS = "enable-authenticator-sync-ios";
public const string AuthenticatorSyncAndroid = "enable-authenticator-sync-android";

public static List<string> GetAllKeys()
{
Expand Down
4 changes: 2 additions & 2 deletions src/Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@

<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.57" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.7" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.64" />
<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" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#nullable enable
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;

namespace Bit.Core.KeyManagement.Commands.Interfaces;

public interface IRegenerateUserAsymmetricKeysCommand
{
Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys,
ICollection<OrganizationUser> usersOrganizationAccounts,
ICollection<EmergencyAccessDetails> designatedEmergencyAccess);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#nullable enable
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;

namespace Bit.Core.KeyManagement.Commands;

public class RegenerateUserAsymmetricKeysCommand : IRegenerateUserAsymmetricKeysCommand
{
private readonly ICurrentContext _currentContext;
private readonly ILogger<RegenerateUserAsymmetricKeysCommand> _logger;
private readonly IUserAsymmetricKeysRepository _userAsymmetricKeysRepository;
private readonly IPushNotificationService _pushService;

public RegenerateUserAsymmetricKeysCommand(
ICurrentContext currentContext,
IUserAsymmetricKeysRepository userAsymmetricKeysRepository,
IPushNotificationService pushService,
ILogger<RegenerateUserAsymmetricKeysCommand> logger)
{
_currentContext = currentContext;
_logger = logger;
_userAsymmetricKeysRepository = userAsymmetricKeysRepository;
_pushService = pushService;
}

public async Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys,
ICollection<OrganizationUser> usersOrganizationAccounts,
ICollection<EmergencyAccessDetails> designatedEmergencyAccess)
{
var userId = _currentContext.UserId;
if (!userId.HasValue ||
userAsymmetricKeys.UserId != userId.Value ||
usersOrganizationAccounts.Any(ou => ou.UserId != userId) ||
designatedEmergencyAccess.Any(dea => dea.GranteeId != userId))
{
throw new NotFoundException();
}

var inOrganizations = usersOrganizationAccounts.Any(ou =>
ou.Status is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked);
var hasDesignatedEmergencyAccess = designatedEmergencyAccess.Any(x =>
x.Status is EmergencyAccessStatusType.Confirmed or EmergencyAccessStatusType.RecoveryApproved
or EmergencyAccessStatusType.RecoveryInitiated);

_logger.LogInformation(
"User asymmetric keys regeneration requested. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}",
userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType);

// For now, don't regenerate asymmetric keys for user's with organization membership and designated emergency access.
if (inOrganizations || hasDesignatedEmergencyAccess)
{
throw new BadRequestException("Key regeneration not supported for this user.");
}

await _userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(userAsymmetricKeys);
_logger.LogInformation(
"User's asymmetric keys regenerated. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}",
userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType);

await _pushService.PushSyncSettingsAsync(userId.Value);
}
}
18 changes: 18 additions & 0 deletions src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.KeyManagement;

public static class KeyManagementServiceCollectionExtensions
{
public static void AddKeyManagementServices(this IServiceCollection services)
{
services.AddKeyManagementCommands();
}

private static void AddKeyManagementCommands(this IServiceCollection services)
{
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
}
}
2 changes: 1 addition & 1 deletion src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
Task SendOTPAsync(User user);
Task<bool> VerifyOTPAsync(User user, string token);
Task<bool> VerifySecretAsync(User user, string secret, bool isSettingMFA = false);

Task ResendNewDeviceVerificationEmail(string email, string secret);
/// <summary>
/// We use this method to check if the user has an active new device verification bypass
/// </summary>
Expand Down
Loading

0 comments on commit dfbb182

Please sign in to comment.