mirror of
https://github.com/bitwarden/server
synced 2026-01-09 03:53:42 +00:00
[PM-19659] Clean up Notifications code (#6244)
* Move PushType to Platform Folder - Move the PushType next to the rest of push notification code - Specifically exclude it from needing Platform code review - Add tests establishing rules Platform has for usage of this enum, making it safe to have no owner * Move NotificationHub code into Platform/Push directory * Update NotificationHub namespace imports * Add attribute for storing push type metadata * Rename Push Engines to have PushEngine suffix * Move Push Registration items to their own directory * Push code move * Add expected usage comment * Add Push feature registration method - Make method able to be called multipes times with no ill effects * Add Push Registration service entrypoint and tests * Use new service entrypoints * Test changes
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Azure.Storage.Queues;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
@@ -13,17 +12,16 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class AzureQueuePushNotificationService : IPushEngine
|
||||
public class AzureQueuePushEngine : IPushEngine
|
||||
{
|
||||
private readonly QueueClient _queueClient;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public AzureQueuePushNotificationService(
|
||||
public AzureQueuePushEngine(
|
||||
[FromKeyedServices("notifications")] QueueClient queueClient,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<AzureQueuePushNotificationService> logger,
|
||||
TimeProvider timeProvider)
|
||||
ILogger<AzureQueuePushEngine> logger)
|
||||
{
|
||||
_queueClient = queueClient;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -8,7 +7,7 @@ namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class MultiServicePushNotificationService : IPushNotificationService
|
||||
{
|
||||
private readonly IEnumerable<IPushEngine> _services;
|
||||
private readonly IPushEngine[] _services;
|
||||
|
||||
public Guid InstallationId { get; }
|
||||
|
||||
@@ -22,7 +21,8 @@ public class MultiServicePushNotificationService : IPushNotificationService
|
||||
GlobalSettings globalSettings,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_services = services;
|
||||
// Filter out any NoopPushEngine's
|
||||
_services = [.. services.Where(engine => engine is not NoopPushEngine)];
|
||||
|
||||
Logger = logger;
|
||||
Logger.LogInformation("Hub services: {Services}", _services.Count());
|
||||
@@ -1,10 +1,9 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
internal class NoopPushNotificationService : IPushEngine
|
||||
internal class NoopPushEngine : IPushEngine
|
||||
{
|
||||
public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds) => Task.CompletedTask;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Services;
|
||||
@@ -8,23 +7,22 @@ using Bit.Core.Vault.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
// This service is not in the `Internal` namespace because it has direct external references.
|
||||
namespace Bit.Core.Platform.Push;
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Sends non-mobile push notifications to the Azure Queue Api, later received by Notifications Api.
|
||||
/// Used by Cloud-Hosted environments.
|
||||
/// Received by AzureQueueHostedService message receiver in Notifications project.
|
||||
/// </summary>
|
||||
public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine
|
||||
public class NotificationsApiPushEngine : BaseIdentityClientService, IPushEngine
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public NotificationsApiPushNotificationService(
|
||||
public NotificationsApiPushEngine(
|
||||
IHttpClientFactory httpFactory,
|
||||
GlobalSettings globalSettings,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<NotificationsApiPushNotificationService> logger)
|
||||
ILogger<NotificationsApiPushEngine> logger)
|
||||
: base(
|
||||
httpFactory,
|
||||
globalSettings.BaseServiceUri.InternalNotifications,
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Models;
|
||||
@@ -19,18 +18,18 @@ namespace Bit.Core.Platform.Push.Internal;
|
||||
/// Used by Self-Hosted environments.
|
||||
/// Received by PushController endpoint in Api project.
|
||||
/// </summary>
|
||||
public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine
|
||||
public class RelayPushEngine : BaseIdentityClientService, IPushEngine
|
||||
{
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
|
||||
public RelayPushNotificationService(
|
||||
public RelayPushEngine(
|
||||
IHttpClientFactory httpFactory,
|
||||
IDeviceRepository deviceRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<RelayPushNotificationService> logger)
|
||||
ILogger<RelayPushEngine> logger)
|
||||
: base(
|
||||
httpFactory,
|
||||
globalSettings.PushRelayBaseUri,
|
||||
@@ -1,8 +1,7 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Core.Platform.Push;
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public interface IPushEngine
|
||||
{
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
@@ -10,10 +9,27 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Used to Push notifications to end-user devices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// New notifications should not be wired up inside this service. You may either directly call the
|
||||
/// <see cref="PushAsync"/> method in your service to send your notification or if you want your notification
|
||||
/// sent by other teams you can make an extension method on this service with a well typed definition
|
||||
/// of your notification. You may also make your own service that injects this and exposes methods for each of
|
||||
/// your notifications.
|
||||
/// </remarks>
|
||||
public interface IPushNotificationService
|
||||
{
|
||||
private const string ServiceDeprecation = "Do not use the services exposed here, instead use your own services injected in your service.";
|
||||
|
||||
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
|
||||
Guid InstallationId { get; }
|
||||
|
||||
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
|
||||
TimeProvider TimeProvider { get; }
|
||||
|
||||
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
|
||||
ILogger Logger { get; }
|
||||
|
||||
#region Legacy method, to be removed soon.
|
||||
@@ -80,7 +96,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -94,7 +112,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -108,7 +128,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -122,7 +144,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -136,7 +160,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -150,7 +176,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = excludeCurrentContextFromPush,
|
||||
});
|
||||
@@ -231,7 +259,9 @@ public interface IPushNotificationService
|
||||
ClientType = notification.ClientType,
|
||||
UserId = notification.UserId,
|
||||
OrganizationId = notification.OrganizationId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
InstallationId = notification.Global ? InstallationId : null,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
TaskId = notification.TaskId,
|
||||
Title = notification.Title,
|
||||
Body = notification.Body,
|
||||
@@ -246,7 +276,9 @@ public interface IPushNotificationService
|
||||
{
|
||||
// TODO: Think about this a bit more
|
||||
target = NotificationTarget.Installation;
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
targetId = InstallationId;
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
}
|
||||
else if (notification.UserId.HasValue)
|
||||
{
|
||||
@@ -260,7 +292,9 @@ public interface IPushNotificationService
|
||||
}
|
||||
else
|
||||
{
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id);
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -285,7 +319,9 @@ public interface IPushNotificationService
|
||||
ClientType = notification.ClientType,
|
||||
UserId = notification.UserId,
|
||||
OrganizationId = notification.OrganizationId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
InstallationId = notification.Global ? InstallationId : null,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
TaskId = notification.TaskId,
|
||||
Title = notification.Title,
|
||||
Body = notification.Body,
|
||||
@@ -302,7 +338,9 @@ public interface IPushNotificationService
|
||||
{
|
||||
// TODO: Think about this a bit more
|
||||
target = NotificationTarget.Installation;
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
targetId = InstallationId;
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
}
|
||||
else if (notification.UserId.HasValue)
|
||||
{
|
||||
@@ -316,7 +354,9 @@ public interface IPushNotificationService
|
||||
}
|
||||
else
|
||||
{
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id);
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -398,7 +438,9 @@ public interface IPushNotificationService
|
||||
Payload = new UserPushNotification
|
||||
{
|
||||
UserId = userId,
|
||||
#pragma warning disable BWP0001 // Type or member is obsolete
|
||||
Date = TimeProvider.GetUtcNow().UtcDateTime,
|
||||
#pragma warning restore BWP0001 // Type or member is obsolete
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
@@ -406,6 +448,12 @@ public interface IPushNotificationService
|
||||
|
||||
Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a notification to devices based on the settings given to us in <see cref="PushNotification{T}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the payload to be sent along with the notification.</typeparam>
|
||||
/// <param name="pushNotification"></param>
|
||||
/// <returns>A task that is NOT guarunteed to have sent the notification by the time the task resolves.</returns>
|
||||
Task PushAsync<T>(PushNotification<T> pushNotification)
|
||||
where T : class;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public interface INotificationHubProxy
|
||||
{
|
||||
Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary<string, string> properties, string tagExpression);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public interface INotificationHubPool
|
||||
{
|
||||
NotificationHubConnection ConnectionFor(Guid comb);
|
||||
INotificationHubClient ClientFor(Guid comb);
|
||||
INotificationHubProxy AllClients { get; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class NotificationHubClientProxy : INotificationHubProxy
|
||||
{
|
||||
private readonly IEnumerable<INotificationHubClient> _clients;
|
||||
|
||||
public NotificationHubClientProxy(IEnumerable<INotificationHubClient> clients)
|
||||
{
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
private async Task<(INotificationHubClient, T)[]> ApplyToAllClientsAsync<T>(Func<INotificationHubClient, Task<T>> action)
|
||||
{
|
||||
var tasks = _clients.Select(async c => (c, await action(c)));
|
||||
return await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
// partial proxy of INotificationHubClient implementation
|
||||
// Note: Any other methods that are needed can simply be delegated as done here.
|
||||
public async Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary<string, string> properties, string tagExpression)
|
||||
{
|
||||
return await ApplyToAllClientsAsync(async c => await c.SendTemplateNotificationAsync(properties, tagExpression));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class NotificationHubConnection
|
||||
{
|
||||
public string? HubName { get; init; }
|
||||
public string? ConnectionString { get; init; }
|
||||
private Lazy<NotificationHubConnectionStringBuilder> _parsedConnectionString;
|
||||
public Uri Endpoint => _parsedConnectionString.Value.Endpoint;
|
||||
private string SasKey => _parsedConnectionString.Value.SharedAccessKey;
|
||||
private string SasKeyName => _parsedConnectionString.Value.SharedAccessKeyName;
|
||||
public bool EnableSendTracing { get; init; }
|
||||
private NotificationHubClient? _hubClient;
|
||||
/// <summary>
|
||||
/// Gets the NotificationHubClient for this connection.
|
||||
///
|
||||
/// If the client is null, it will be initialized.
|
||||
///
|
||||
/// <throws>Exception</throws> if the connection is invalid.
|
||||
/// </summary>
|
||||
public NotificationHubClient HubClient
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hubClient == null)
|
||||
{
|
||||
if (!IsValid)
|
||||
{
|
||||
throw new Exception("Invalid notification hub settings");
|
||||
}
|
||||
Init();
|
||||
}
|
||||
return _hubClient;
|
||||
}
|
||||
private set
|
||||
{
|
||||
_hubClient = value;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets the start date for registration.
|
||||
///
|
||||
/// If null, registration is always disabled.
|
||||
/// </summary>
|
||||
public DateTime? RegistrationStartDate { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the end date for registration.
|
||||
///
|
||||
/// If null, registration has no end date.
|
||||
/// </summary>
|
||||
public DateTime? RegistrationEndDate { get; init; }
|
||||
/// <summary>
|
||||
/// Gets whether all data needed to generate a connection to Notification Hub is present.
|
||||
/// </summary>
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
{
|
||||
var invalid = string.IsNullOrWhiteSpace(HubName) || string.IsNullOrWhiteSpace(ConnectionString);
|
||||
return !invalid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string LogString
|
||||
{
|
||||
get
|
||||
{
|
||||
return $"HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether registration is enabled for the given comb ID.
|
||||
/// This is based off of the generation time encoded in the comb ID.
|
||||
/// </summary>
|
||||
/// <param name="comb"></param>
|
||||
/// <returns></returns>
|
||||
public bool RegistrationEnabled(Guid comb)
|
||||
{
|
||||
var combTime = CoreHelpers.DateFromComb(comb);
|
||||
return RegistrationEnabled(combTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether registration is enabled for the given time.
|
||||
/// </summary>
|
||||
/// <param name="queryTime">The time to check</param>
|
||||
/// <returns></returns>
|
||||
public bool RegistrationEnabled(DateTime queryTime)
|
||||
{
|
||||
if (queryTime >= RegistrationEndDate || RegistrationStartDate == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return RegistrationStartDate < queryTime;
|
||||
}
|
||||
|
||||
public HttpRequestMessage CreateRequest(HttpMethod method, string pathUri, params string[] queryParameters)
|
||||
{
|
||||
var uriBuilder = new UriBuilder(Endpoint)
|
||||
{
|
||||
Scheme = "https",
|
||||
Path = $"{HubName}/{pathUri.TrimStart('/')}",
|
||||
Query = string.Join('&', [.. queryParameters, "api-version=2015-01"]),
|
||||
};
|
||||
|
||||
var result = new HttpRequestMessage(method, uriBuilder.Uri);
|
||||
result.Headers.Add("Authorization", GenerateSasToken(uriBuilder.Uri));
|
||||
result.Headers.Add("TrackingId", Guid.NewGuid().ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private string GenerateSasToken(Uri uri)
|
||||
{
|
||||
string targetUri = Uri.EscapeDataString(uri.ToString().ToLower()).ToLower();
|
||||
long expires = DateTime.UtcNow.AddMinutes(1).Ticks / TimeSpan.TicksPerSecond;
|
||||
string stringToSign = targetUri + "\n" + expires;
|
||||
|
||||
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SasKey)))
|
||||
{
|
||||
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)));
|
||||
return $"SharedAccessSignature sr={targetUri}&sig={HttpUtility.UrlEncode(signature)}&se={expires}&skn={SasKeyName}";
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationHubConnection()
|
||||
{
|
||||
_parsedConnectionString = new(() => new NotificationHubConnectionStringBuilder(ConnectionString));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new NotificationHubConnection from the given settings.
|
||||
/// </summary>
|
||||
/// <param name="settings"></param>
|
||||
/// <returns></returns>
|
||||
public static NotificationHubConnection From(GlobalSettings.NotificationHubSettings settings)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
HubName = settings.HubName,
|
||||
ConnectionString = settings.ConnectionString,
|
||||
EnableSendTracing = settings.EnableSendTracing,
|
||||
// Comb time is not precise enough for millisecond accuracy
|
||||
RegistrationStartDate = settings.RegistrationStartDate.HasValue ? Truncate(settings.RegistrationStartDate.Value, TimeSpan.FromMilliseconds(10)) : null,
|
||||
RegistrationEndDate = settings.RegistrationEndDate
|
||||
};
|
||||
}
|
||||
|
||||
[MemberNotNull(nameof(_hubClient))]
|
||||
private NotificationHubConnection Init()
|
||||
{
|
||||
_hubClient = NotificationHubClient.CreateClientFromConnectionString(ConnectionString, HubName, EnableSendTracing);
|
||||
return this;
|
||||
}
|
||||
|
||||
private static DateTime Truncate(DateTime dateTime, TimeSpan resolution)
|
||||
{
|
||||
return dateTime.AddTicks(-(dateTime.Ticks % resolution.Ticks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class NotificationHubPool : INotificationHubPool
|
||||
{
|
||||
private List<NotificationHubConnection> _connections { get; }
|
||||
private readonly IEnumerable<INotificationHubClient> _clients;
|
||||
private readonly ILogger<NotificationHubPool> _logger;
|
||||
public NotificationHubPool(ILogger<NotificationHubPool> logger, GlobalSettings globalSettings)
|
||||
{
|
||||
_logger = logger;
|
||||
_connections = FilterInvalidHubs(globalSettings.NotificationHubPool.NotificationHubs);
|
||||
_clients = _connections.GroupBy(c => c.ConnectionString).Select(g => g.First().HubClient);
|
||||
}
|
||||
|
||||
private List<NotificationHubConnection> FilterInvalidHubs(IEnumerable<GlobalSettings.NotificationHubSettings> hubs)
|
||||
{
|
||||
List<NotificationHubConnection> result = new();
|
||||
_logger.LogDebug("Filtering {HubCount} notification hubs", hubs.Count());
|
||||
foreach (var hub in hubs)
|
||||
{
|
||||
var connection = NotificationHubConnection.From(hub);
|
||||
if (!connection.IsValid)
|
||||
{
|
||||
_logger.LogWarning("Invalid notification hub settings: {HubName}", hub.HubName ?? "hub name missing");
|
||||
continue;
|
||||
}
|
||||
_logger.LogDebug("Adding notification hub: {ConnectionLogString}", connection.LogString);
|
||||
result.Add(connection);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the NotificationHubClient for the given comb ID.
|
||||
/// </summary>
|
||||
/// <param name="comb"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no notification hub is found for a given comb.</exception>
|
||||
public INotificationHubClient ClientFor(Guid comb)
|
||||
{
|
||||
var resolvedConnection = ConnectionFor(comb);
|
||||
return resolvedConnection.HubClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the NotificationHubConnection for the given comb ID.
|
||||
/// </summary>
|
||||
/// <param name="comb"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no notification hub is found for a given comb.</exception>
|
||||
public NotificationHubConnection ConnectionFor(Guid comb)
|
||||
{
|
||||
var possibleConnections = _connections.Where(c => c.RegistrationEnabled(comb)).ToArray();
|
||||
if (possibleConnections.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No valid notification hubs are available for the given comb ({comb}).\n" +
|
||||
$"The comb's datetime is {CoreHelpers.DateFromComb(comb)}." +
|
||||
$"Hub start and end times are configured as follows:\n" +
|
||||
string.Join("\n", _connections.Select(c => $"Hub {c.HubName} - Start: {c.RegistrationStartDate}, End: {c.RegistrationEndDate}")));
|
||||
}
|
||||
var resolvedConnection = possibleConnections[CoreHelpers.BinForComb(comb, possibleConnections.Length)];
|
||||
_logger.LogTrace("Resolved notification hub for comb {Comb} out of {HubCount} hubs.\n{ConnectionInfo}", comb, possibleConnections.Length, resolvedConnection.LogString);
|
||||
return resolvedConnection;
|
||||
|
||||
}
|
||||
|
||||
public INotificationHubProxy AllClients { get { return new NotificationHubClientProxy(_clients); } }
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
/// <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 NotificationHubPushEngine : IPushEngine, IPushRelayer
|
||||
{
|
||||
private readonly IInstallationDeviceRepository _installationDeviceRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly bool _enableTracing = false;
|
||||
private readonly INotificationHubPool _notificationHubPool;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public NotificationHubPushEngine(
|
||||
IInstallationDeviceRepository installationDeviceRepository,
|
||||
INotificationHubPool notificationHubPool,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<NotificationHubPushEngine> 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);
|
||||
}
|
||||
}
|
||||
44
src/Core/Platform/Push/NotificationInfoAttribute.cs
Normal file
44
src/Core/Platform/Push/NotificationInfoAttribute.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Platform.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Used to annotate information about a given <see cref="PushType"/>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public class NotificationInfoAttribute : Attribute
|
||||
{
|
||||
// Once upon a time we can feed this information into a C# analyzer to make sure that we validate
|
||||
// the callsites of IPushNotificationService.PushAsync uses the correct payload type for the notification type
|
||||
// for now this only exists as forced documentation to teams who create a push type.
|
||||
|
||||
// It's especially on purpose that we allow ourselves to take a type name via just the string,
|
||||
// this allows teams to make a push type that is only sent with a payload that exists in a separate assembly than
|
||||
// this one.
|
||||
|
||||
public NotificationInfoAttribute(string team, Type payloadType)
|
||||
// It should be impossible to reference an unnamed type for an attributes constructor so this assertion should be safe.
|
||||
: this(team, payloadType.FullName!)
|
||||
{
|
||||
Team = team;
|
||||
}
|
||||
|
||||
public NotificationInfoAttribute(string team, string payloadTypeName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(team);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(payloadTypeName);
|
||||
|
||||
Team = team;
|
||||
PayloadTypeName = payloadTypeName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name of the team that owns this <see cref="PushType"/>.
|
||||
/// </summary>
|
||||
public string Team { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully qualified type name of the payload that should be used when sending a notification of this type.
|
||||
/// </summary>
|
||||
public string PayloadTypeName { get; }
|
||||
}
|
||||
@@ -6,6 +6,9 @@ namespace Bit.Core.Platform.Push;
|
||||
/// <summary>
|
||||
/// Contains constants for all the available targets for a given notification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Please reach out to the Platform team if you need a new target added.
|
||||
/// </remarks>
|
||||
public enum NotificationTarget
|
||||
{
|
||||
/// <summary>
|
||||
82
src/Core/Platform/Push/PushServiceCollectionExtensions.cs
Normal file
82
src/Core/Platform/Push/PushServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Azure.Storage.Queues;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding the Push feature.
|
||||
/// </summary>
|
||||
public static class PushServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a <see cref="IPushNotificationService"/> to the services that can be used to send push notifications to
|
||||
/// end user devices. This method is safe to be ran multiple time provided <see cref="GlobalSettings"/> does not
|
||||
/// change between calls.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="globalSettings">The <see cref="GlobalSettings"/> to use to configure services.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
|
||||
public static IServiceCollection AddPush(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(globalSettings);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IPushNotificationService, MultiServicePushNotificationService>();
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
if (globalSettings.Installation.Id == Guid.Empty)
|
||||
{
|
||||
throw new InvalidOperationException("Installation Id must be set for self-hosted installations.");
|
||||
}
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
|
||||
{
|
||||
// TODO: We should really define the HttpClient we will use here
|
||||
services.AddHttpClient();
|
||||
services.AddHttpContextAccessor();
|
||||
// We also depend on IDeviceRepository but don't explicitly add it right now.
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, RelayPushEngine>());
|
||||
}
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications))
|
||||
{
|
||||
// TODO: We should really define the HttpClient we will use here
|
||||
services.AddHttpClient();
|
||||
services.AddHttpContextAccessor();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationsApiPushEngine>());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<INotificationHubPool, NotificationHubPool>();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// We also depend on IInstallationDeviceRepository but don't explicitly add it right now.
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationHubPushEngine>());
|
||||
|
||||
services.TryAddSingleton<IPushRelayer, NotificationHubPushEngine>();
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString))
|
||||
{
|
||||
services.TryAddKeyedSingleton("notifications", static (sp, _) =>
|
||||
{
|
||||
var gs = sp.GetRequiredService<GlobalSettings>();
|
||||
return new QueueClient(gs.Notifications.ConnectionString, "notifications");
|
||||
});
|
||||
|
||||
// We not IHttpContextAccessor will be added above, no need to do it here.
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, AzureQueuePushEngine>());
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
93
src/Core/Platform/Push/PushType.cs
Normal file
93
src/Core/Platform/Push/PushType.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Bit.Core.Platform.Push;
|
||||
|
||||
// TODO: This namespace should change to `Bit.Core.Platform.Push`
|
||||
namespace Bit.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
|
||||
/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced
|
||||
/// in <see cref="NotificationInfoAttribute"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// You may and are
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum PushType : byte
|
||||
{
|
||||
// When adding a new enum member you must annotate it with a NotificationInfoAttribute this is enforced with a unit
|
||||
// test. It is preferred that you do NOT add new usings for the type referenced for the payload. You are also
|
||||
// encouraged to define the payload type in your own teams owned code.
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
|
||||
SyncCipherUpdate = 0,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
|
||||
SyncCipherCreate = 1,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
|
||||
SyncLoginDelete = 2,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
|
||||
SyncFolderDelete = 3,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
|
||||
SyncCiphers = 4,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
|
||||
SyncVault = 5,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.UserPushNotification))]
|
||||
SyncOrgKeys = 6,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
|
||||
SyncFolderCreate = 7,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
|
||||
SyncFolderUpdate = 8,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
|
||||
SyncCipherDelete = 9,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
|
||||
SyncSettings = 10,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
|
||||
LogOut = 11,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
|
||||
SyncSendCreate = 12,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
|
||||
SyncSendUpdate = 13,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
|
||||
SyncSendDelete = 14,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))]
|
||||
AuthRequest = 15,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))]
|
||||
AuthRequestResponse = 16,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
|
||||
SyncOrganizations = 17,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.OrganizationStatusPushNotification))]
|
||||
SyncOrganizationStatusChanged = 18,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.OrganizationCollectionManagementPushNotification))]
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))]
|
||||
Notification = 20,
|
||||
|
||||
[NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))]
|
||||
NotificationStatus = 21,
|
||||
|
||||
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
|
||||
RefreshSecurityTasks = 22,
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationHub;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.PushRegistration;
|
||||
|
||||
// TODO: Change this namespace to `Bit.Core.Platform.PushRegistration
|
||||
namespace Bit.Core.Platform.Push;
|
||||
|
||||
|
||||
public interface IPushRegistrationService
|
||||
{
|
||||
Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string identifier, DeviceType type, IEnumerable<string> organizationIds, Guid installationId);
|
||||
@@ -1,9 +1,7 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationHub;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
namespace Bit.Core.Platform.PushRegistration.Internal;
|
||||
|
||||
public class NoopPushRegistrationService : IPushRegistrationService
|
||||
{
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.NotificationHubs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.PushRegistration.Internal;
|
||||
|
||||
public class NotificationHubPushRegistrationService : IPushRegistrationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions webPushSerializationOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
private readonly IInstallationDeviceRepository _installationDeviceRepository;
|
||||
private readonly INotificationHubPool _notificationHubPool;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<NotificationHubPushRegistrationService> _logger;
|
||||
|
||||
public NotificationHubPushRegistrationService(
|
||||
IInstallationDeviceRepository installationDeviceRepository,
|
||||
INotificationHubPool notificationHubPool,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<NotificationHubPushRegistrationService> logger)
|
||||
{
|
||||
_installationDeviceRepository = installationDeviceRepository;
|
||||
_notificationHubPool = notificationHubPool;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId,
|
||||
string? identifier, DeviceType type, IEnumerable<string> organizationIds, Guid installationId)
|
||||
{
|
||||
var orgIds = organizationIds.ToList();
|
||||
var clientType = DeviceTypes.ToClientType(type);
|
||||
var installation = new Installation
|
||||
{
|
||||
InstallationId = deviceId,
|
||||
PushChannel = data.Token,
|
||||
Tags = new List<string>
|
||||
{
|
||||
$"userId:{userId}",
|
||||
$"clientType:{clientType}"
|
||||
}.Concat(orgIds.Select(organizationId => $"organizationId:{organizationId}")).ToList(),
|
||||
Templates = new Dictionary<string, InstallationTemplate>()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
installation.Tags.Add("deviceIdentifier:" + identifier);
|
||||
}
|
||||
|
||||
if (installationId != Guid.Empty)
|
||||
{
|
||||
installation.Tags.Add($"installationId:{installationId}");
|
||||
}
|
||||
|
||||
if (data.Token != null)
|
||||
{
|
||||
await CreateOrUpdateMobileRegistrationAsync(installation, userId, identifier, clientType, orgIds, type, installationId);
|
||||
}
|
||||
else if (data.WebPush != null)
|
||||
{
|
||||
await CreateOrUpdateWebRegistrationAsync(data.WebPush.Value.Endpoint, data.WebPush.Value.P256dh, data.WebPush.Value.Auth, installation, userId, identifier, clientType, orgIds, installationId);
|
||||
}
|
||||
|
||||
if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
|
||||
{
|
||||
await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateOrUpdateMobileRegistrationAsync(Installation installation, string userId,
|
||||
string? identifier, ClientType clientType, List<string> organizationIds, DeviceType type, Guid installationId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(installation.PushChannel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case DeviceType.Android:
|
||||
installation.Templates.Add(BuildInstallationTemplate("payload",
|
||||
"{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("message",
|
||||
"{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
|
||||
"{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
|
||||
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Platform = NotificationPlatform.FcmV1;
|
||||
break;
|
||||
case DeviceType.iOS:
|
||||
installation.Templates.Add(BuildInstallationTemplate("payload",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
|
||||
"\"aps\":{\"content-available\":1}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("message",
|
||||
"{\"data\":{\"type\":\"#(type)\"}," +
|
||||
"\"aps\":{\"alert\":\"$(message)\",\"badge\":null,\"content-available\":1}}", userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
|
||||
"{\"data\":{\"type\":\"#(type)\"}," +
|
||||
"\"aps\":{\"alert\":\"$(message)\",\"badge\":\"#(badge)\",\"content-available\":1}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Platform = NotificationPlatform.Apns;
|
||||
break;
|
||||
case DeviceType.AndroidAmazon:
|
||||
installation.Templates.Add(BuildInstallationTemplate("payload",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("message",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
|
||||
installation.Platform = NotificationPlatform.Adm;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
await ClientFor(GetComb(installation.InstallationId)).CreateOrUpdateInstallationAsync(installation);
|
||||
}
|
||||
|
||||
private async Task CreateOrUpdateWebRegistrationAsync(string endpoint, string p256dh, string auth, Installation installation, string userId,
|
||||
string? identifier, ClientType clientType, List<string> organizationIds, Guid installationId)
|
||||
{
|
||||
// The Azure SDK is currently lacking support for web push registrations.
|
||||
// We need to use the REST API directly.
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(p256dh) || string.IsNullOrWhiteSpace(auth))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
installation.Templates.Add(BuildInstallationTemplate("payload",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("message",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
|
||||
"{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
|
||||
userId, identifier, clientType, organizationIds, installationId));
|
||||
|
||||
var content = new
|
||||
{
|
||||
installationId = installation.InstallationId,
|
||||
pushChannel = new
|
||||
{
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth
|
||||
},
|
||||
platform = "browser",
|
||||
tags = installation.Tags,
|
||||
templates = installation.Templates
|
||||
};
|
||||
|
||||
var client = _httpClientFactory.CreateClient("NotificationHub");
|
||||
var request = ConnectionFor(GetComb(installation.InstallationId)).CreateRequest(HttpMethod.Put, $"installations/{installation.InstallationId}");
|
||||
request.Content = JsonContent.Create(content, new MediaTypeHeaderValue("application/json"), webPushSerializationOptions);
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Web push registration failed: {Response}", body);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Web push registration success: {Response}", body);
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, InstallationTemplate> BuildInstallationTemplate(string templateId, [StringSyntax(StringSyntaxAttribute.Json)] string templateBody,
|
||||
string userId, string? identifier, ClientType clientType, List<string> organizationIds, Guid installationId)
|
||||
{
|
||||
var fullTemplateId = $"template:{templateId}";
|
||||
|
||||
var template = new InstallationTemplate
|
||||
{
|
||||
Body = templateBody,
|
||||
Tags = new List<string>
|
||||
{
|
||||
fullTemplateId, $"{fullTemplateId}_userId:{userId}", $"clientType:{clientType}"
|
||||
}
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
template.Tags.Add($"{fullTemplateId}_deviceIdentifier:{identifier}");
|
||||
}
|
||||
|
||||
foreach (var organizationId in organizationIds)
|
||||
{
|
||||
template.Tags.Add($"organizationId:{organizationId}");
|
||||
}
|
||||
|
||||
if (installationId != Guid.Empty)
|
||||
{
|
||||
template.Tags.Add($"installationId:{installationId}");
|
||||
}
|
||||
|
||||
return new KeyValuePair<string, InstallationTemplate>(fullTemplateId, template);
|
||||
}
|
||||
|
||||
public async Task DeleteRegistrationAsync(string deviceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ClientFor(GetComb(deviceId)).DeleteInstallationAsync(deviceId);
|
||||
if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
|
||||
{
|
||||
await _installationDeviceRepository.DeleteAsync(new InstallationDeviceEntity(deviceId));
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found"))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddUserRegistrationOrganizationAsync(IEnumerable<string> deviceIds, string organizationId)
|
||||
{
|
||||
await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Add, $"organizationId:{organizationId}");
|
||||
if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First()))
|
||||
{
|
||||
var entities = deviceIds.Select(e => new InstallationDeviceEntity(e));
|
||||
await _installationDeviceRepository.UpsertManyAsync(entities.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable<string> deviceIds, string organizationId)
|
||||
{
|
||||
await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Remove,
|
||||
$"organizationId:{organizationId}");
|
||||
if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First()))
|
||||
{
|
||||
var entities = deviceIds.Select(e => new InstallationDeviceEntity(e));
|
||||
await _installationDeviceRepository.UpsertManyAsync(entities.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PatchTagsForUserDevicesAsync(IEnumerable<string> deviceIds, UpdateOperationType op,
|
||||
string tag)
|
||||
{
|
||||
if (!deviceIds.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var operation = new PartialUpdateOperation
|
||||
{
|
||||
Operation = op,
|
||||
Path = "/tags"
|
||||
};
|
||||
|
||||
if (op == UpdateOperationType.Add)
|
||||
{
|
||||
operation.Value = tag;
|
||||
}
|
||||
else if (op == UpdateOperationType.Remove)
|
||||
{
|
||||
operation.Path += $"/{tag}";
|
||||
}
|
||||
|
||||
foreach (var deviceId in deviceIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ClientFor(GetComb(deviceId)).PatchInstallationAsync(deviceId, new List<PartialUpdateOperation> { operation });
|
||||
}
|
||||
catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found"))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private INotificationHubClient ClientFor(Guid deviceId)
|
||||
{
|
||||
return _notificationHubPool.ClientFor(deviceId);
|
||||
}
|
||||
|
||||
private NotificationHubConnection ConnectionFor(Guid deviceId)
|
||||
{
|
||||
return _notificationHubPool.ConnectionFor(deviceId);
|
||||
}
|
||||
|
||||
private Guid GetComb(string deviceId)
|
||||
{
|
||||
var deviceIdString = deviceId;
|
||||
InstallationDeviceEntity installationDeviceEntity;
|
||||
Guid deviceIdGuid;
|
||||
if (InstallationDeviceEntity.TryParse(deviceIdString, out installationDeviceEntity))
|
||||
{
|
||||
// Strip off the installation id (PartitionId). RowKey is the ID in the Installation's table.
|
||||
deviceIdString = installationDeviceEntity.RowKey;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(deviceIdString, out deviceIdGuid))
|
||||
{
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Invalid device id {deviceId}.");
|
||||
}
|
||||
return deviceIdGuid;
|
||||
}
|
||||
}
|
||||
31
src/Core/Platform/PushRegistration/PushRegistrationData.cs
Normal file
31
src/Core/Platform/PushRegistration/PushRegistrationData.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Bit.Core.Platform.PushRegistration;
|
||||
|
||||
public record struct WebPushRegistrationData
|
||||
{
|
||||
public string Endpoint { get; init; }
|
||||
public string P256dh { get; init; }
|
||||
public string Auth { get; init; }
|
||||
}
|
||||
|
||||
public record class PushRegistrationData
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
public WebPushRegistrationData? WebPush { get; set; }
|
||||
public PushRegistrationData(string? token)
|
||||
{
|
||||
Token = token;
|
||||
}
|
||||
|
||||
public PushRegistrationData(string Endpoint, string P256dh, string Auth) : this(new WebPushRegistrationData
|
||||
{
|
||||
Endpoint = Endpoint,
|
||||
P256dh = P256dh,
|
||||
Auth = Auth
|
||||
})
|
||||
{ }
|
||||
|
||||
public PushRegistrationData(WebPushRegistrationData webPush)
|
||||
{
|
||||
WebPush = webPush;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Platform.PushRegistration.Internal;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding the Push Registration feature.
|
||||
/// </summary>
|
||||
public static class PushRegistrationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a <see cref="IPushRegistrationService"/> to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddPushRegistration(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// TODO: Should add feature that brings in IInstallationDeviceRepository once that is featurized
|
||||
|
||||
// Register all possible variants under there concrete type.
|
||||
services.TryAddSingleton<RelayPushRegistrationService>();
|
||||
services.TryAddSingleton<NoopPushRegistrationService>();
|
||||
|
||||
services.AddHttpClient();
|
||||
services.TryAddSingleton<INotificationHubPool, NotificationHubPool>();
|
||||
services.TryAddSingleton<NotificationHubPushRegistrationService>();
|
||||
|
||||
services.TryAddSingleton<IPushRegistrationService>(static sp =>
|
||||
{
|
||||
var globalSettings = sp.GetRequiredService<GlobalSettings>();
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
|
||||
{
|
||||
return sp.GetRequiredService<RelayPushRegistrationService>();
|
||||
}
|
||||
|
||||
return sp.GetRequiredService<NoopPushRegistrationService>();
|
||||
}
|
||||
|
||||
return sp.GetRequiredService<NotificationHubPushRegistrationService>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.NotificationHub;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
namespace Bit.Core.Platform.PushRegistration.Internal;
|
||||
|
||||
public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService
|
||||
{
|
||||
Reference in New Issue
Block a user