diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 16d0e12d3d..a154ecab7b 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -127,6 +127,7 @@ namespace Bit.Api services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); // Cors services.AddCors(config => diff --git a/src/Api/settings.json b/src/Api/settings.json index af8f7fdb1c..4e98ca0c12 100644 --- a/src/Api/settings.json +++ b/src/Api/settings.json @@ -1,22 +1,29 @@ { - "globalSettings": { - "siteName": "bitwarden", - "baseVaultUri": "http://localhost:4001", - "jwtSigningKey": "THIS IS A SECRET. IT KEEPS YOUR TOKEN SAFE. :)", - "sqlServer": { - "connectionString": "SECRET" - }, - "mail": { - "apiKey": "SECRET", - "replyToEmail": "do-not-reply@bitwarden.com" - }, - "loggr": { - "logKey": "SECRET", - "apiKey": "SECRET" - }, - "cache": { - "connectionString": "SECRET.COM:6380,password=SECRET,ssl=True,abortConnect=False", - "database": 0 + "globalSettings": { + "siteName": "bitwarden", + "baseVaultUri": "http://localhost:4001", + "jwtSigningKey": "THIS IS A SECRET. IT KEEPS YOUR TOKEN SAFE. :)", + "sqlServer": { + "connectionString": "SECRET" + }, + "mail": { + "apiKey": "SECRET", + "replyToEmail": "do-not-reply@bitwarden.com" + }, + "loggr": { + "logKey": "SECRET", + "apiKey": "SECRET" + }, + "cache": { + "connectionString": "SECRET.COM:6380,password=SECRET,ssl=True,abortConnect=False", + "database": 0 + }, + "push": { + "ApnsCertificateThumbprint": "SECRET", + "ApnsCertificatePassword": "SECRET", + "GcmSenderId": "SECRET", + "GcmApiKey": "SECRET", + "GcmAppPackageName": "com.bitwarden.vault" + } } - } } diff --git a/src/Core/GlobalSettings.cs b/src/Core/GlobalSettings.cs index 1785e0f0e2..23a20bb6ce 100644 --- a/src/Core/GlobalSettings.cs +++ b/src/Core/GlobalSettings.cs @@ -1,8 +1,4 @@ -using StackExchange.Redis.Extensions.Core.Configuration; -using System; -using System.Collections.Generic; - -namespace Bit.Core +namespace Bit.Core { public class GlobalSettings { @@ -13,6 +9,7 @@ namespace Bit.Core public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual LoggrSettings Loggr { get; set; } = new LoggrSettings(); public virtual CacheSettings Cache { get; set; } = new CacheSettings(); + public virtual PushSettings Push { get; set; } = new PushSettings(); public class SqlServerSettings { @@ -36,5 +33,14 @@ namespace Bit.Core public string ConnectionString { get; set; } public int Database { get; set; } } + + public class PushSettings + { + public string ApnsCertificateThumbprint { get; set; } + public string ApnsCertificatePassword { get; set; } + public string GcmSenderId { get; set; } + public string GcmApiKey { get; set; } + public string GcmAppPackageName { get; set; } + } } } diff --git a/src/Core/Services/IPushService.cs b/src/Core/Services/IPushService.cs new file mode 100644 index 0000000000..2226afa45b --- /dev/null +++ b/src/Core/Services/IPushService.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Services +{ + public interface IPushService + { + + } +} diff --git a/src/Core/Services/PushService.cs b/src/Core/Services/PushService.cs new file mode 100644 index 0000000000..f2f0090e56 --- /dev/null +++ b/src/Core/Services/PushService.cs @@ -0,0 +1,226 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Repositories; +using Newtonsoft.Json.Linq; +using PushSharp.Google; +using PushSharp.Apple; +using Microsoft.AspNetCore.Hosting; +using PushSharp.Core; +using System.Security.Cryptography.X509Certificates; + +namespace Bit.Core.Services +{ + public class PushService : IPushService + { + private readonly IDeviceRepository _deviceRepository; + private GcmServiceBroker _gcmBroker; + private ApnsServiceBroker _apnsBroker; + + public PushService( + IDeviceRepository deviceRepository, + IHostingEnvironment hostingEnvironment, + GlobalSettings globalSettings) + { + _deviceRepository = deviceRepository; + + InitGcmBroker(globalSettings); + InitApnsBroker(globalSettings, hostingEnvironment); + } + + private void InitGcmBroker(GlobalSettings globalSettings) + { + if(string.IsNullOrWhiteSpace(globalSettings.Push.GcmSenderId) || string.IsNullOrWhiteSpace(globalSettings.Push.GcmApiKey) + || string.IsNullOrWhiteSpace(globalSettings.Push.GcmAppPackageName)) + { + return; + } + + var gcmConfig = new GcmConfiguration(globalSettings.Push.GcmSenderId, globalSettings.Push.GcmApiKey, + globalSettings.Push.GcmAppPackageName); + + _gcmBroker = new GcmServiceBroker(gcmConfig); + _gcmBroker.OnNotificationFailed += GcmBroker_OnNotificationFailed; + _gcmBroker.OnNotificationSucceeded += (notification) => + { + Console.WriteLine("GCM Notification Sent!"); + }; + _gcmBroker.Start(); + } + + private void GcmBroker_OnNotificationFailed(GcmNotification notification, AggregateException exception) + { + exception.Handle(ex => + { + // See what kind of exception it was to further diagnose + if(ex is GcmNotificationException) + { + var notificationException = ex as GcmNotificationException; + + // Deal with the failed notification + var gcmNotification = notificationException.Notification; + var description = notificationException.Description; + + Console.WriteLine($"GCM Notification Failed: ID={gcmNotification.MessageId}, Desc={description}"); + } + else if(ex is GcmMulticastResultException) + { + var multicastException = ex as GcmMulticastResultException; + + foreach(var succeededNotification in multicastException.Succeeded) + { + Console.WriteLine($"GCM Notification Failed: ID={succeededNotification.MessageId}"); + } + + foreach(var failedKvp in multicastException.Failed) + { + var n = failedKvp.Key; + var e = failedKvp.Value; + + Console.WriteLine($"GCM Notification Failed: ID={n.MessageId}, Desc={e.Message}"); + } + + } + else if(ex is DeviceSubscriptionExpiredException) + { + var expiredException = ex as DeviceSubscriptionExpiredException; + + var oldId = expiredException.OldSubscriptionId; + var newId = expiredException.NewSubscriptionId; + + Console.WriteLine($"Device RegistrationId Expired: {oldId}"); + + if(!string.IsNullOrWhiteSpace(newId)) + { + // If this value isn't null, our subscription changed and we should update our database + Console.WriteLine($"Device RegistrationId Changed To: {newId}"); + } + } + else if(ex is RetryAfterException) + { + var retryException = (RetryAfterException)ex; + // If you get rate limited, you should stop sending messages until after the RetryAfterUtc date + Console.WriteLine($"GCM Rate Limited, don't send more until after {retryException.RetryAfterUtc}"); + } + else + { + Console.WriteLine("GCM Notification Failed for some unknown reason"); + } + + // Mark it as handled + return true; + }); + } + + private void InitApnsBroker(GlobalSettings globalSettings, IHostingEnvironment hostingEnvironment) + { + if(string.IsNullOrWhiteSpace(globalSettings.Push.ApnsCertificatePassword) + || string.IsNullOrWhiteSpace(globalSettings.Push.ApnsCertificateThumbprint)) + { + return; + } + + var apnsCertificate = GetCertificate(globalSettings.Push.ApnsCertificateThumbprint); + if(apnsCertificate == null) + { + return; + } + + var apnsConfig = new ApnsConfiguration(hostingEnvironment.IsProduction() ? + ApnsConfiguration.ApnsServerEnvironment.Production : ApnsConfiguration.ApnsServerEnvironment.Sandbox, + apnsCertificate.RawData, globalSettings.Push.ApnsCertificatePassword); + + _apnsBroker = new ApnsServiceBroker(apnsConfig); + _apnsBroker.OnNotificationFailed += ApnsBroker_OnNotificationFailed; + _apnsBroker.OnNotificationSucceeded += (notification) => + { + Console.WriteLine("Apple Notification Sent!"); + }; + _apnsBroker.Start(); + + var feedbackService = new FeedbackService(apnsConfig); + feedbackService.FeedbackReceived += FeedbackService_FeedbackReceived; + feedbackService.Check(); + } + + private void ApnsBroker_OnNotificationFailed(ApnsNotification notification, AggregateException exception) + { + exception.Handle(ex => + { + // See what kind of exception it was to further diagnose + if(ex is ApnsNotificationException) + { + var notificationException = ex as ApnsNotificationException; + + // Deal with the failed notification + var apnsNotification = notificationException.Notification; + var statusCode = notificationException.ErrorStatusCode; + + Console.WriteLine($"Apple Notification Failed: ID={apnsNotification.Identifier}, Code={statusCode}"); + } + else + { + // Inner exception might hold more useful information like an ApnsConnectionException + Console.WriteLine($"Apple Notification Failed for some unknown reason : {ex.InnerException}"); + } + + // Mark it as handled + return true; + }); + } + + private X509Certificate2 GetCertificate(string thumbprint) + { + X509Certificate2 cert = null; + var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); + certStore.Open(OpenFlags.ReadOnly); + var certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if(certCollection.Count > 0) + { + cert = certCollection[0]; + } + certStore.Close(); + return cert; + } + + private void FeedbackService_FeedbackReceived(string deviceToken, DateTime timestamp) + { + // Remove the deviceToken from your database + // timestamp is the time the token was reported as expired + } + + private async Task PushToAllUserDevicesAsync(Guid userId, JObject message) + { + var devices = (await _deviceRepository.GetManyByUserIdAsync(userId)).Where(d => d.PushToken != null); + if(devices.Count() == 0) + { + return; + } + + if(_apnsBroker != null) + { + // Send to each iOS device + foreach(var device in devices.Where(d => d.Type == Enums.DeviceType.iOS && d.PushToken != null)) + { + _apnsBroker.QueueNotification(new ApnsNotification + { + DeviceToken = device.PushToken, + Payload = message + }); + } + } + + // Android can send to many devices at once + if(_gcmBroker != null && devices.Any(d => d.Type == Enums.DeviceType.Android)) + { + _gcmBroker.QueueNotification(new GcmNotification + { + RegistrationIds = devices.Where(d => d.Type == Enums.DeviceType.Android && d.PushToken != null) + .Select(d => d.PushToken).ToList(), + Data = message + }); + } + } + } +} diff --git a/src/Core/project.json b/src/Core/project.json index ea0323c227..e42c90251d 100644 --- a/src/Core/project.json +++ b/src/Core/project.json @@ -12,7 +12,8 @@ "DataTableProxy": "1.2.0", "Sendgrid": "6.3.4", "StackExchange.Redis": "1.0.488", - "StackExchange.Redis.Extensions.Protobuf": "1.3.5" + "StackExchange.Redis.Extensions.Protobuf": "1.3.5", + "PushSharp": "4.0.10" }, "frameworks": {