mirror of
https://github.com/bitwarden/server
synced 2026-02-02 01:33:19 +00:00
Merge branch 'main' into ac/pm-28414-remove-feature-flag
This commit is contained in:
@@ -11,3 +11,7 @@ checkmarx:
|
||||
filter: "!test"
|
||||
kics:
|
||||
filter: "!dev,!.devcontainer"
|
||||
sca:
|
||||
filter: "!dev,!.devcontainer"
|
||||
containers:
|
||||
filter: "!dev,!.devcontainer"
|
||||
|
||||
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -11,6 +11,9 @@
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
# Scanning tools
|
||||
.checkmarx/ @bitwarden/team-appsec
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/publish.yml @bitwarden/dept-bre
|
||||
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -263,14 +263,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
|
||||
uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -30,6 +34,8 @@ public class MembersController : Controller
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
|
||||
public MembersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -42,7 +48,9 @@ public class MembersController : Controller
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_groupRepository = groupRepository;
|
||||
@@ -55,6 +63,8 @@ public class MembersController : Controller
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -258,4 +268,59 @@ public class MembersController : Controller
|
||||
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a member's access to an organization.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the member to be revoked.</param>
|
||||
[HttpPost("{id}/revoke")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Revoke(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
_currentContext.OrganizationId!.Value,
|
||||
[id],
|
||||
new SystemUser(EventSystemUser.PublicApi)
|
||||
);
|
||||
|
||||
var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
|
||||
var result = results.Single();
|
||||
|
||||
return result.Result.Match<IActionResult>(
|
||||
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
|
||||
_ => new OkResult()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Restores a previously revoked member of the organization.
|
||||
/// </remarks>
|
||||
/// <param name="id">The identifier of the member to be restored.</param>
|
||||
[HttpPost("{id}/restore")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Restore(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
|
||||
}
|
||||
|
||||
// Exclude any account recovery that do not have a key.
|
||||
existing = existing.Where(o => o.ResetPasswordKey != null).ToList();
|
||||
|
||||
existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
|
||||
|
||||
foreach (var ou in existing)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
@@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, string errorValue)
|
||||
: this(errorKey, new string[] { errorValue })
|
||||
: this(errorKey, [errorValue])
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues)
|
||||
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
|
||||
{ }
|
||||
|
||||
[JsonConstructor]
|
||||
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
|
||||
{
|
||||
Message = message;
|
||||
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
|
||||
/// </summary>
|
||||
/// <example>The request model is invalid.</example>
|
||||
[Required]
|
||||
public string Message { get; set; }
|
||||
public string Message { get; init; }
|
||||
/// <summary>
|
||||
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
|
||||
/// request parameter will include a dictionary key describing that parameter.
|
||||
/// </summary>
|
||||
public Dictionary<string, IEnumerable<string>> Errors { get; set; }
|
||||
public Dictionary<string, IEnumerable<string>>? Errors { get; }
|
||||
}
|
||||
|
||||
@@ -239,12 +239,6 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
@@ -272,12 +266,6 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
|
||||
@@ -102,9 +102,17 @@ public class SendRequestModel
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(4000)]
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of email **hashes** that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
public string EmailHashes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
@@ -253,6 +261,7 @@ public class SendRequestModel
|
||||
// normalize encoding
|
||||
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
|
||||
existingSend.Emails = string.Join(",", emails);
|
||||
existingSend.EmailHashes = EmailHashes;
|
||||
existingSend.Password = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
|
||||
/// <param name="policyDetails">Collection of policy details that apply to this user id</param>
|
||||
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
|
||||
{
|
||||
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
|
||||
public bool CannotHaveEmergencyAccess() => policyDetails.Any();
|
||||
|
||||
public bool CannotJoinProvider() => policyDetails.Any();
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ public static class FeatureFlagKeys
|
||||
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
|
||||
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
|
||||
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
|
||||
public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
|
||||
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
|
||||
|
||||
/* Autofill Team */
|
||||
@@ -223,9 +224,10 @@ public static class FeatureFlagKeys
|
||||
|
||||
/* Platform Team */
|
||||
public const string WebPush = "web-push";
|
||||
public const string IpcChannelFramework = "ipc-channel-framework";
|
||||
public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
|
||||
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
|
||||
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
|
||||
public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
|
||||
|
||||
/* Tools Team */
|
||||
/// <summary>
|
||||
|
||||
@@ -81,6 +81,15 @@ public class Send : ITableObject<Guid>
|
||||
[MaxLength(4000)]
|
||||
public string? Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of email **hashes** for OTP authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field is mutually exclusive with <see cref="Password" />
|
||||
/// </remarks>
|
||||
[MaxLength(4000)]
|
||||
public string? EmailHashes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The send becomes unavailable to API callers when
|
||||
/// <see cref="AccessCount"/> >= <see cref="MaxAccessCount"/>.
|
||||
|
||||
@@ -45,6 +45,6 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
|
||||
/// Create a send claim by requesting a one time password (OTP) confirmation code.
|
||||
/// </summary>
|
||||
/// <param name="Emails">
|
||||
/// The list of email addresses permitted access to the send.
|
||||
/// The list of email address **hashes** permitted access to the send.
|
||||
/// </param>
|
||||
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
|
||||
|
||||
@@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
|
||||
SendAuthenticationMethod method = send switch
|
||||
{
|
||||
null => NEVER_AUTHENTICATE,
|
||||
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
|
||||
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
|
||||
var s when s.Disabled => NEVER_AUTHENTICATE,
|
||||
var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
|
||||
var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
|
||||
var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
|
||||
var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
|
||||
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
|
||||
_ => NOT_AUTHENTICATED
|
||||
};
|
||||
@@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
|
||||
return method;
|
||||
}
|
||||
|
||||
private EmailOtp emailOtp(string emails)
|
||||
private static EmailOtp EmailOtp(string? emailHashes)
|
||||
{
|
||||
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrWhiteSpace(emailHashes))
|
||||
{
|
||||
return new EmailOtp([]);
|
||||
}
|
||||
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return new EmailOtp(list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Icons' " />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using Bit.Core;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
@@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Tools.Helpers;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||
@@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||
/// <inheritdoc cref="ISendRepository" />
|
||||
public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
{
|
||||
public SendRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
private readonly IDataProtector _dataProtector;
|
||||
|
||||
public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider)
|
||||
{ }
|
||||
|
||||
public SendRepository(string connectionString, string readOnlyConnectionString)
|
||||
public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
{
|
||||
_dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
|
||||
}
|
||||
|
||||
public override async Task<Send?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var send = await base.GetByIdAsync(id);
|
||||
UnprotectData(send);
|
||||
return send;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ICollection<Send>> GetManyByUserIdAsync(Guid userId)
|
||||
@@ -33,7 +46,9 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
new { UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
var sends = results.ToList();
|
||||
UnprotectData(sends);
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,15 +62,35 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
new { DeletionDate = deletionDateBefore },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
var sends = results.ToList();
|
||||
UnprotectData(sends);
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<Send> CreateAsync(Send send)
|
||||
{
|
||||
await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send));
|
||||
return send;
|
||||
}
|
||||
|
||||
public override async Task ReplaceAsync(Send send)
|
||||
{
|
||||
await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable<Send> sends)
|
||||
{
|
||||
return async (connection, transaction) =>
|
||||
{
|
||||
// Protect all sends before bulk update
|
||||
var sendsList = sends.ToList();
|
||||
foreach (var send in sendsList)
|
||||
{
|
||||
ProtectData(send);
|
||||
}
|
||||
|
||||
// Create temp table
|
||||
var sqlCreateTemp = @"
|
||||
SELECT TOP 0 *
|
||||
@@ -71,7 +106,7 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "#TempSend";
|
||||
var sendsTable = sends.ToDataTable();
|
||||
var sendsTable = sendsList.ToDataTable();
|
||||
foreach (DataColumn col in sendsTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
@@ -101,6 +136,69 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Unprotect after save
|
||||
foreach (var send in sendsList)
|
||||
{
|
||||
UnprotectData(send);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ProtectDataAndSaveAsync(Send send, Func<Task> saveTask)
|
||||
{
|
||||
if (send == null)
|
||||
{
|
||||
await saveTask();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture original value
|
||||
var originalEmailHashes = send.EmailHashes;
|
||||
|
||||
// Protect value
|
||||
ProtectData(send);
|
||||
|
||||
// Save
|
||||
await saveTask();
|
||||
|
||||
// Restore original value
|
||||
send.EmailHashes = originalEmailHashes;
|
||||
}
|
||||
|
||||
private void ProtectData(Send send)
|
||||
{
|
||||
if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
|
||||
{
|
||||
send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
|
||||
_dataProtector.Protect(send.EmailHashes!));
|
||||
}
|
||||
}
|
||||
|
||||
private void UnprotectData(Send? send)
|
||||
{
|
||||
if (send == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
|
||||
{
|
||||
send.EmailHashes = _dataProtector.Unprotect(
|
||||
send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private void UnprotectData(IEnumerable<Send> sends)
|
||||
{
|
||||
if (sends == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var send in sends)
|
||||
{
|
||||
UnprotectData(send);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ public class DatabaseContext : DbContext
|
||||
var eOrganizationDomain = builder.Entity<OrganizationDomain>();
|
||||
var aWebAuthnCredential = builder.Entity<WebAuthnCredential>();
|
||||
var eOrganizationMemberBaseDetail = builder.Entity<OrganizationMemberBaseDetail>();
|
||||
var eSend = builder.Entity<Send>();
|
||||
|
||||
// Shadow property configurations go here
|
||||
|
||||
@@ -148,6 +149,7 @@ public class DatabaseContext : DbContext
|
||||
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
|
||||
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
|
||||
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
|
||||
eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter);
|
||||
|
||||
if (Database.IsNpgsql())
|
||||
{
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
-- FIXME: remove null default value once this argument has been
|
||||
-- in 2 server releases
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -42,7 +43,8 @@ BEGIN
|
||||
[HideEmail],
|
||||
[CipherId],
|
||||
[Emails],
|
||||
[AuthType]
|
||||
[AuthType],
|
||||
[EmailHashes]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -63,7 +65,8 @@ BEGIN
|
||||
@HideEmail,
|
||||
@CipherId,
|
||||
@Emails,
|
||||
@AuthType
|
||||
@AuthType,
|
||||
@EmailHashes
|
||||
)
|
||||
|
||||
IF @UserId IS NOT NULL
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
@HideEmail BIT,
|
||||
@CipherId UNIQUEIDENTIFIER = NULL,
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -40,7 +41,8 @@ BEGIN
|
||||
[HideEmail] = @HideEmail,
|
||||
[CipherId] = @CipherId,
|
||||
[Emails] = @Emails,
|
||||
[AuthType] = @AuthType
|
||||
[AuthType] = @AuthType,
|
||||
[EmailHashes] = @EmailHashes
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
CREATE TABLE [dbo].[Send] (
|
||||
CREATE TABLE [dbo].[Send]
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Data] VARCHAR(MAX) NOT NULL,
|
||||
[Key] VARCHAR (MAX) NOT NULL,
|
||||
[Password] NVARCHAR (300) NULL,
|
||||
[Emails] NVARCHAR (4000) NULL,
|
||||
[Key] VARCHAR(MAX) NOT NULL,
|
||||
[Password] NVARCHAR(300) NULL,
|
||||
[Emails] NVARCHAR(4000) NULL,
|
||||
[MaxAccessCount] INT NULL,
|
||||
[AccessCount] INT NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
[ExpirationDate] DATETIME2 (7) NULL,
|
||||
[DeletionDate] DATETIME2 (7) NOT NULL,
|
||||
[CreationDate] DATETIME2(7) NOT NULL,
|
||||
[RevisionDate] DATETIME2(7) NOT NULL,
|
||||
[ExpirationDate] DATETIME2(7) NULL,
|
||||
[DeletionDate] DATETIME2(7) NOT NULL,
|
||||
[Disabled] BIT NOT NULL,
|
||||
[HideEmail] BIT NULL,
|
||||
[CipherId] UNIQUEIDENTIFIER NULL,
|
||||
[AuthType] TINYINT NULL,
|
||||
[EmailHashes] NVARCHAR(4000) NULL,
|
||||
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
|
||||
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
|
||||
@@ -26,9 +28,9 @@
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId]
|
||||
ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC);
|
||||
ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate]
|
||||
ON [dbo].[Send]([DeletionDate] ASC);
|
||||
ON [dbo].[Send] ([DeletionDate] ASC);
|
||||
|
||||
|
||||
@@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true },
|
||||
orgUser.GetPermissions());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_Member_Success()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_AlreadyRevoked_ReturnsBadRequest()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
|
||||
Assert.Equal("Already revoked.", error?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_NotFound_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_DifferentOrganization_ReturnsNotFound()
|
||||
{
|
||||
// Create a different organization
|
||||
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
// Create a user in the other organization
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, otherOrganization.Id, OrganizationUserType.User);
|
||||
|
||||
// Re-authenticate with the original organization
|
||||
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
|
||||
|
||||
// Try to revoke the user from the other organization
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Member_Success()
|
||||
{
|
||||
// Invite a user to revoke
|
||||
var email = $"integration-test{Guid.NewGuid()}@example.com";
|
||||
var inviteRequest = new MemberCreateRequestModel
|
||||
{
|
||||
Email = email,
|
||||
Type = OrganizationUserType.User,
|
||||
};
|
||||
|
||||
var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest));
|
||||
Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode);
|
||||
var invitedMember = await inviteResponse.Content.ReadFromJsonAsync<MemberResponseModel>();
|
||||
Assert.NotNull(invitedMember);
|
||||
|
||||
// Revoke the invited user
|
||||
var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
|
||||
|
||||
// Restore the user
|
||||
var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Verify user is restored to Invited state
|
||||
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(invitedMember.Id);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_AlreadyActive_ReturnsBadRequest()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
|
||||
Assert.Equal("Already active.", error?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_NotFound_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_DifferentOrganization_ReturnsNotFound()
|
||||
{
|
||||
// Create a different organization
|
||||
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
// Create a user in the other organization
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, otherOrganization.Id, OrganizationUserType.User);
|
||||
|
||||
// Re-authenticate with the original organization
|
||||
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
|
||||
|
||||
// Try to restore the user from the other organization
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,44 @@ public class OrganizationUserRotationValidatorTests
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData([null])]
|
||||
[BitAutoData("")]
|
||||
public async Task ValidateAsync_OrgUsersWithNullOrEmptyResetPasswordKey_FiltersOutInvalidKeys(
|
||||
string? invalidResetPasswordKey,
|
||||
SutProvider<OrganizationUserRotationValidator> sutProvider, User user,
|
||||
ResetPasswordWithOrgIdRequestModel validResetPasswordKey)
|
||||
{
|
||||
// Arrange
|
||||
var existingUserResetPassword = new List<OrganizationUser>
|
||||
{
|
||||
// Valid org user with reset password key
|
||||
new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = validResetPasswordKey.OrganizationId,
|
||||
ResetPasswordKey = validResetPasswordKey.ResetPasswordKey
|
||||
},
|
||||
// Invalid org user with null or empty reset password key - should be filtered out
|
||||
new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
ResetPasswordKey = invalidResetPasswordKey
|
||||
}
|
||||
};
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(user.Id)
|
||||
.Returns(existingUserResetPassword);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_MissingResetPassword_Throws(
|
||||
|
||||
@@ -981,205 +981,6 @@ public class SendsControllerTests : IDisposable
|
||||
Assert.Equal(expectedUrl, response.Url);
|
||||
}
|
||||
|
||||
#region AccessUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 5,
|
||||
MaxAccessCount = 5 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSendFileDownloadDataUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 10,
|
||||
MaxAccessCount = 10 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ public class SendAuthenticationQueryTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(EmailParsingTestCases))]
|
||||
public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails)
|
||||
[MemberData(nameof(EmailHashesParsingTestCases))]
|
||||
public async Task GetAuthenticationMethod_WithEmailHashes_ParsesEmailHashesCorrectly(string emailHashString, string[] expectedEmailHashes)
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: emailHashString, password: null, AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -56,15 +56,15 @@ public class SendAuthenticationQueryTests
|
||||
|
||||
// Assert
|
||||
var emailOtp = Assert.IsType<EmailOtp>(result);
|
||||
Assert.Equal(expectedEmails, emailOtp.Emails);
|
||||
Assert.Equal(expectedEmailHashes, emailOtp.Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp()
|
||||
public async Task GetAuthenticationMethod_WithBothEmailHashesAndPassword_ReturnsEmailOtp()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: "hashedpassword", AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -79,7 +79,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -106,32 +106,218 @@ public class SendAuthenticationQueryTests
|
||||
public static IEnumerable<object[]> AuthenticationMethodTestCases()
|
||||
{
|
||||
yield return new object[] { null, typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null, AuthType.Email), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None), typeof(NotAuthenticated) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: null, AuthType.Email), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None), typeof(NotAuthenticated) };
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EmailParsingTestCases()
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithDisabledSend_ReturnsNeverAuthenticate()
|
||||
{
|
||||
yield return new object[] { "test@example.com", new[] { "test@example.com" } };
|
||||
yield return new object[] { "test1@example.com,test2@example.com", new[] { "test1@example.com", "test2@example.com" } };
|
||||
yield return new object[] { " test@example.com , other@example.com ", new[] { "test@example.com", "other@example.com" } };
|
||||
yield return new object[] { "test@example.com,,other@example.com", new[] { "test@example.com", "other@example.com" } };
|
||||
yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } };
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = 10,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = true,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<NeverAuthenticate>(result);
|
||||
}
|
||||
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password, AuthType? authType)
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithExpiredSend_ReturnsNeverAuthenticate()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = 10,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1) // Expired yesterday
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<NeverAuthenticate>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithDeletionDatePassed_ReturnsNeverAuthenticate()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = 10,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
|
||||
ExpirationDate = null
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<NeverAuthenticate>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithDeletionDateEqualToNow_ReturnsNeverAuthenticate()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = 10,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = now, // DeletionDate <= DateTime.UtcNow
|
||||
ExpirationDate = null
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<NeverAuthenticate>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithAccessCountEqualToMaxAccessCount_ReturnsNeverAuthenticate()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 5,
|
||||
MaxAccessCount = 5,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<NeverAuthenticate>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithNullMaxAccessCount_DoesNotRestrictAccess()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 1000,
|
||||
MaxAccessCount = null, // No limit
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<EmailOtp>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationMethod_WithNullExpirationDate_DoesNotExpire()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = 10,
|
||||
EmailHashes = "hashedemail",
|
||||
Password = null,
|
||||
AuthType = AuthType.Email,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null // No expiration
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<EmailOtp>(result);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EmailHashesParsingTestCases()
|
||||
{
|
||||
yield return new object[] { "hash1", new[] { "hash1" } };
|
||||
yield return new object[] { "hash1,hash2", new[] { "hash1", "hash2" } };
|
||||
yield return new object[] { " hash1 , hash2 ", new[] { "hash1", "hash2" } };
|
||||
yield return new object[] { "hash1,,hash2", new[] { "hash1", "hash2" } };
|
||||
yield return new object[] { " , hash1, ,hash2, ", new[] { "hash1", "hash2" } };
|
||||
}
|
||||
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emailHashes, string? password, AuthType? authType)
|
||||
{
|
||||
return new Send
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccessCount = accessCount,
|
||||
MaxAccessCount = maxAccessCount,
|
||||
Emails = emails,
|
||||
EmailHashes = emailHashes,
|
||||
Password = password,
|
||||
AuthType = authType
|
||||
AuthType = authType,
|
||||
Disabled = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ public class EnumerationProtectionHelpersTests
|
||||
var hmacKey = RandomNumberGenerator.GetBytes(32);
|
||||
var salt1 = "user1@example.com";
|
||||
var salt2 = "user2@example.com";
|
||||
var range = 100;
|
||||
var range = 10_000;
|
||||
|
||||
// Act
|
||||
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range);
|
||||
@@ -117,7 +117,7 @@ public class EnumerationProtectionHelpersTests
|
||||
var hmacKey1 = RandomNumberGenerator.GetBytes(32);
|
||||
var hmacKey2 = RandomNumberGenerator.GetBytes(32);
|
||||
var salt = "test@example.com";
|
||||
var range = 100;
|
||||
var range = 10_000;
|
||||
|
||||
// Act
|
||||
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range);
|
||||
|
||||
@@ -45,7 +45,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.MangleMap);
|
||||
Assert.Null(result.Result);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
148
util/Migrator/DbScripts/2026-01-17_00_Send_EmailHashes.sql
Normal file
148
util/Migrator/DbScripts/2026-01-17_00_Send_EmailHashes.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- Update Send table to add EmailHashes Column
|
||||
IF COL_LENGTH('[dbo].[Send]', 'EmailHashes') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE [dbo].[Send]
|
||||
ADD [EmailHashes] NVARCHAR(4000) NULL;
|
||||
END
|
||||
GO
|
||||
|
||||
-- Update Send_Create to include EmailHashes column
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Send_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Data VARCHAR(MAX),
|
||||
@Key VARCHAR(MAX),
|
||||
@Password NVARCHAR(300),
|
||||
@MaxAccessCount INT,
|
||||
@AccessCount INT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@DeletionDate DATETIME2(7),
|
||||
@Disabled BIT,
|
||||
@HideEmail BIT,
|
||||
@CipherId UNIQUEIDENTIFIER = NULL,
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[Send]
|
||||
(
|
||||
[Id],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[Type],
|
||||
[Data],
|
||||
[Key],
|
||||
[Password],
|
||||
[MaxAccessCount],
|
||||
[AccessCount],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[ExpirationDate],
|
||||
[DeletionDate],
|
||||
[Disabled],
|
||||
[HideEmail],
|
||||
[CipherId],
|
||||
[Emails],
|
||||
[AuthType],
|
||||
[EmailHashes]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@UserId,
|
||||
@OrganizationId,
|
||||
@Type,
|
||||
@Data,
|
||||
@Key,
|
||||
@Password,
|
||||
@MaxAccessCount,
|
||||
@AccessCount,
|
||||
@CreationDate,
|
||||
@RevisionDate,
|
||||
@ExpirationDate,
|
||||
@DeletionDate,
|
||||
@Disabled,
|
||||
@HideEmail,
|
||||
@CipherId,
|
||||
@Emails,
|
||||
@AuthType,
|
||||
@EmailHashes
|
||||
)
|
||||
|
||||
IF @UserId IS NOT NULL
|
||||
BEGIN
|
||||
IF @Type = 1 --File
|
||||
BEGIN
|
||||
EXEC [dbo].[User_UpdateStorage] @UserId
|
||||
END
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
END
|
||||
-- TODO: OrganizationId bump?
|
||||
END
|
||||
GO
|
||||
|
||||
-- Update Send_Update to include EmailHashes column
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Send_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Data VARCHAR(MAX),
|
||||
@Key VARCHAR(MAX),
|
||||
@Password NVARCHAR(300),
|
||||
@MaxAccessCount INT,
|
||||
@AccessCount INT,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@ExpirationDate DATETIME2(7),
|
||||
@DeletionDate DATETIME2(7),
|
||||
@Disabled BIT,
|
||||
@HideEmail BIT,
|
||||
@CipherId UNIQUEIDENTIFIER = NULL,
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[Send]
|
||||
SET
|
||||
[UserId] = @UserId,
|
||||
[OrganizationId] = @OrganizationId,
|
||||
[Type] = @Type,
|
||||
[Data] = @Data,
|
||||
[Key] = @Key,
|
||||
[Password] = @Password,
|
||||
[MaxAccessCount] = @MaxAccessCount,
|
||||
[AccessCount] = @AccessCount,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[ExpirationDate] = @ExpirationDate,
|
||||
[DeletionDate] = @DeletionDate,
|
||||
[Disabled] = @Disabled,
|
||||
[HideEmail] = @HideEmail,
|
||||
[CipherId] = @CipherId,
|
||||
[Emails] = @Emails,
|
||||
[AuthType] = @AuthType,
|
||||
[EmailHashes] = @EmailHashes
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
|
||||
IF @UserId IS NOT NULL
|
||||
BEGIN
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
END
|
||||
-- TODO: OrganizationId bump?
|
||||
END
|
||||
GO
|
||||
EXECUTE sp_refreshview N'[dbo].[SendView]'
|
||||
GO
|
||||
3506
util/MySqlMigrations/Migrations/20260117234040_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
3506
util/MySqlMigrations/Migrations/20260117234040_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class _20260117_00_Send_EmailHashes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmailHashes",
|
||||
table: "Send",
|
||||
type: "varchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmailHashes",
|
||||
table: "Send");
|
||||
}
|
||||
}
|
||||
@@ -1689,6 +1689,10 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Property<bool>("Disabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("EmailHashes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("varchar(4000)");
|
||||
|
||||
b.Property<string>("Emails")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("varchar(4000)");
|
||||
|
||||
3512
util/PostgresMigrations/Migrations/20260117234031_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
3512
util/PostgresMigrations/Migrations/20260117234031_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.PostgresMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class _20260117_00_Send_EmailHashes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmailHashes",
|
||||
table: "Send",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmailHashes",
|
||||
table: "Send");
|
||||
}
|
||||
}
|
||||
@@ -1694,6 +1694,10 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Property<bool>("Disabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("EmailHashes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("Emails")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.RustSDK;
|
||||
@@ -10,13 +9,6 @@ namespace Bit.Seeder.Factories;
|
||||
public struct UserData
|
||||
{
|
||||
public string Email;
|
||||
public Guid Id;
|
||||
public string? Key;
|
||||
public string? PublicKey;
|
||||
public string? PrivateKey;
|
||||
public string? ApiKey;
|
||||
public KdfType Kdf;
|
||||
public int KdfIterations;
|
||||
}
|
||||
|
||||
public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Entities.User> passwordHasher, MangleId mangleId)
|
||||
@@ -75,30 +67,8 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
|
||||
{
|
||||
var mangleMap = new Dictionary<string, string?>
|
||||
{
|
||||
{ expectedUserData.Email, MangleEmail(expectedUserData.Email) },
|
||||
{ expectedUserData.Id.ToString(), user.Id.ToString() },
|
||||
{ expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
|
||||
{ expectedUserData.KdfIterations.ToString(CultureInfo.InvariantCulture), user.KdfIterations.ToString(CultureInfo.InvariantCulture) }
|
||||
{ expectedUserData.Email, user.Email },
|
||||
};
|
||||
if (expectedUserData.Key != null)
|
||||
{
|
||||
mangleMap[expectedUserData.Key] = user.Key;
|
||||
}
|
||||
|
||||
if (expectedUserData.PublicKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.PublicKey] = user.PublicKey;
|
||||
}
|
||||
|
||||
if (expectedUserData.PrivateKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.PrivateKey] = user.PrivateKey;
|
||||
}
|
||||
|
||||
if (expectedUserData.ApiKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.ApiKey] = user.ApiKey;
|
||||
}
|
||||
|
||||
return mangleMap;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ public interface IScene<TRequest> : IScene where TRequest : class
|
||||
/// and entity tracking information. The explicit interface implementations allow dynamic invocation
|
||||
/// while preserving type safety in the implementation.
|
||||
/// </remarks>
|
||||
public interface IScene<TRequest, TResult> : IScene where TRequest : class where TResult : class
|
||||
public interface IScene<TRequest, TResult> : IScene where TRequest : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds data based on the provided strongly-typed request and returns typed result data.
|
||||
|
||||
@@ -4,10 +4,22 @@ using Bit.Seeder.Factories;
|
||||
|
||||
namespace Bit.Seeder.Scenes;
|
||||
|
||||
public struct SingleUserSceneResult
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string Kdf { get; init; }
|
||||
public int KdfIterations { get; init; }
|
||||
public string Key { get; init; }
|
||||
public string PublicKey { get; init; }
|
||||
public string PrivateKey { get; init; }
|
||||
public string ApiKey { get; init; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single user using the provided account details.
|
||||
/// </summary>
|
||||
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request>
|
||||
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request, SingleUserSceneResult>
|
||||
{
|
||||
public class Request
|
||||
{
|
||||
@@ -17,22 +29,24 @@ public class SingleUserScene(UserSeeder userSeeder, IUserRepository userReposito
|
||||
public bool Premium { get; set; } = false;
|
||||
}
|
||||
|
||||
public async Task<SceneResult> SeedAsync(Request request)
|
||||
public async Task<SceneResult<SingleUserSceneResult>> SeedAsync(Request request)
|
||||
{
|
||||
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
|
||||
|
||||
await userRepository.CreateAsync(user);
|
||||
|
||||
return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
|
||||
return new SceneResult<SingleUserSceneResult>(result: new SingleUserSceneResult
|
||||
{
|
||||
UserId = user.Id,
|
||||
Kdf = user.Kdf.ToString(),
|
||||
KdfIterations = user.KdfIterations,
|
||||
Key = user.Key!,
|
||||
PublicKey = user.PublicKey!,
|
||||
PrivateKey = user.PrivateKey!,
|
||||
ApiKey = user.ApiKey!,
|
||||
}, mangleMap: userSeeder.GetMangleMap(user, new UserData
|
||||
{
|
||||
Email = request.Email,
|
||||
Id = user.Id,
|
||||
Key = user.Key,
|
||||
PublicKey = user.PublicKey,
|
||||
PrivateKey = user.PrivateKey,
|
||||
ApiKey = user.ApiKey,
|
||||
Kdf = user.Kdf,
|
||||
KdfIterations = user.KdfIterations,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
3495
util/SqliteMigrations/Migrations/20260117234036_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
3495
util/SqliteMigrations/Migrations/20260117234036_2026-01-17_00_Send_EmailHashes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.SqliteMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class _20260117_00_Send_EmailHashes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmailHashes",
|
||||
table: "Send",
|
||||
type: "TEXT",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmailHashes",
|
||||
table: "Send");
|
||||
}
|
||||
}
|
||||
@@ -1678,6 +1678,10 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Property<bool>("Disabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EmailHashes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Emails")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
Reference in New Issue
Block a user