mirror of
https://github.com/bitwarden/server.git
synced 2025-06-20 02:48:03 -05:00

* More tests * More tests * Add non-guid tests * Introduce slimmer services * Implement IPushEngine on services * Implement IPushEngine * Fix tests * Format * Switch to `Guid` on `PushSendRequestModel` * Remove TODOs
200 lines
7.5 KiB
C#
200 lines
7.5 KiB
C#
#nullable enable
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Bit.Core.Context;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Models;
|
|
using Bit.Core.Models.Data;
|
|
using Bit.Core.Platform.Push;
|
|
using Bit.Core.Platform.Push.Internal;
|
|
using Bit.Core.Repositories;
|
|
using Bit.Core.Settings;
|
|
using Bit.Core.Vault.Entities;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Bit.Core.NotificationHub;
|
|
|
|
#nullable enable
|
|
|
|
/// <summary>
|
|
/// Sends mobile push notifications to the Azure Notification Hub.
|
|
/// Used by Cloud-Hosted environments.
|
|
/// Received by Firebase for Android or APNS for iOS.
|
|
/// </summary>
|
|
public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer
|
|
{
|
|
private readonly IInstallationDeviceRepository _installationDeviceRepository;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly bool _enableTracing = false;
|
|
private readonly INotificationHubPool _notificationHubPool;
|
|
private readonly ILogger _logger;
|
|
|
|
public NotificationHubPushNotificationService(
|
|
IInstallationDeviceRepository installationDeviceRepository,
|
|
INotificationHubPool notificationHubPool,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
ILogger<NotificationHubPushNotificationService> logger,
|
|
IGlobalSettings globalSettings)
|
|
{
|
|
_installationDeviceRepository = installationDeviceRepository;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_notificationHubPool = notificationHubPool;
|
|
_logger = logger;
|
|
if (globalSettings.Installation.Id == Guid.Empty)
|
|
{
|
|
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
|
|
}
|
|
}
|
|
|
|
public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
|
|
{
|
|
if (cipher.OrganizationId.HasValue)
|
|
{
|
|
// We cannot send org pushes since access logic is much more complicated than just the fact that they belong
|
|
// to the organization. Potentially we could blindly send to just users that have the access all permission
|
|
// device registration needs to be more granular to handle that appropriately. A more brute force approach could
|
|
// me to send "full sync" push to all org users, but that has the potential to DDOS the API in bursts.
|
|
|
|
// await SendPayloadToOrganizationAsync(cipher.OrganizationId.Value, type, message, true);
|
|
}
|
|
else if (cipher.UserId.HasValue)
|
|
{
|
|
var message = new SyncCipherPushNotification
|
|
{
|
|
Id = cipher.Id,
|
|
UserId = cipher.UserId,
|
|
OrganizationId = cipher.OrganizationId,
|
|
RevisionDate = cipher.RevisionDate,
|
|
CollectionIds = collectionIds,
|
|
};
|
|
|
|
await PushAsync(new PushNotification<SyncCipherPushNotification>
|
|
{
|
|
Type = type,
|
|
Target = NotificationTarget.User,
|
|
TargetId = cipher.UserId.Value,
|
|
Payload = message,
|
|
ExcludeCurrentContext = true,
|
|
});
|
|
}
|
|
}
|
|
|
|
private string? GetContextIdentifier(bool excludeCurrentContext)
|
|
{
|
|
if (!excludeCurrentContext)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var currentContext =
|
|
_httpContextAccessor.HttpContext?.RequestServices.GetService(typeof(ICurrentContext)) as ICurrentContext;
|
|
return currentContext?.DeviceIdentifier;
|
|
}
|
|
|
|
private string BuildTag(string tag, string? identifier, ClientType? clientType)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(identifier))
|
|
{
|
|
tag += $" && !deviceIdentifier:{SanitizeTagInput(identifier)}";
|
|
}
|
|
|
|
if (clientType.HasValue && clientType.Value != ClientType.All)
|
|
{
|
|
tag += $" && clientType:{clientType}";
|
|
}
|
|
|
|
return $"({tag})";
|
|
}
|
|
|
|
public async Task PushAsync<T>(PushNotification<T> pushNotification)
|
|
where T : class
|
|
{
|
|
var initialTag = pushNotification.Target switch
|
|
{
|
|
NotificationTarget.User => $"template:payload_userId:{pushNotification.TargetId}",
|
|
NotificationTarget.Organization => $"template:payload && organizationId:{pushNotification.TargetId}",
|
|
NotificationTarget.Installation => $"template:payload && installationId:{pushNotification.TargetId}",
|
|
_ => throw new InvalidOperationException($"Push notification target '{pushNotification.Target}' is not valid."),
|
|
};
|
|
|
|
await PushCoreAsync(
|
|
initialTag,
|
|
GetContextIdentifier(pushNotification.ExcludeCurrentContext),
|
|
pushNotification.Type,
|
|
pushNotification.ClientType,
|
|
pushNotification.Payload
|
|
);
|
|
}
|
|
|
|
public async Task RelayAsync(Guid fromInstallation, RelayedNotification relayedNotification)
|
|
{
|
|
// Relayed notifications need identifiers prefixed with the installation they are from and a underscore
|
|
var initialTag = relayedNotification.Target switch
|
|
{
|
|
NotificationTarget.User => $"template:payload_userId:{fromInstallation}_{relayedNotification.TargetId}",
|
|
NotificationTarget.Organization => $"template:payload && organizationId:{fromInstallation}_{relayedNotification.TargetId}",
|
|
NotificationTarget.Installation => $"template:payload && installationId:{fromInstallation}",
|
|
_ => throw new InvalidOperationException($"Invalid Notification target {relayedNotification.Target}"),
|
|
};
|
|
|
|
await PushCoreAsync(
|
|
initialTag,
|
|
relayedNotification.Identifier,
|
|
relayedNotification.Type,
|
|
relayedNotification.ClientType,
|
|
relayedNotification.Payload
|
|
);
|
|
|
|
if (relayedNotification.DeviceId.HasValue)
|
|
{
|
|
await _installationDeviceRepository.UpsertAsync(
|
|
new InstallationDeviceEntity(fromInstallation, relayedNotification.DeviceId.Value)
|
|
);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"A related notification of type '{Type}' came through without a device id from installation {Installation}",
|
|
relayedNotification.Type,
|
|
fromInstallation
|
|
);
|
|
}
|
|
}
|
|
|
|
private async Task PushCoreAsync<T>(string initialTag, string? contextId, PushType pushType, ClientType? clientType, T payload)
|
|
{
|
|
var finalTag = BuildTag(initialTag, contextId, clientType);
|
|
|
|
var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync(
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "type", ((byte)pushType).ToString() },
|
|
{ "payload", JsonSerializer.Serialize(payload) },
|
|
},
|
|
finalTag
|
|
);
|
|
|
|
if (_enableTracing)
|
|
{
|
|
foreach (var (client, outcome) in results)
|
|
{
|
|
if (!client.EnableTestSend)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Azure Notification Hub Tracking ID: {Id} | {Type} push notification with {Success} successes and {Failure} failures with a payload of {@Payload} and result of {@Results}",
|
|
outcome.TrackingId, pushType, outcome.Success, outcome.Failure, payload, outcome.Results);
|
|
}
|
|
}
|
|
}
|
|
|
|
private string SanitizeTagInput(string input)
|
|
{
|
|
// Only allow a-z, A-Z, 0-9, and special characters -_:
|
|
return Regex.Replace(input, "[^a-zA-Z0-9-_:]", string.Empty);
|
|
}
|
|
}
|