1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05: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:
Addison Beck
2024-05-26 20:56:52 -05:00
committed by GitHub
parent 0d2e953459
commit 98a191a5e8
15 changed files with 1054 additions and 21 deletions

View File

@ -1,12 +1,14 @@
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
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))
{

View File

@ -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
};
}
}

View File

@ -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
{
Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey);
Task UpdateAsync(Guid organizationId, IEnumerable<OrganizationAuthRequestUpdate> authRequestUpdates);
}

View File

@ -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.")
{
}
}

View File

@ -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.")
{
}
}

View File

@ -0,0 +1,9 @@
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
public class AuthRequestUpdateProcessingException : Exception
{
public AuthRequestUpdateProcessingException() { }
public AuthRequestUpdateProcessingException(string message)
: base(message) { }
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.AdminConsole.OrganizationAuth.Models;
public class AuthRequestUpdateProcessorConfiguration
{
public Guid OrganizationId { get; set; }
public TimeSpan AuthRequestExpiresAfter { get; set; }
}

View File

@ -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);
}));
}
}
}

View File

@ -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; }
}

View File

@ -1,10 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
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.Data;
using Bit.Core.Auth.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
namespace Bit.Core.AdminConsole.OrganizationAuth;
@ -15,19 +20,38 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
private readonly IMailService _mailService;
private readonly IUserRepository _userRepository;
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(
IAuthRequestService authRequestService,
IMailService mailService,
IUserRepository userRepository,
ILogger<UpdateOrganizationAuthRequestCommand> logger)
ILogger<UpdateOrganizationAuthRequestCommand> logger,
IAuthRequestRepository authRequestRepository,
IGlobalSettings globalSettings,
IPushNotificationService pushNotificationService,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService)
{
_authRequestService = authRequestService;
_mailService = mailService;
_userRepository = userRepository;
_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)
{
var updatedAuthRequest = await _authRequestService.UpdateAuthRequestAsync(requestId, userId,
@ -51,5 +75,65 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
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);
})
);
}
}