1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

[PM-15637] Add Email Notification Templates and Logic for Device Approval Requests (#5270)

* Add device approval notification email templates

* Add DeviceApprovalRequestedViewModel for device approval notifications

* Add method to send device approval requested notification email

* Send email notification to Organization Admins when adding a new admin approval auth request

* Add tests for device approval notification email sending in AuthRequestServiceTests

* fix(email-templates): Remove unnecessary triple braces from user name variable in device approval notification emails

* Add feature flag for admin notifications on device approval requests

* Add logging for skipped admin notifications on device approval requests
This commit is contained in:
Rui Tomé 2025-01-27 10:59:46 +00:00 committed by GitHub
parent 3908edd08f
commit 9e718d7336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 249 additions and 1 deletions

View File

@ -0,0 +1,14 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.AdminConsole.Models.Mail;
public class DeviceApprovalRequestedViewModel : BaseMailModel
{
public Guid OrganizationId { get; set; }
public string UserNameRequestingAccess { get; set; }
public string Url => string.Format("{0}/organizations/{1}/settings/device-approvals",
WebVaultUrl,
OrganizationId);
}

View File

@ -12,6 +12,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
#nullable enable #nullable enable
@ -27,6 +28,9 @@ public class AuthRequestService : IAuthRequestService
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IMailService _mailService;
private readonly IFeatureService _featureService;
private readonly ILogger<AuthRequestService> _logger;
public AuthRequestService( public AuthRequestService(
IAuthRequestRepository authRequestRepository, IAuthRequestRepository authRequestRepository,
@ -36,7 +40,10 @@ public class AuthRequestService : IAuthRequestService
ICurrentContext currentContext, ICurrentContext currentContext,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
IEventService eventService, IEventService eventService,
IOrganizationUserRepository organizationRepository) IOrganizationUserRepository organizationRepository,
IMailService mailService,
IFeatureService featureService,
ILogger<AuthRequestService> logger)
{ {
_authRequestRepository = authRequestRepository; _authRequestRepository = authRequestRepository;
_userRepository = userRepository; _userRepository = userRepository;
@ -46,6 +53,9 @@ public class AuthRequestService : IAuthRequestService
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_eventService = eventService; _eventService = eventService;
_organizationUserRepository = organizationRepository; _organizationUserRepository = organizationRepository;
_mailService = mailService;
_featureService = featureService;
_logger = logger;
} }
public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId) public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId)
@ -132,6 +142,8 @@ public class AuthRequestService : IAuthRequestService
{ {
var createdAuthRequest = await CreateAuthRequestAsync(model, user, organizationUser.OrganizationId); var createdAuthRequest = await CreateAuthRequestAsync(model, user, organizationUser.OrganizationId);
firstAuthRequest ??= createdAuthRequest; firstAuthRequest ??= createdAuthRequest;
await NotifyAdminsOfDeviceApprovalRequestAsync(organizationUser, user);
} }
// I know this won't be null because I have already validated that at least one organization exists // I know this won't be null because I have already validated that at least one organization exists
@ -276,4 +288,19 @@ public class AuthRequestService : IAuthRequestService
{ {
return DateTime.UtcNow > savedDate.Add(allowedLifetime); return DateTime.UtcNow > savedDate.Add(allowedLifetime);
} }
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications))
{
_logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled");
return;
}
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(
organizationUser.OrganizationId,
OrganizationUserType.Admin);
var adminEmails = admins.Select(a => a.Email).Distinct().ToList();
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(adminEmails, organizationUser.OrganizationId, user.Email, user.Name);
}
} }

View File

@ -106,6 +106,7 @@ public static class FeatureFlagKeys
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string IntegrationPage = "pm-14505-admin-console-integration-page"; public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";

View File

@ -0,0 +1,23 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in.
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{Url}}" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Review request
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in.
{{Url}}
{{/BasicTextLayout}}

View File

@ -0,0 +1,29 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
To review requests, log in to your self-hosted instance → navigate to the Admin Console → select Device Approvals
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{Url}}" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Review request
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,7 @@
{{#>BasicTextLayout}}
{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in.
To review requests, log in to your self-hosted instance -> navigate to the Admin Console -> select Device Approvals.
{{Url}}
{{/BasicTextLayout}}

View File

@ -96,5 +96,6 @@ public interface IMailService
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
string organizationName); string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
} }

View File

@ -2,6 +2,7 @@
using System.Reflection; using System.Reflection;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Mail;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Mail; using Bit.Core.Auth.Models.Mail;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
@ -1168,6 +1169,23 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName)
{
var templateName = _globalSettings.SelfHosted ?
"AdminConsole.SelfHostNotifyAdminDeviceApprovalRequested" :
"AdminConsole.NotifyAdminDeviceApprovalRequested";
var message = CreateDefaultMessage("Review SSO login request for new device", adminEmails);
var model = new DeviceApprovalRequestedViewModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
UserNameRequestingAccess = GetUserIdentifier(email, userName),
OrganizationId = organizationId,
};
await AddMessageContentAsync(message, templateName, model);
message.Category = "DeviceApprovalRequested";
await _mailDeliveryService.SendEmailAsync(message);
}
private static string GetUserIdentifier(string email, string userName) private static string GetUserIdentifier(string email, string userName)
{ {
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);

View File

@ -316,5 +316,10 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask;
public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName)
{
return Task.FromResult(0);
}
} }

View File

@ -7,6 +7,7 @@ using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -227,6 +228,14 @@ public class AuthRequestServiceTests
await sutProvider.GetDependency<IAuthRequestRepository>() await sutProvider.GetDependency<IAuthRequestRepository>()
.Received() .Received()
.CreateAsync(createdAuthRequest); .CreateAsync(createdAuthRequest);
await sutProvider.GetDependency<IMailService>()
.DidNotReceiveWithAnyArgs()
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Any<IEnumerable<string>>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>());
} }
/// <summary> /// <summary>
@ -321,6 +330,115 @@ public class AuthRequestServiceTests
await sutProvider.GetDependency<IEventService>() await sutProvider.GetDependency<IEventService>()
.Received(1) .Received(1)
.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval); .LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
await sutProvider.GetDependency<IMailService>()
.DidNotReceiveWithAnyArgs()
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Any<IEnumerable<string>>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_CreatesForEachOrganization_SendsEmails(
SutProvider<AuthRequestService> sutProvider,
AuthRequestCreateRequestModel createModel,
User user,
OrganizationUser organizationUser1,
OrganizationUserUserDetails admin1,
OrganizationUser organizationUser2,
OrganizationUserUserDetails admin2,
OrganizationUserUserDetails admin3)
{
createModel.Type = AuthRequestType.AdminApproval;
user.Email = createModel.Email;
organizationUser1.UserId = user.Id;
organizationUser2.UserId = user.Id;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)
.Returns(true);
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(user.Email)
.Returns(user);
sutProvider.GetDependency<ICurrentContext>()
.DeviceType
.Returns(DeviceType.ChromeExtension);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(user.Id);
sutProvider.GetDependency<IGlobalSettings>()
.PasswordlessAuth.KnownDevicesOnly
.Returns(false);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(user.Id)
.Returns(new List<OrganizationUser>
{
organizationUser1,
organizationUser2,
});
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Admin)
.Returns(
[
admin1,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Admin)
.Returns(
[
admin2,
admin3,
]);
sutProvider.GetDependency<IAuthRequestRepository>()
.CreateAsync(Arg.Any<AuthRequest>())
.Returns(c => c.ArgAt<AuthRequest>(0));
var authRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel);
Assert.Equal(organizationUser1.OrganizationId, authRequest.OrganizationId);
await sutProvider.GetDependency<IAuthRequestRepository>()
.Received(1)
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser1.OrganizationId));
await sutProvider.GetDependency<IAuthRequestRepository>()
.Received(1)
.CreateAsync(Arg.Is<AuthRequest>(o => o.OrganizationId == organizationUser2.OrganizationId));
await sutProvider.GetDependency<IAuthRequestRepository>()
.Received(2)
.CreateAsync(Arg.Any<AuthRequest>());
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 1 && emails.Contains(admin1.Email)),
organizationUser1.OrganizationId,
user.Email,
user.Name);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 2 && emails.Contains(admin2.Email) && emails.Contains(admin3.Email)),
organizationUser2.OrganizationId,
user.Email,
user.Name);
} }
/// <summary> /// <summary>