mirror of
https://github.com/bitwarden/server.git
synced 2024-11-22 12:15:36 +01:00
Allow for bulk processing new login device requests (#4064)
* Define a model for updating many auth requests In order to facilitate a command method that can update many auth requests at one time a new model must be defined that accepts valid input for the command's needs. To achieve this a new file has been created at `Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdateCommandModel.cs` that contains a class of the same name. It's properties match those that need to come from any calling API request models to fulfill the request. * Declare a new command interface method Calling API functions of the `UpdateOrganizationAuthRequestCommand` need a function that can accept many auth request response objects and process them as approved or denied. To achieve this a new function has been added to `IUpdateOrganizationAuthRequestCommand` called `UpdateManyAsync()` that accepts an `IEnumberable<OrganizationAuthRequest>` and returns a `Task`. Implementations of this interface method will be used to bulk process auth requests as approved or denied. * Stub out method implementation for unit testing To facilitate a bulk device login request approval workflow in the admin console `UpdateOrganizationAuthRequestCommand` needs to be updated to include an `UpdateMany()` method. It should accept a list of `OrganizationAuthRequestUpdateCommandModel` objects, perform some simple data validation checks, and then pass those along to `AuthRequestRepository` for updating in the database. This commit stubs out this method for the purpose of writing unit tests. At this stage the method throws a `NotImplementedException()`. It will be expand after writing assertions. * Inject `IAuthRequestRepository` into `UpdateOrganizationAuthCommand` The updates to `UpdateOrganizationAuthRequestCommand` require a new direct dependency on `IAuthRequestRepository`. This commit simply registers this dependency in the `UpdateOrganizationAuthRequest` constructor for use in unit tests and the `UpdateManyAsync()` implementation. * Write tests * Rename `UpdateManyAsync()` to `UpdateAsync` * Drop the `CommandModel` suffix * Invert business logic update filters * Rework everything to be more model-centric * Bulk send push notifications * Write tests that validate the command as a whole * Fix a test that I broke by mistake * Swap to using await instead of chained methods for processing * Seperate a function arguement into a variable declaration * Ungeneric-ify the processor * Adjust ternary formatting * Adjust naming of methods regarding logging organization events * Throw an exception if Process is called with no auth request loaded * Rename `_updates` -> `_update` * Rename email methods * Stop returning `this` * Allow callbacks to be null * Make some assertions about the state of a processed auth request * Be more terse about arguements in happy path test * Remove unneeded null check * Expose an endpoint for bulk processing of organization auth requests (#4077) --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
parent
0d2e953459
commit
98a191a5e8
@ -1,12 +1,14 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request;
|
using Bit.Api.AdminConsole.Models.Request;
|
||||||
using Bit.Api.AdminConsole.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
||||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -73,7 +75,15 @@ public class OrganizationAuthRequestsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateAdminRequest(Guid orgId)
|
[RequireFeature(FeatureFlagKeys.BulkDeviceApproval)]
|
||||||
|
[HttpPost("")]
|
||||||
|
public async Task UpdateManyAuthRequests(Guid orgId, [FromBody] IEnumerable<OrganizationAuthRequestUpdateManyRequestModel> model)
|
||||||
|
{
|
||||||
|
await ValidateAdminRequest(orgId);
|
||||||
|
await _updateOrganizationAuthRequestCommand.UpdateAsync(orgId, model.Select(x => x.ToOrganizationAuthRequestUpdate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ValidateAdminRequest(Guid orgId)
|
||||||
{
|
{
|
||||||
if (!await _currentContext.ManageResetPassword(orgId))
|
if (!await _currentContext.ManageResetPassword(orgId))
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Request;
|
||||||
|
|
||||||
|
public class OrganizationAuthRequestUpdateManyRequestModel
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
[EncryptedString]
|
||||||
|
public string Key { get; set; }
|
||||||
|
|
||||||
|
public bool Approved { get; set; }
|
||||||
|
|
||||||
|
public OrganizationAuthRequestUpdate ToOrganizationAuthRequestUpdate()
|
||||||
|
{
|
||||||
|
return new OrganizationAuthRequestUpdate
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Key = Key,
|
||||||
|
Approved = Approved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
namespace Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
||||||
|
|
||||||
public interface IUpdateOrganizationAuthRequestCommand
|
public interface IUpdateOrganizationAuthRequestCommand
|
||||||
{
|
{
|
||||||
Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey);
|
Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey);
|
||||||
|
Task UpdateAsync(Guid organizationId, IEnumerable<OrganizationAuthRequestUpdate> authRequestUpdates);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class ApprovedAuthRequestIsMissingKeyException : AuthRequestUpdateProcessingException
|
||||||
|
{
|
||||||
|
public ApprovedAuthRequestIsMissingKeyException(Guid id)
|
||||||
|
: base($"An auth request with id {id} was approved, but no key was provided. This auth request can not be approved.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class AuthRequestUpdateCouldNotBeProcessedException : AuthRequestUpdateProcessingException
|
||||||
|
{
|
||||||
|
public AuthRequestUpdateCouldNotBeProcessedException()
|
||||||
|
: base($"An auth request could not be processed.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthRequestUpdateCouldNotBeProcessedException(Guid id)
|
||||||
|
: base($"An auth request with id {id} could not be processed.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class AuthRequestUpdateProcessingException : Exception
|
||||||
|
{
|
||||||
|
public AuthRequestUpdateProcessingException() { }
|
||||||
|
|
||||||
|
public AuthRequestUpdateProcessingException(string message)
|
||||||
|
: base(message) { }
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Reflection;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class AuthRequestUpdateProcessor
|
||||||
|
{
|
||||||
|
public OrganizationAdminAuthRequest ProcessedAuthRequest { get; private set; }
|
||||||
|
|
||||||
|
private OrganizationAdminAuthRequest _unprocessedAuthRequest { get; }
|
||||||
|
private OrganizationAuthRequestUpdate _update { get; }
|
||||||
|
private AuthRequestUpdateProcessorConfiguration _configuration { get; }
|
||||||
|
|
||||||
|
public EventType OrganizationEventType => ProcessedAuthRequest?.Approved.Value ?? false
|
||||||
|
? EventType.OrganizationUser_ApprovedAuthRequest
|
||||||
|
: EventType.OrganizationUser_RejectedAuthRequest;
|
||||||
|
|
||||||
|
public AuthRequestUpdateProcessor(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_unprocessedAuthRequest = authRequest;
|
||||||
|
_update = update;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Process()
|
||||||
|
{
|
||||||
|
if (_unprocessedAuthRequest == null)
|
||||||
|
{
|
||||||
|
throw new AuthRequestUpdateCouldNotBeProcessedException();
|
||||||
|
}
|
||||||
|
var isExpired = DateTime.UtcNow >
|
||||||
|
_unprocessedAuthRequest.CreationDate
|
||||||
|
.Add(_configuration.AuthRequestExpiresAfter);
|
||||||
|
var isSpent = _unprocessedAuthRequest.Approved != null ||
|
||||||
|
_unprocessedAuthRequest.ResponseDate.HasValue ||
|
||||||
|
_unprocessedAuthRequest.AuthenticationDate.HasValue;
|
||||||
|
var canBeProcessed = !isExpired &&
|
||||||
|
!isSpent &&
|
||||||
|
_unprocessedAuthRequest.Id == _update.Id &&
|
||||||
|
_unprocessedAuthRequest.OrganizationId == _configuration.OrganizationId;
|
||||||
|
if (!canBeProcessed)
|
||||||
|
{
|
||||||
|
throw new AuthRequestUpdateCouldNotBeProcessedException(_unprocessedAuthRequest.Id);
|
||||||
|
}
|
||||||
|
if (_update.Approved)
|
||||||
|
{
|
||||||
|
Approve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendPushNotification(Func<OrganizationAdminAuthRequest, Task> callback)
|
||||||
|
{
|
||||||
|
if (!ProcessedAuthRequest?.Approved ?? false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await callback(ProcessedAuthRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendApprovalEmail(Func<OrganizationAdminAuthRequest, string, Task> callback)
|
||||||
|
{
|
||||||
|
if (!ProcessedAuthRequest?.Approved ?? false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var deviceTypeDisplayName = _unprocessedAuthRequest.RequestDeviceType.GetType()
|
||||||
|
.GetMember(_unprocessedAuthRequest.RequestDeviceType.ToString())
|
||||||
|
.FirstOrDefault()?
|
||||||
|
// This unknown case can't be unit tested without adding an enum
|
||||||
|
// with no display attribute. Faith and trust are required!
|
||||||
|
.GetCustomAttribute<DisplayAttribute>()?.Name ?? "Unknown Device Type";
|
||||||
|
var deviceTypeAndIdentifierDisplayString =
|
||||||
|
string.IsNullOrWhiteSpace(_unprocessedAuthRequest.RequestDeviceIdentifier)
|
||||||
|
? deviceTypeDisplayName
|
||||||
|
: $"{deviceTypeDisplayName} - {_unprocessedAuthRequest.RequestDeviceIdentifier}";
|
||||||
|
await callback(ProcessedAuthRequest, deviceTypeAndIdentifierDisplayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Approve()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_update.Key))
|
||||||
|
{
|
||||||
|
throw new ApprovedAuthRequestIsMissingKeyException(_update.Id);
|
||||||
|
}
|
||||||
|
ProcessedAuthRequest = _unprocessedAuthRequest;
|
||||||
|
ProcessedAuthRequest.Key = _update.Key;
|
||||||
|
ProcessedAuthRequest.Approved = true;
|
||||||
|
ProcessedAuthRequest.ResponseDate = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Deny()
|
||||||
|
{
|
||||||
|
ProcessedAuthRequest = _unprocessedAuthRequest;
|
||||||
|
ProcessedAuthRequest.Approved = false;
|
||||||
|
ProcessedAuthRequest.ResponseDate = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class AuthRequestUpdateProcessorConfiguration
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
public TimeSpan AuthRequestExpiresAfter { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
|||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class BatchAuthRequestUpdateProcessor
|
||||||
|
{
|
||||||
|
public List<AuthRequestUpdateProcessor> Processors { get; } = new List<AuthRequestUpdateProcessor>();
|
||||||
|
private List<AuthRequestUpdateProcessor> _processed => Processors
|
||||||
|
.Where(p => p.ProcessedAuthRequest != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public BatchAuthRequestUpdateProcessor(
|
||||||
|
ICollection<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
IEnumerable<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Processors = authRequests?.Select(ar =>
|
||||||
|
{
|
||||||
|
return new AuthRequestUpdateProcessor(
|
||||||
|
ar,
|
||||||
|
updates.FirstOrDefault(u => u.Id == ar.Id),
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
}).ToList() ?? Processors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatchAuthRequestUpdateProcessor Process(Action<Exception> errorHandlerCallback)
|
||||||
|
{
|
||||||
|
foreach (var processor in Processors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
processor.Process();
|
||||||
|
}
|
||||||
|
catch (AuthRequestUpdateProcessingException e)
|
||||||
|
{
|
||||||
|
errorHandlerCallback(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Save(Func<IEnumerable<OrganizationAdminAuthRequest>, Task> callback)
|
||||||
|
{
|
||||||
|
if (_processed.Any())
|
||||||
|
{
|
||||||
|
await callback(_processed.Select(p => p.ProcessedAuthRequest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently push notifications and emails are still done per-request in
|
||||||
|
// a loop, which is different than saving updates to the database and
|
||||||
|
// raising organization events. These can be done in bulk all the way
|
||||||
|
// through to the repository.
|
||||||
|
//
|
||||||
|
// Adding bulk notification and email methods is being tracked as tech
|
||||||
|
// debt on https://bitwarden.atlassian.net/browse/AC-2629
|
||||||
|
public async Task SendPushNotifications(Func<OrganizationAdminAuthRequest, Task> callback)
|
||||||
|
{
|
||||||
|
foreach (var processor in _processed)
|
||||||
|
{
|
||||||
|
await processor.SendPushNotification(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendApprovalEmailsForProcessedRequests(Func<OrganizationAdminAuthRequest, string, Task> callback)
|
||||||
|
{
|
||||||
|
foreach (var processor in _processed)
|
||||||
|
{
|
||||||
|
await processor.SendApprovalEmail(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LogOrganizationEventsForProcessedRequests(Func<IEnumerable<(OrganizationAdminAuthRequest, EventType)>, Task> callback)
|
||||||
|
{
|
||||||
|
if (_processed.Any())
|
||||||
|
{
|
||||||
|
await callback(_processed.Select(p =>
|
||||||
|
{
|
||||||
|
return (p.ProcessedAuthRequest, p.OrganizationEventType);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
public class OrganizationAuthRequestUpdate
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public bool Approved { get; set; }
|
||||||
|
public string Key { get; set; }
|
||||||
|
}
|
@ -1,10 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
using Bit.Core.Auth.Entities;
|
||||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationAuth;
|
namespace Bit.Core.AdminConsole.OrganizationAuth;
|
||||||
@ -15,19 +20,38 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
|
|||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ILogger<UpdateOrganizationAuthRequestCommand> _logger;
|
private readonly ILogger<UpdateOrganizationAuthRequestCommand> _logger;
|
||||||
|
private readonly IAuthRequestRepository _authRequestRepository;
|
||||||
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IPushNotificationService _pushNotificationService;
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
private readonly IEventService _eventService;
|
||||||
|
|
||||||
public UpdateOrganizationAuthRequestCommand(
|
public UpdateOrganizationAuthRequestCommand(
|
||||||
IAuthRequestService authRequestService,
|
IAuthRequestService authRequestService,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ILogger<UpdateOrganizationAuthRequestCommand> logger)
|
ILogger<UpdateOrganizationAuthRequestCommand> logger,
|
||||||
|
IAuthRequestRepository authRequestRepository,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IPushNotificationService pushNotificationService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IEventService eventService)
|
||||||
{
|
{
|
||||||
_authRequestService = authRequestService;
|
_authRequestService = authRequestService;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_authRequestRepository = authRequestRepository;
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
_pushNotificationService = pushNotificationService;
|
||||||
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
_eventService = eventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: When refactoring this method as a part of Bulk Device Approval
|
||||||
|
// post-release cleanup we should be able to construct a single
|
||||||
|
// AuthRequestProcessor and run its Process() Save() methods, and the
|
||||||
|
// various calls to send notifications.
|
||||||
public async Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey)
|
public async Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey)
|
||||||
{
|
{
|
||||||
var updatedAuthRequest = await _authRequestService.UpdateAuthRequestAsync(requestId, userId,
|
var updatedAuthRequest = await _authRequestService.UpdateAuthRequestAsync(requestId, userId,
|
||||||
@ -51,5 +75,65 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
|
|||||||
updatedAuthRequest.RequestIpAddress, deviceTypeAndIdentifier);
|
updatedAuthRequest.RequestIpAddress, deviceTypeAndIdentifier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Guid organizationId, IEnumerable<OrganizationAuthRequestUpdate> authRequestUpdates)
|
||||||
|
{
|
||||||
|
var authRequestEntities = await FetchManyOrganizationAuthRequestsFromTheDatabase(organizationId, authRequestUpdates.Select(aru => aru.Id));
|
||||||
|
var processor = new BatchAuthRequestUpdateProcessor(
|
||||||
|
authRequestEntities,
|
||||||
|
authRequestUpdates,
|
||||||
|
new AuthRequestUpdateProcessorConfiguration()
|
||||||
|
{
|
||||||
|
OrganizationId = organizationId,
|
||||||
|
AuthRequestExpiresAfter = _globalSettings.PasswordlessAuth.AdminRequestExpiration
|
||||||
|
}
|
||||||
|
);
|
||||||
|
processor.Process((Exception e) => _logger.LogError(e.Message));
|
||||||
|
await processor.Save((IEnumerable<OrganizationAdminAuthRequest> authRequests) => _authRequestRepository.UpdateManyAsync(authRequests));
|
||||||
|
await processor.SendPushNotifications((ar) => _pushNotificationService.PushAuthRequestResponseAsync(ar));
|
||||||
|
await processor.SendApprovalEmailsForProcessedRequests(SendApprovalEmail);
|
||||||
|
await processor.LogOrganizationEventsForProcessedRequests(LogOrganizationEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<ICollection<OrganizationAdminAuthRequest>> FetchManyOrganizationAuthRequestsFromTheDatabase(Guid organizationId, IEnumerable<Guid> authRequestIds)
|
||||||
|
{
|
||||||
|
return authRequestIds != null && authRequestIds.Any()
|
||||||
|
? await _authRequestRepository
|
||||||
|
.GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||||
|
organizationId,
|
||||||
|
authRequestIds
|
||||||
|
)
|
||||||
|
: new List<OrganizationAdminAuthRequest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SendApprovalEmail<T>(T authRequest, string identifier) where T : AuthRequest
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(authRequest.UserId);
|
||||||
|
|
||||||
|
// This should be impossible
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogError($"User {authRequest.UserId} not found. Trusted device admin approval email not sent.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _mailService.SendTrustedDeviceAdminApprovalEmailAsync(
|
||||||
|
user.Email,
|
||||||
|
authRequest.ResponseDate ?? DateTime.UtcNow,
|
||||||
|
authRequest.RequestIpAddress,
|
||||||
|
identifier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LogOrganizationEvents(IEnumerable<(OrganizationAdminAuthRequest AuthRequest, EventType EventType)> events)
|
||||||
|
{
|
||||||
|
var organizationUsers = await _organizationUserRepository.GetManyAsync(events.Select(e => e.AuthRequest.OrganizationUserId));
|
||||||
|
await _eventService.LogOrganizationUserEventsAsync(
|
||||||
|
organizationUsers.Select(ou =>
|
||||||
|
{
|
||||||
|
var e = events.FirstOrDefault(e => e.AuthRequest.OrganizationUserId == ou.Id);
|
||||||
|
return (ou, e.EventType, e.AuthRequest.ResponseDate);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
using Bit.Api.AdminConsole.Controllers;
|
||||||
|
using Bit.Api.AdminConsole.Models.Request;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Api.Test.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
[ControllerCustomize(typeof(OrganizationAuthRequestsController))]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class OrganizationAuthRequestsControllerTests
|
||||||
|
{
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAdminRequest_UserDoesNotHaveManageResetPasswordPermissions_ThrowsUnauthorized(
|
||||||
|
SutProvider<OrganizationAuthRequestsController> sutProvider,
|
||||||
|
Guid organizationId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(false);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||||
|
sutProvider.Sut.ValidateAdminRequest(organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAdminRequest_UserHasManageResetPasswordPermissions_DoesNotThrow(
|
||||||
|
SutProvider<OrganizationAuthRequestsController> sutProvider,
|
||||||
|
Guid organizationId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(true);
|
||||||
|
await sutProvider.Sut.ValidateAdminRequest(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task UpdateManyAuthRequests_ValidInput_DoesNotThrow(
|
||||||
|
SutProvider<OrganizationAuthRequestsController> sutProvider,
|
||||||
|
IEnumerable<OrganizationAuthRequestUpdateManyRequestModel> request,
|
||||||
|
Guid organizationId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(true);
|
||||||
|
await sutProvider.Sut.UpdateManyAuthRequests(organizationId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task UpdateManyAuthRequests_NotPermissioned_ThrowsUnauthorized(
|
||||||
|
SutProvider<OrganizationAuthRequestsController> sutProvider,
|
||||||
|
IEnumerable<OrganizationAuthRequestUpdateManyRequestModel> request,
|
||||||
|
Guid organizationId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(false);
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||||
|
sutProvider.Sut.UpdateManyAuthRequests(organizationId, request));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,258 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class AuthRequestUpdateProcessorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_NoAuthRequestLoaded_Throws(
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new AuthRequestUpdateProcessor(null, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestIsAlreadyApproved_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
authRequest = Approve(authRequest);
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestIsAlreadyDenied_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
authRequest = Deny(authRequest);
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestIsExpired_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(0, 10, 0);
|
||||||
|
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-60);
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_UpdateDoesNotMatch_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
while (authRequest.Id == update.Id)
|
||||||
|
{
|
||||||
|
authRequest.Id = new Guid();
|
||||||
|
}
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_AuthRequestAndOrganizationIdMismatch_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
while (authRequest.OrganizationId == processorConfiguration.OrganizationId)
|
||||||
|
{
|
||||||
|
authRequest.OrganizationId = new Guid();
|
||||||
|
}
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<AuthRequestUpdateCouldNotBeProcessedException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestApproved_NoKey_Throws(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = true;
|
||||||
|
update.Key = null;
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
Assert.ThrowsAny<ApprovedAuthRequestIsMissingKeyException>(() => sut.Process());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestApproved_ValidInput_Works(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = true;
|
||||||
|
update.Key = "key";
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
sut.Process();
|
||||||
|
Assert.True(sut.ProcessedAuthRequest.Approved);
|
||||||
|
Assert.Equal(sut.ProcessedAuthRequest.Key, update.Key);
|
||||||
|
Assert.NotNull(sut.ProcessedAuthRequest.ResponseDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_RequestDenied_ValidInput_Works(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = false;
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
sut.Process();
|
||||||
|
Assert.False(sut.ProcessedAuthRequest.Approved);
|
||||||
|
Assert.Null(sut.ProcessedAuthRequest.Key);
|
||||||
|
Assert.NotNull(sut.ProcessedAuthRequest.ResponseDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendPushNotification_RequestIsDenied_DoesNotSend(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = false;
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, Task>>();
|
||||||
|
sut.Process();
|
||||||
|
await sut.SendPushNotification(callback);
|
||||||
|
await callback.DidNotReceiveWithAnyArgs()(sut.ProcessedAuthRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendPushNotification_RequestIsApproved_DoesSend(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = true;
|
||||||
|
update.Key = "key";
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, Task>>();
|
||||||
|
sut.Process();
|
||||||
|
await sut.SendPushNotification(callback);
|
||||||
|
await callback.Received()(sut.ProcessedAuthRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendApprovalEmail_RequestIsDenied_DoesNotSend(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
update.Approved = false;
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, string, Task>>();
|
||||||
|
sut.Process();
|
||||||
|
await sut.SendApprovalEmail(callback);
|
||||||
|
await callback.DidNotReceiveWithAnyArgs()(sut.ProcessedAuthRequest, "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendApprovalEmail_RequestIsApproved_DoesSend(
|
||||||
|
OrganizationAdminAuthRequest authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration);
|
||||||
|
authRequest.RequestDeviceType = DeviceType.iOS;
|
||||||
|
authRequest.RequestDeviceIdentifier = "device-id";
|
||||||
|
update.Approved = true;
|
||||||
|
update.Key = "key";
|
||||||
|
var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, string, Task>>();
|
||||||
|
sut.Process();
|
||||||
|
await sut.SendApprovalEmail(callback);
|
||||||
|
await callback.Received()(sut.ProcessedAuthRequest, "iOS - device-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Approve<T>(T authRequest) where T : AuthRequest
|
||||||
|
{
|
||||||
|
authRequest.Key = "key";
|
||||||
|
authRequest.Approved = true;
|
||||||
|
authRequest.ResponseDate = DateTime.UtcNow;
|
||||||
|
return authRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Deny<T>(T authRequest) where T : AuthRequest
|
||||||
|
{
|
||||||
|
authRequest.Approved = false;
|
||||||
|
authRequest.ResponseDate = DateTime.UtcNow;
|
||||||
|
return authRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (
|
||||||
|
T AuthRequest,
|
||||||
|
AuthRequestUpdateProcessorConfiguration ProcessorConfiguration
|
||||||
|
) UnrespondAndEnsureValid<T>(
|
||||||
|
T authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
) where T : AuthRequest
|
||||||
|
{
|
||||||
|
authRequest.Id = update.Id;
|
||||||
|
authRequest.OrganizationId = processorConfiguration.OrganizationId;
|
||||||
|
authRequest.Key = null;
|
||||||
|
authRequest.Approved = null;
|
||||||
|
authRequest.ResponseDate = null;
|
||||||
|
authRequest.AuthenticationDate = null;
|
||||||
|
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-1);
|
||||||
|
processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(1, 0, 0);
|
||||||
|
return (authRequest, processorConfiguration);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,205 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationAuth.Models;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class BatchAuthRequestUpdateProcessorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_NoProcessors_Handled(
|
||||||
|
IEnumerable<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration);
|
||||||
|
sut.Process(errorHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_BadInput_CallsHandler(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
IEnumerable<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// An already approved auth request should break the processor
|
||||||
|
// immediately.
|
||||||
|
authRequests[0].Approved = true;
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
var errorHandler = Substitute.For<Action<Exception>>();
|
||||||
|
sut.Process(errorHandler);
|
||||||
|
errorHandler.ReceivedWithAnyArgs()(new AuthRequestUpdateProcessingException());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Process_ValidInput_Works(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration);
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
Assert.NotEmpty(sut.Processors);
|
||||||
|
sut.Process(errorHandler);
|
||||||
|
Assert.NotEmpty(sut.Processors.Where(p => p.ProcessedAuthRequest != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Save_NoProcessedAuthRequests_IsHandled(
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Func<IEnumerable<AuthRequest>, Task> saveCallback
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration);
|
||||||
|
Assert.Empty(sut.Processors);
|
||||||
|
await sut.Save(saveCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Save_ProcessedAuthRequests_IsHandled(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration);
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
var saveCallback = Substitute.For<Func<IEnumerable<OrganizationAdminAuthRequest>, Task>>();
|
||||||
|
await sut.Process(errorHandler).Save(saveCallback);
|
||||||
|
await saveCallback.ReceivedWithAnyArgs()(Arg.Any<IEnumerable<OrganizationAdminAuthRequest>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendPushNotifications_NoProcessors_IsHandled
|
||||||
|
(
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Func<AuthRequest, Task> callback
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration);
|
||||||
|
Assert.Empty(sut.Processors);
|
||||||
|
await sut.SendPushNotifications(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendPushNotifications_HasProcessors_Sends
|
||||||
|
(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration);
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, Task>>();
|
||||||
|
await sut.Process(errorHandler).SendPushNotifications(callback);
|
||||||
|
await callback.ReceivedWithAnyArgs()(Arg.Any<OrganizationAdminAuthRequest>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendApprovalEmailsForProcessedRequests_NoProcessors_IsHandled
|
||||||
|
(
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Func<AuthRequest, string, Task> callback
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration);
|
||||||
|
Assert.Empty(sut.Processors);
|
||||||
|
await sut.SendApprovalEmailsForProcessedRequests(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task SendApprovalEmailsForProcessedRequests_HasProcessors_Sends
|
||||||
|
(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration);
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
var callback = Substitute.For<Func<OrganizationAdminAuthRequest, string, Task>>();
|
||||||
|
await sut.Process(errorHandler).SendApprovalEmailsForProcessedRequests(callback);
|
||||||
|
await callback.ReceivedWithAnyArgs()(Arg.Any<OrganizationAdminAuthRequest>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task LogOrganizationEventsForProcessedRequests_NoProcessedAuthRequests_IsHandled
|
||||||
|
(
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration);
|
||||||
|
var callback = Substitute.For<Func<IEnumerable<(OrganizationAdminAuthRequest, EventType)>, Task>>();
|
||||||
|
Assert.Empty(sut.Processors);
|
||||||
|
await sut.LogOrganizationEventsForProcessedRequests(callback);
|
||||||
|
await callback.DidNotReceiveWithAnyArgs()(Arg.Any<IEnumerable<(OrganizationAdminAuthRequest, EventType)>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task LogOrganizationEventsForProcessedRequests_HasProcessedAuthRequests_IsHandled
|
||||||
|
(
|
||||||
|
List<OrganizationAdminAuthRequest> authRequests,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
Action<Exception> errorHandler
|
||||||
|
)
|
||||||
|
{
|
||||||
|
(authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration);
|
||||||
|
var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration);
|
||||||
|
var callback = Substitute.For<Func<IEnumerable<(OrganizationAdminAuthRequest, EventType)>, Task>>();
|
||||||
|
await sut.Process(errorHandler).LogOrganizationEventsForProcessedRequests(callback);
|
||||||
|
await callback.ReceivedWithAnyArgs()(Arg.Any<IEnumerable<(OrganizationAdminAuthRequest, EventType)>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private (
|
||||||
|
T authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration ProcessorConfiguration
|
||||||
|
) UnrespondAndEnsureValid<T>(
|
||||||
|
T authRequest,
|
||||||
|
OrganizationAuthRequestUpdate update,
|
||||||
|
AuthRequestUpdateProcessorConfiguration processorConfiguration
|
||||||
|
) where T : AuthRequest
|
||||||
|
{
|
||||||
|
authRequest.Id = update.Id;
|
||||||
|
authRequest.OrganizationId = processorConfiguration.OrganizationId;
|
||||||
|
authRequest.Key = null;
|
||||||
|
authRequest.Approved = null;
|
||||||
|
authRequest.ResponseDate = null;
|
||||||
|
authRequest.AuthenticationDate = null;
|
||||||
|
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-1);
|
||||||
|
processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(1, 0, 0);
|
||||||
|
|
||||||
|
update.Approved = true;
|
||||||
|
update.Key = "key";
|
||||||
|
return (authRequest, update, processorConfiguration);
|
||||||
|
}
|
||||||
|
}
|
@ -1,15 +1,17 @@
|
|||||||
using Bit.Core.AdminConsole.OrganizationAuth;
|
using Bit.Core.AdminConsole.OrganizationAuth;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationAuth.Models;
|
||||||
using Bit.Core.Auth.Entities;
|
using Bit.Core.Auth.Entities;
|
||||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ReturnsExtensions;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Core.Test.AdminConsole.OrganizationAuth;
|
namespace Bit.Core.Test.AdminConsole.OrganizationAuth;
|
||||||
@ -76,25 +78,169 @@ public class UpdateOrganizationAuthRequestCommandTests
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData]
|
[BitAutoData]
|
||||||
public async Task UpdateOrgAuthRequest_Approved_UserNotFound(
|
public async Task UpdateAsync_BatchUpdate_AuthRequestForOrganizationNotFound_DoesNotExecute(
|
||||||
SutProvider<UpdateOrganizationAuthRequestCommand> sutProvider, Guid requestId, Guid userId,
|
SutProvider<UpdateOrganizationAuthRequestCommand> sutProvider,
|
||||||
bool requestApproved, string encryptedUserKey)
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IAuthRequestService>()
|
sutProvider.GetDependency<IAuthRequestRepository>().GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||||
.UpdateAuthRequestAsync(requestId, userId,
|
configuration.OrganizationId,
|
||||||
Arg.Is<AuthRequestUpdateRequestModel>(x =>
|
updates.Select(ar => ar.Id)
|
||||||
x.RequestApproved == requestApproved && x.Key == encryptedUserKey))
|
).ReturnsForAnyArgs((ICollection<OrganizationAdminAuthRequest>)null);
|
||||||
.Returns(new AuthRequest() { Approved = true, });
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IUserRepository>()
|
await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates);
|
||||||
.GetByIdAsync(userId)
|
await sutProvider.GetDependency<IAuthRequestRepository>().DidNotReceiveWithAnyArgs().UpdateManyAsync(Arg.Any<IEnumerable<OrganizationAdminAuthRequest>>());
|
||||||
.ReturnsNull();
|
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushAuthRequestResponseAsync(Arg.Any<AuthRequest>());
|
||||||
|
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendTrustedDeviceAdminApprovalEmailAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>()
|
||||||
|
);
|
||||||
|
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogOrganizationUserEventsAsync(
|
||||||
|
Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await sutProvider.Sut.UpdateAsync(requestId, userId, requestApproved, encryptedUserKey);
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task UpdateAsync_BatchUpdate_ValidRequest_SavesAndFiresAllEvents(
|
||||||
|
SutProvider<UpdateOrganizationAuthRequestCommand> sutProvider,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
List<OrganizationAdminAuthRequest> unprocessedAuthRequests,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
List<OrganizationUser> organizationUsers,
|
||||||
|
List<User> users
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// For this command to work we need the following from external
|
||||||
|
// classes:
|
||||||
|
// 1. A configured expiration timespan for organization auth requests
|
||||||
|
// 2. Some unresponded to auth requests that match the ids provided
|
||||||
|
// 3. A valid user to send emails to
|
||||||
|
// 4. A valid organization user to log events for
|
||||||
|
|
||||||
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(userId);
|
for (int i = 0; i < updates.Count(); i++)
|
||||||
await sutProvider.GetDependency<IMailService>().DidNotReceive()
|
{
|
||||||
.SendTrustedDeviceAdminApprovalEmailAsync(Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>(),
|
unprocessedAuthRequests[i] = UnrespondAndEnsureValid(unprocessedAuthRequests[i], configuration.OrganizationId);
|
||||||
Arg.Any<string>());
|
updates[i].Approved = true;
|
||||||
|
updates[i].Key = "key";
|
||||||
|
unprocessedAuthRequests[i].Id = updates[i].Id;
|
||||||
|
unprocessedAuthRequests[i].RequestDeviceType = DeviceType.iOS;
|
||||||
|
unprocessedAuthRequests[i].OrganizationUserId = organizationUsers[i].Id;
|
||||||
|
organizationUsers[i].OrganizationId = configuration.OrganizationId;
|
||||||
|
users[i].Id = unprocessedAuthRequests[i].UserId;
|
||||||
|
organizationUsers[i].UserId = unprocessedAuthRequests[i].UserId;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Is(users[i].Id)).Returns(users[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IGlobalSettings>().PasswordlessAuth.AdminRequestExpiration.Returns(TimeSpan.FromDays(7));
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAuthRequestRepository>().GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||||
|
configuration.OrganizationId,
|
||||||
|
updates.Select(ar => ar.Id)
|
||||||
|
).ReturnsForAnyArgs(unprocessedAuthRequests);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(Arg.Is<IEnumerable<Guid>>(
|
||||||
|
list => list.All(x => organizationUsers.Select(y => y.Id).Contains(x)))).Returns(organizationUsers);
|
||||||
|
|
||||||
|
// Call the SUT
|
||||||
|
await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates);
|
||||||
|
|
||||||
|
// Assert that because we passed in good data we call a save
|
||||||
|
// operation and raise all events
|
||||||
|
await sutProvider.GetDependency<IAuthRequestRepository>()
|
||||||
|
.Received()
|
||||||
|
.UpdateManyAsync(
|
||||||
|
Arg.Is<IEnumerable<OrganizationAdminAuthRequest>>(list =>
|
||||||
|
list.Any() &&
|
||||||
|
list.All(x =>
|
||||||
|
x.Approved.Value &&
|
||||||
|
x.Key == "key" &&
|
||||||
|
x.ResponseDate != null &&
|
||||||
|
unprocessedAuthRequests.Select(y => y.Id).Contains(x.Id))));
|
||||||
|
|
||||||
|
foreach (var authRequest in unprocessedAuthRequests)
|
||||||
|
{
|
||||||
|
await sutProvider.GetDependency<IPushNotificationService>().Received()
|
||||||
|
.PushAuthRequestResponseAsync(Arg.Is<OrganizationAdminAuthRequest>
|
||||||
|
(ar => ar.Id == authRequest.Id && ar.Approved == true && ar.Key == "key"));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>().Received().SendTrustedDeviceAdminApprovalEmailAsync(
|
||||||
|
users.FirstOrDefault(x => x.Id == authRequest.UserId).Email,
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
authRequest.RequestIpAddress,
|
||||||
|
$"iOS - {authRequest.RequestDeviceIdentifier}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IEventService>().Received().LogOrganizationUserEventsAsync(
|
||||||
|
Arg.Is<IEnumerable<(OrganizationUser o, EventType e, DateTime? d)>>(list =>
|
||||||
|
list.Any() && list.All(x => organizationUsers.Any(y => y.Id == x.o.Id) && x.e == EventType.OrganizationUser_ApprovedAuthRequest)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task UpdateAsync_BatchUpdate_AuthRequestIsDenied_DoesNotLeakRejection(
|
||||||
|
SutProvider<UpdateOrganizationAuthRequestCommand> sutProvider,
|
||||||
|
List<OrganizationAuthRequestUpdate> updates,
|
||||||
|
OrganizationAdminAuthRequest unprocessedAuthRequest,
|
||||||
|
AuthRequestUpdateProcessorConfiguration configuration,
|
||||||
|
User user
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// For this command to work we need the following from external
|
||||||
|
// classes:
|
||||||
|
// 1. A configured expiration timespan for organization auth requests
|
||||||
|
// 2. Some unresponded to auth requests that match the ids provided
|
||||||
|
// 3. A valid user to send emails to
|
||||||
|
|
||||||
|
var unprocessedAuthRequests = new List<OrganizationAdminAuthRequest>();
|
||||||
|
unprocessedAuthRequest = UnrespondAndEnsureValid(unprocessedAuthRequest, configuration.OrganizationId);
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
update.Approved = false;
|
||||||
|
unprocessedAuthRequest.Id = update.Id;
|
||||||
|
unprocessedAuthRequests.Add(unprocessedAuthRequest);
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IGlobalSettings>().PasswordlessAuth.AdminRequestExpiration.Returns(TimeSpan.FromDays(7));
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IAuthRequestRepository>().GetManyAdminApprovalRequestsByManyIdsAsync(
|
||||||
|
configuration.OrganizationId,
|
||||||
|
updates.Select(ar => ar.Id)
|
||||||
|
).ReturnsForAnyArgs(unprocessedAuthRequests);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
|
||||||
|
|
||||||
|
// Call the SUT
|
||||||
|
await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates);
|
||||||
|
|
||||||
|
// Assert that because we passed in good data we call a save
|
||||||
|
// operation and raise all events
|
||||||
|
await sutProvider.GetDependency<IAuthRequestRepository>().ReceivedWithAnyArgs().UpdateManyAsync(Arg.Any<IEnumerable<OrganizationAdminAuthRequest>>());
|
||||||
|
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushAuthRequestResponseAsync(Arg.Any<AuthRequest>());
|
||||||
|
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendTrustedDeviceAdminApprovalEmailAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>()
|
||||||
|
);
|
||||||
|
await sutProvider.GetDependency<IEventService>().ReceivedWithAnyArgs().LogOrganizationUserEventsAsync(
|
||||||
|
Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T UnrespondAndEnsureValid<T>(T authRequest, Guid organizationId) where T : AuthRequest
|
||||||
|
{
|
||||||
|
authRequest.OrganizationId = organizationId;
|
||||||
|
authRequest.Key = null;
|
||||||
|
authRequest.Approved = null;
|
||||||
|
authRequest.ResponseDate = null;
|
||||||
|
authRequest.AuthenticationDate = null;
|
||||||
|
authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10);
|
||||||
|
return authRequest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user