1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-03 00:52:49 -05:00

[PM-14406] Security Task Notifications (#5344)

* initial commit of `CipherOrganizationPermission_GetManyByUserId`

* create queries to get all of the security tasks that are actionable by a user

- A task is "actionable" when the user has manage permissions for that cipher

* rename query

* return the user's email from the query as well

* Add email notification for at-risk passwords

- Added email layouts for security tasks

* add push notification for security tasks

* update entity framework to match stored procedure plus testing

* update date of migration and remove orderby

* add push service to security task controller

* rename `SyncSecurityTasksCreated` to `SyncNotification`

* remove duplicate return

* remove unused directive

* remove unneeded new notification type

* use `createNotificationCommand` to alert all platforms

* return the cipher id that is associated with the security task and store the security task id on the notification entry

* Add `TaskId` to the output model of `GetUserSecurityTasksByCipherIdsAsync`

* move notification logic to command

* use TaskId from `_getSecurityTasksNotificationDetailsQuery`

* add service

* only push last notification for each user

* formatting

* refactor `CreateNotificationCommand` parameter to `sendPush`

* flip boolean in test

* update interface to match usage

* do not push any of the security related notifications to the user

* add `PendingSecurityTasks` push type

* add push notification for pending security tasks
This commit is contained in:
Nick Krantz
2025-02-27 08:34:42 -06:00
committed by GitHub
parent a2e665cb96
commit 1267332b5b
35 changed files with 893 additions and 8 deletions

View File

@ -29,5 +29,7 @@ public enum PushType : byte
SyncOrganizationCollectionSettingChanged = 19,
Notification = 20,
NotificationStatus = 21
NotificationStatus = 21,
PendingSecurityTasks = 22
}

View File

@ -0,0 +1,61 @@
{{#>FullUpdatedHtmlLayout}}
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="background-color: #175DDC;padding-top:25px;padding-bottom:15px;">
<tr>
<td align="center" valign="top" width="70%" class="templateColumnContainer">
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
TaskCountPlural}}s{{/unless}} a
password change
</td>
</tr>
</table>
</td>
<td align="right" valign="bottom" class="templateColumnContainer" style="padding-right: 15px;">
<img width="140" height="140" align="right" valign="bottom"
style="width: 140px; height:140px; font-size: 0; vertical-align: bottom; text-align: right;" alt=''
src='https://assets.bitwarden.com/email/v1/business-warning.png' />
</td>
</tr>
</table>
{{>@partial-block}}
<table width="100%" style="display:table; background-color: #FBFBFB; vertical-align: middle; padding:30px" border="0"
cellpadding="0" cellspacing="0" valign="middle">
<tr>
<td width="70%" class="footer-text" style="padding-right: 20px;">
<table align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
<p
style="margin: 0; padding: 0; margin-bottom: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 600; font-size: 20px; line-height: 28px;">
Were here for you!</p>
If you have any questions, search the Bitwarden <a
style="text-decoration: none; color: #175DDC; font-weight: 600;"
href="https://bitwarden.com/help/">Help</a> site or <a
style="text-decoration: none; color: #175DDC; font-weight: 600;"
href="https://bitwarden.com/contact/">contact us</a>.
</td>
</tr>
</table>
</td>
<td width="30%">
<table align="right" valign="bottom" class="footer-image" border="0" cellpadding="0" cellspacing="0"
style="padding-left: 40px;">
<tr>
<td>
<img width="94" height="77" src="https://assets.bitwarden.com/email/v1/chat.png"
style="width: 94px; height: 77px;" alt="" />
</td>
</tr>
</table>
</td>
</tr>
</table>
{{/FullUpdatedHtmlLayout}}

View File

@ -0,0 +1,12 @@
{{#>FullTextLayout}}
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
TaskCountPlural}}s{{/unless}} a
password change
{{>@partial-block}}
Were here for you!
If you have any questions, search the Bitwarden Help site or contact us.
- https://bitwarden.com/help/
- https://bitwarden.com/contact/
{{/FullTextLayout}}

View File

@ -0,0 +1,28 @@
{{#>SecurityTasksHtmlLayout}}
<table width="100%" border="0" style="display: block; padding: 30px;" align="center" cellpadding="0" cellspacing="0">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a
data breach.
</td>
</tr>
<tr>
<td
style="padding-top: 24px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
Launch the Bitwarden extension to review your at-risk passwords.
</td>
</tr>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0"
style="display: table; width:100%; padding-bottom: 35px; text-align: center;" align="center">
<tr>
<td display="display: table-cell">
<a href="{{ReviewPasswordsUrl}}" clicktracking=off target="_blank"
style="display: inline-block; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; 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 at-risk passwords
</a>
</td>
</tr>
</table>
{{/SecurityTasksHtmlLayout}}

View File

@ -0,0 +1,8 @@
{{#>SecurityTasksHtmlLayout}}
Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a data
breach.
Launch the Bitwarden extension to review your at-risk passwords.
Review at-risk passwords ({{{ReviewPasswordsUrl}}})
{{/SecurityTasksHtmlLayout}}

View File

@ -0,0 +1,12 @@
namespace Bit.Core.Models.Mail;
public class SecurityTaskNotificationViewModel : BaseMailModel
{
public string OrgName { get; set; }
public int TaskCount { get; set; }
public bool TaskCountPlural => TaskCount != 1;
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
}

View File

@ -28,7 +28,7 @@ public class CreateNotificationCommand : ICreateNotificationCommand
_pushNotificationService = pushNotificationService;
}
public async Task<Notification> CreateAsync(Notification notification)
public async Task<Notification> CreateAsync(Notification notification, bool sendPush = true)
{
notification.CreationDate = notification.RevisionDate = DateTime.UtcNow;
@ -37,7 +37,10 @@ public class CreateNotificationCommand : ICreateNotificationCommand
var newNotification = await _notificationRepository.CreateAsync(notification);
await _pushNotificationService.PushNotificationAsync(newNotification);
if (sendPush)
{
await _pushNotificationService.PushNotificationAsync(newNotification);
}
return newNotification;
}

View File

@ -5,5 +5,5 @@ namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface ICreateNotificationCommand
{
Task<Notification> CreateAsync(Notification notification);
Task<Notification> CreateAsync(Notification notification, bool sendPush = true);
}

View File

@ -329,6 +329,11 @@ public class NotificationHubPushNotificationService : IPushNotificationService
GetContextIdentifier(excludeCurrentContext), clientType: clientType);
}
public async Task PushPendingSecurityTasksAsync(Guid userId)
{
await PushUserAsync(userId, PushType.PendingSecurityTasks);
}
public async Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload,
string? identifier, string? deviceId = null, ClientType? clientType = null)
{

View File

@ -219,6 +219,11 @@ public class AzureQueuePushNotificationService : IPushNotificationService
await SendMessageAsync(PushType.NotificationStatus, message, true);
}
public async Task PushPendingSecurityTasksAsync(Guid userId)
{
await PushUserAsync(userId, PushType.PendingSecurityTasks);
}
private async Task PushSendAsync(Send send, PushType type)
{
if (send.UserId.HasValue)

View File

@ -38,4 +38,5 @@ public interface IPushNotificationService
string? deviceId = null, ClientType? clientType = null);
Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
string? deviceId = null, ClientType? clientType = null);
Task PushPendingSecurityTasksAsync(Guid userId);
}

View File

@ -179,6 +179,12 @@ public class MultiServicePushNotificationService : IPushNotificationService
return Task.FromResult(0);
}
public Task PushPendingSecurityTasksAsync(Guid userId)
{
PushToServices((s) => s.PushPendingSecurityTasksAsync(userId));
return Task.CompletedTask;
}
private void PushToServices(Func<IPushNotificationService, Task> pushFunc)
{
if (!_services.Any())

View File

@ -121,4 +121,9 @@ public class NoopPushNotificationService : IPushNotificationService
{
return Task.FromResult(0);
}
public Task PushPendingSecurityTasksAsync(Guid userId)
{
return Task.FromResult(0);
}
}

View File

@ -232,6 +232,11 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
await SendMessageAsync(PushType.NotificationStatus, message, true);
}
public async Task PushPendingSecurityTasksAsync(Guid userId)
{
await PushUserAsync(userId, PushType.PendingSecurityTasks);
}
private async Task PushSendAsync(Send send, PushType type)
{
if (send.UserId.HasValue)

View File

@ -300,6 +300,11 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
false
);
public async Task PushPendingSecurityTasksAsync(Guid userId)
{
await PushUserAsync(userId, PushType.PendingSecurityTasks);
}
private async Task SendPayloadToInstallationAsync(PushType type, object payload, bool excludeCurrentContext,
ClientType? clientType = null)
{

View File

@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Services;
@ -98,5 +99,5 @@ public interface IMailService
string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons);
}

View File

@ -15,6 +15,7 @@ using Bit.Core.Models.Mail.Provider;
using Bit.Core.SecretsManager.Models.Mail;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Models.Data;
using HandlebarsDotNet;
namespace Bit.Core.Services;
@ -654,6 +655,10 @@ public class HandlebarsMailService : IMailService
Handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource);
var titleContactUsTextLayoutSource = await ReadSourceAsync("Layouts.TitleContactUs.text");
Handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource);
var securityTasksHtmlLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.html");
Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource);
var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text");
Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource);
Handlebars.RegisterHelper("date", (writer, context, parameters) =>
{
@ -1196,9 +1201,26 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons)
{
MailQueueMessage CreateMessage(UserSecurityTasksCount notification)
{
var message = CreateDefaultMessage($"{orgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email);
var model = new SecurityTaskNotificationViewModel
{
OrgName = orgName,
TaskCount = notification.TaskCount,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
};
message.Category = "SecurityTasksNotification";
return new MailQueueMessage(message, "SecurityTasksNotification", model);
}
var messageModels = securityTaskNotificaitons.Select(CreateMessage);
await EnqueueMailAsync(messageModels.ToList());
}
private static string GetUserIdentifier(string email, string userName)
{
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
}
}

View File

@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Services;
@ -322,5 +323,9 @@ public class NoopMailService : IMailService
{
return Task.FromResult(0);
}
}
public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons)
{
return Task.FromResult(0);
}
}

View File

@ -0,0 +1,82 @@
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries;
public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCommand
{
private readonly IGetSecurityTasksNotificationDetailsQuery _getSecurityTasksNotificationDetailsQuery;
private readonly IOrganizationRepository _organizationRepository;
private readonly IMailService _mailService;
private readonly ICreateNotificationCommand _createNotificationCommand;
private readonly IPushNotificationService _pushNotificationService;
public CreateManyTaskNotificationsCommand(
IGetSecurityTasksNotificationDetailsQuery getSecurityTasksNotificationDetailsQuery,
IOrganizationRepository organizationRepository,
IMailService mailService,
ICreateNotificationCommand createNotificationCommand,
IPushNotificationService pushNotificationService)
{
_getSecurityTasksNotificationDetailsQuery = getSecurityTasksNotificationDetailsQuery;
_organizationRepository = organizationRepository;
_mailService = mailService;
_createNotificationCommand = createNotificationCommand;
_pushNotificationService = pushNotificationService;
}
public async Task CreateAsync(Guid orgId, IEnumerable<SecurityTask> securityTasks)
{
var securityTaskCiphers = await _getSecurityTasksNotificationDetailsQuery.GetNotificationDetailsByManyIds(orgId, securityTasks);
// Get the number of tasks for each user
var userTaskCount = securityTaskCiphers.GroupBy(x => x.UserId).Select(x => new UserSecurityTasksCount
{
UserId = x.Key,
Email = x.First().Email,
TaskCount = x.Count()
}).ToList();
var organization = await _organizationRepository.GetByIdAsync(orgId);
await _mailService.SendBulkSecurityTaskNotificationsAsync(organization.Name, userTaskCount);
// Break securityTaskCiphers into separate lists by user Id
var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var userId in securityTaskCiphersByUser.Keys)
{
// Get the security tasks by the user Id
var userSecurityTaskCiphers = securityTaskCiphersByUser[userId];
// Process each user's security task ciphers
for (int i = 0; i < userSecurityTaskCiphers.Count; i++)
{
var userSecurityTaskCipher = userSecurityTaskCiphers[i];
// Create a notification for the user with the associated task
var notification = new Notification
{
UserId = userSecurityTaskCipher.UserId,
OrganizationId = orgId,
Priority = Priority.Informational,
ClientType = ClientType.Browser,
TaskId = userSecurityTaskCipher.TaskId
};
await _createNotificationCommand.CreateAsync(notification, false);
}
// Notify the user that they have pending security tasks
await _pushNotificationService.PushPendingSecurityTasksAsync(userId);
}
}
}

View File

@ -0,0 +1,13 @@
using Bit.Core.Vault.Entities;
namespace Bit.Core.Vault.Commands.Interfaces;
public interface ICreateManyTaskNotificationsCommand
{
/// <summary>
/// Creates email and push notifications for the given security tasks.
/// </summary>
/// <param name="organizationId">The organization Id </param>
/// <param name="securityTasks">All applicable security tasks</param>
Task CreateAsync(Guid organizationId, IEnumerable<SecurityTask> securityTasks);
}

View File

@ -0,0 +1,23 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// Minimal data model that represents a User and the associated cipher for a security task.
/// Only to be used for query responses. For full data model, <see cref="UserSecurityTaskCipher"/>.
/// </summary>
public class UserCipherForTask
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The cipher Id of the security task.
/// </summary>
public Guid CipherId { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// Data model that represents a User and the associated cipher for a security task.
/// </summary>
public class UserSecurityTaskCipher
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The cipher Id of the security task.
/// </summary>
public Guid CipherId { get; set; }
/// <summary>
/// The Id of the security task.
/// </summary>
public Guid TaskId { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// Data model that represents a User and the amount of actionable security tasks.
/// </summary>
public class UserSecurityTasksCount
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The number of actionable security tasks for the respective users.
/// </summary>
public int TaskCount { get; set; }
}

View File

@ -0,0 +1,33 @@
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
namespace Bit.Core.Vault.Queries;
public class GetSecurityTasksNotificationDetailsQuery : IGetSecurityTasksNotificationDetailsQuery
{
private readonly ICurrentContext _currentContext;
private readonly ICipherRepository _cipherRepository;
public GetSecurityTasksNotificationDetailsQuery(ICurrentContext currentContext, ICipherRepository cipherRepository)
{
_currentContext = currentContext;
_cipherRepository = cipherRepository;
}
public async Task<ICollection<UserSecurityTaskCipher>> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable<SecurityTask> tasks)
{
var org = _currentContext.GetOrganization(organizationId);
if (org == null)
{
throw new NotFoundException();
}
var userSecurityTaskCiphers = await _cipherRepository.GetUserSecurityTasksByCipherIdsAsync(organizationId, tasks);
return userSecurityTaskCiphers;
}
}

View File

@ -0,0 +1,16 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Vault.Queries;
public interface IGetSecurityTasksNotificationDetailsQuery
{
/// <summary>
/// Retrieves all users within the given organization that are applicable to the given security tasks.
///
/// <param name="organizationId"></param>
/// <param name="tasks"></param>
/// <returns>A dictionary of UserIds and the corresponding amount of security tasks applicable to them.</returns>
/// </summary>
public Task<ICollection<UserSecurityTaskCipher>> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable<SecurityTask> tasks);
}

View File

@ -4,6 +4,7 @@ using Bit.Core.Repositories;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Vault.Repositories;
public interface ICipherRepository : IRepository<Cipher, Guid>
@ -49,6 +50,13 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
Task<ICollection<OrganizationCipherPermission>> GetCipherPermissionsForOrganizationAsync(Guid organizationId,
Guid userId);
/// <summary>
/// Returns the users and the cipher ids for security tawsks that are applicable to them.
///
/// Security tasks are actionable when a user has manage access to the associated cipher.
/// </summary>
Task<ICollection<UserSecurityTaskCipher>> GetUserSecurityTasksByCipherIdsAsync(Guid organizationId, IEnumerable<SecurityTask> tasks);
/// <summary>
/// Updates encrypted data for ciphers during a key rotation
/// </summary>

View File

@ -21,6 +21,8 @@ public static class VaultServiceCollectionExtensions
services.AddScoped<IMarkTaskAsCompleteCommand, MarkTaskAsCompletedCommand>();
services.AddScoped<IGetCipherPermissionsForUserQuery, GetCipherPermissionsForUserQuery>();
services.AddScoped<IGetTasksForOrganizationQuery, GetTasksForOrganizationQuery>();
services.AddScoped<IGetSecurityTasksNotificationDetailsQuery, GetSecurityTasksNotificationDetailsQuery>();
services.AddScoped<ICreateManyTaskNotificationsCommand, CreateManyTaskNotificationsCommand>();
services.AddScoped<ICreateManyTasksCommand, CreateManyTasksCommand>();
}
}