From 0f1af2333e9e2af5aecd1b290130f3925a7e280d Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 16 Dec 2020 20:36:47 +0100 Subject: [PATCH] Add support for Emergency Access (#1000) * Add support for Emergency Access * Add migration script * Review comments * Ensure grantor has premium when inviting new grantees. * Resolve review comments * Remove two factor references --- .../Controllers/EmergencyAccessController.cs | 157 ++++++ .../Jobs/EmergencyAccessNotificationJob.cs | 27 + src/Api/Jobs/EmergencyAccessTimeoutJob.cs | 27 + src/Api/Jobs/JobsHostedService.cs | 12 + src/Api/Startup.cs | 2 + src/Core/Enums/EmergencyAccessStatusType.cs | 11 + src/Core/Enums/EmergencyAccessType.cs | 8 + .../EmergencyAccessAccepted.html.hbs | 19 + .../EmergencyAccessAccepted.text.hbs | 7 + .../EmergencyAccessApproved.html.hbs | 9 + .../EmergencyAccessApproved.text.hbs | 3 + .../EmergencyAccessConfirmed.html.hbs | 14 + .../EmergencyAccessConfirmed.text.hbs | 5 + .../EmergencyAccessInvited.html.hbs | 21 + .../EmergencyAccessInvited.text.hbs | 7 + .../EmergencyAccessRecovery.html.hbs | 14 + .../EmergencyAccessRecovery.text.hbs | 5 + .../EmergencyAccessRecoveryReminder.html.hbs | 14 + .../EmergencyAccessRecoveryReminder.text.hbs | 5 + .../EmergencyAccessRecoveryTimedOut.html.hbs | 9 + .../EmergencyAccessRecoveryTimedOut.text.hbs | 3 + .../EmergencyAccessRejected.html.hbs | 9 + .../EmergencyAccessRejected.text.hbs | 3 + .../Request/EmergencyAccessRequstModels.cs | 47 ++ .../Response/EmergencyAccessResponseModel.cs | 119 +++++ .../Models/Data/EmergencyAccessDetails.cs | 12 + src/Core/Models/Data/EmergencyAccessNotify.cs | 14 + .../Mail/EmergencyAccessAcceptedViewModel.cs | 7 + .../Mail/EmergencyAccessApprovedViewModel.cs | 7 + .../Mail/EmergencyAccessConfirmedViewModel.cs | 7 + .../Mail/EmergencyAccessInvitedViewModel.cs | 11 + ...mergencyAccessRecoveryTimedOutViewModel.cs | 8 + .../Mail/EmergencyAccessRecoveryViewModel.cs | 9 + .../Mail/EmergencyAccessRejectedViewModel.cs | 7 + src/Core/Models/Table/EmergencyAccess.cs | 46 ++ .../IEmergencyAccessRepository.cs | 18 + .../SqlServer/EmergencyAccessRepository.cs | 99 ++++ src/Core/Services/IEmergencyAccessService.cs | 28 ++ src/Core/Services/IMailService.cs | 8 + .../Implementations/EmergencyAccessService.cs | 314 ++++++++++++ .../Implementations/HandlebarsMailService.cs | 116 +++++ .../NoopImplementations/NoopMailService.cs | 40 ++ src/Core/Utilities/CoreHelpers.cs | 10 +- .../Utilities/ServiceCollectionExtensions.cs | 2 + src/Sql/Sql.sqlproj | 14 +- ...EmergencyAccessDetails_ReadByGranteeId.sql | 13 + ...EmergencyAccessDetails_ReadByGrantorId.sql | 13 + ...ergencyAccessDetails_ReadByIdGrantorId.sql | 16 + ...ncyAccessDetails_ReadExpiredRecoveries.sql | 14 + .../EmergencyAccess_Create.sql | 48 ++ .../EmergencyAccess_DeleteById.sql | 14 + .../EmergencyAccess_ReadById.sql | 13 + ...rgencyAccess_ReadCountByGrantorIdEmail.sql | 21 + .../EmergencyAccess_ReadToNotify.sql | 22 + .../EmergencyAccess_Update.sql | 36 ++ ...RevisionDateByEmergencyAccessGranteeId.sql | 18 + .../dbo/Stored Procedures/User_DeleteById.sql | 9 + src/Sql/dbo/Tables/EmergencyAccess.sql | 18 + .../dbo/Views/EmergencyAccessDetailsView.sql | 14 + .../2020-11-18_00_EmergencyAccess.sql | 473 ++++++++++++++++++ 60 files changed, 2073 insertions(+), 3 deletions(-) create mode 100644 src/Api/Controllers/EmergencyAccessController.cs create mode 100644 src/Api/Jobs/EmergencyAccessNotificationJob.cs create mode 100644 src/Api/Jobs/EmergencyAccessTimeoutJob.cs create mode 100644 src/Core/Enums/EmergencyAccessStatusType.cs create mode 100644 src/Core/Enums/EmergencyAccessType.cs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.text.hbs create mode 100644 src/Core/Models/Api/Request/EmergencyAccessRequstModels.cs create mode 100644 src/Core/Models/Api/Response/EmergencyAccessResponseModel.cs create mode 100644 src/Core/Models/Data/EmergencyAccessDetails.cs create mode 100644 src/Core/Models/Data/EmergencyAccessNotify.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessAcceptedViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessApprovedViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessConfirmedViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessInvitedViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessRecoveryViewModel.cs create mode 100644 src/Core/Models/Mail/EmergencyAccessRejectedViewModel.cs create mode 100644 src/Core/Models/Table/EmergencyAccess.cs create mode 100644 src/Core/Repositories/IEmergencyAccessRepository.cs create mode 100644 src/Core/Repositories/SqlServer/EmergencyAccessRepository.cs create mode 100644 src/Core/Services/IEmergencyAccessService.cs create mode 100644 src/Core/Services/Implementations/EmergencyAccessService.cs create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGranteeId.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGrantorId.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByIdGrantorId.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadExpiredRecoveries.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_Create.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_DeleteById.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadById.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadCountByGrantorIdEmail.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadToNotify.sql create mode 100644 src/Sql/dbo/Stored Procedures/EmergencyAccess_Update.sql create mode 100644 src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByEmergencyAccessGranteeId.sql create mode 100644 src/Sql/dbo/Tables/EmergencyAccess.sql create mode 100644 src/Sql/dbo/Views/EmergencyAccessDetailsView.sql create mode 100644 util/Migrator/DbScripts/2020-11-18_00_EmergencyAccess.sql diff --git a/src/Api/Controllers/EmergencyAccessController.cs b/src/Api/Controllers/EmergencyAccessController.cs new file mode 100644 index 0000000000..2f659747ef --- /dev/null +++ b/src/Api/Controllers/EmergencyAccessController.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Exceptions; +using Bit.Core.Models.Api; +using Bit.Core.Models.Api.Request; +using Bit.Core.Models.Api.Response; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Controllers +{ + [Route("emergency-access")] + [Authorize("Application")] + public class EmergencyAccessController : Controller + { + private readonly IUserService _userService; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IEmergencyAccessService _emergencyAccessService; + + public EmergencyAccessController( + IUserService userService, + IEmergencyAccessRepository emergencyAccessRepository, + IEmergencyAccessService emergencyAccessService) + { + _userService = userService; + _emergencyAccessRepository = emergencyAccessRepository; + _emergencyAccessService = emergencyAccessService; + } + + [HttpGet("trusted")] + public async Task> GetContacts() + { + var userId = _userService.GetProperUserId(User); + var granteeDetails = await _emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(userId.Value); + + var responses = granteeDetails.Select(d => + new EmergencyAccessGranteeDetailsResponseModel(d)); + + return new ListResponseModel(responses); + } + + [HttpGet("granted")] + public async Task> GetGrantees() + { + var userId = _userService.GetProperUserId(User); + var granteeDetails = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(userId.Value); + + var responses = granteeDetails.Select(d => new EmergencyAccessGrantorDetailsResponseModel(d)); + + return new ListResponseModel(responses); + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var userId = _userService.GetProperUserId(User); + var result = await _emergencyAccessService.GetAsync(new Guid(id), userId.Value); + return new EmergencyAccessGranteeDetailsResponseModel(result); + } + + [HttpPut("{id}")] + [HttpPost("{id}")] + public async Task Put(string id, [FromBody]EmergencyAccessUpdateRequestModel model) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(new Guid(id)); + if (emergencyAccess == null) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User); + await _emergencyAccessService.SaveAsync(model.ToEmergencyAccess(emergencyAccess), userId.Value); + } + + [HttpDelete("{id}")] + [HttpPost("{id}/delete")] + public async Task Delete(string id) + { + var userId = _userService.GetProperUserId(User); + await _emergencyAccessService.DeleteAsync(new Guid(id), userId.Value); + } + + [HttpPost("invite")] + public async Task Invite([FromBody] EmergencyAccessInviteRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.InviteAsync(user, user.Name, model.Email, model.Type.Value, model.WaitTimeDays); + } + + [HttpPost("{id}/reinvite")] + public async Task Reinvite(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.ResendInviteAsync(user.Id, new Guid(id), user.Name); + } + + [HttpPost("{id}/accept")] + public async Task Accept(string id, [FromBody] OrganizationUserAcceptRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.AcceptUserAsync(new Guid(id), user, model.Token, _userService); + } + + [HttpPost("{id}/confirm")] + public async Task Confirm(string id, [FromBody] OrganizationUserConfirmRequestModel model) + { + var userId = _userService.GetProperUserId(User); + await _emergencyAccessService.ConfirmUserAsync(new Guid(id), model.Key, userId.Value); + } + + [HttpPost("{id}/initiate")] + public async Task Initiate(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.InitiateAsync(new Guid(id), user); + } + + [HttpPost("{id}/approve")] + public async Task Accept(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.ApproveAsync(new Guid(id), user); + } + + [HttpPost("{id}/reject")] + public async Task Reject(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.RejectAsync(new Guid(id), user); + } + + [HttpPost("{id}/takeover")] + public async Task Takeover(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + var (result, grantor) = await _emergencyAccessService.TakeoverAsync(new Guid(id), user); + return new EmergencyAccessTakeoverResponseModel(result, grantor); + } + + [HttpPost("{id}/password")] + public async Task Password(string id, [FromBody] EmergencyAccessPasswordRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + await _emergencyAccessService.PasswordAsync(new Guid(id), user, model.NewMasterPasswordHash, model.Key); + } + + [HttpPost("{id}/view")] + public async Task ViewCiphers(string id) + { + var user = await _userService.GetUserByPrincipalAsync(User); + return await _emergencyAccessService.ViewAsync(new Guid(id), user); + } + } +} diff --git a/src/Api/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Jobs/EmergencyAccessNotificationJob.cs new file mode 100644 index 0000000000..16a4d11987 --- /dev/null +++ b/src/Api/Jobs/EmergencyAccessNotificationJob.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Bit.Api.Jobs +{ + public class EmergencyAccessNotificationJob : BaseJob + { + private readonly IServiceScopeFactory _serviceScopeFactory; + + public EmergencyAccessNotificationJob(IServiceScopeFactory serviceScopeFactory, ILogger logger) + : base(logger) + { + _serviceScopeFactory = serviceScopeFactory; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + using var scope = _serviceScopeFactory.CreateScope(); + var emergencyAccessService = scope.ServiceProvider.GetService(typeof(IEmergencyAccessService)) as IEmergencyAccessService; + await emergencyAccessService.SendNotificationsAsync(); + } + } +} diff --git a/src/Api/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Jobs/EmergencyAccessTimeoutJob.cs new file mode 100644 index 0000000000..7cad6a7fe8 --- /dev/null +++ b/src/Api/Jobs/EmergencyAccessTimeoutJob.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Bit.Api.Jobs +{ + public class EmergencyAccessTimeoutJob : BaseJob + { + private readonly IServiceScopeFactory _serviceScopeFactory; + + public EmergencyAccessTimeoutJob(IServiceScopeFactory serviceScopeFactory, ILogger logger) + : base(logger) + { + _serviceScopeFactory = serviceScopeFactory; + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + using var scope = _serviceScopeFactory.CreateScope(); + var emergencyAccessService = scope.ServiceProvider.GetService(typeof(IEmergencyAccessService)) as IEmergencyAccessService; + await emergencyAccessService.HandleTimedOutRequestsAsync(); + } + } +} diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index ab331a24e4..ef64be446b 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -23,6 +23,14 @@ namespace Bit.Api.Jobs .StartNow() .WithCronSchedule("0 0 * * * ?") .Build(); + var emergencyAccessNotificationTrigger = TriggerBuilder.Create() + .StartNow() + .WithCronSchedule("0 * * * * ?") + .Build(); + var emergencyAccessTimeoutTrigger = TriggerBuilder.Create() + .StartNow() + .WithCronSchedule("0 * * * * ?") + .Build(); var everyTopOfTheSixthHourTrigger = TriggerBuilder.Create() .StartNow() .WithCronSchedule("0 0 */6 * * ?") @@ -35,6 +43,8 @@ namespace Bit.Api.Jobs Jobs = new List> { new Tuple(typeof(AliveJob), everyTopOfTheHourTrigger), + new Tuple(typeof(EmergencyAccessNotificationJob), emergencyAccessNotificationTrigger), + new Tuple(typeof(EmergencyAccessTimeoutJob), emergencyAccessTimeoutTrigger), new Tuple(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger) }; @@ -45,6 +55,8 @@ namespace Bit.Api.Jobs public static void AddJobsServices(IServiceCollection services) { services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 0bc9da2e12..ce214d3b0b 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -126,6 +126,8 @@ namespace Bit.Api }); services.AddSwagger(globalSettings); + Jobs.JobsHostedService.AddJobsServices(services); + services.AddHostedService(); if (globalSettings.SelfHosted) { diff --git a/src/Core/Enums/EmergencyAccessStatusType.cs b/src/Core/Enums/EmergencyAccessStatusType.cs new file mode 100644 index 0000000000..2c5b472a92 --- /dev/null +++ b/src/Core/Enums/EmergencyAccessStatusType.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Enums +{ + public enum EmergencyAccessStatusType : byte + { + Invited = 0, + Accepted = 1, + Confirmed = 2, + RecoveryInitiated = 3, + RecoveryApproved = 4, + } +} diff --git a/src/Core/Enums/EmergencyAccessType.cs b/src/Core/Enums/EmergencyAccessType.cs new file mode 100644 index 0000000000..d622857aad --- /dev/null +++ b/src/Core/Enums/EmergencyAccessType.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Enums +{ + public enum EmergencyAccessType : byte + { + View = 0, + Takeover = 1, + } +} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.html.hbs new file mode 100644 index 0000000000..19ff860050 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.html.hbs @@ -0,0 +1,19 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ This email is to notify you that {{GranteeEmail}} has accepted your invitation to become an emergency access contact. +
+ To confirm this user, log into the Bitwarden web vault, go to settings and confirm the user. +
+ If you do not wish to confirm this user, you can also remove them on the same page. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.text.hbs new file mode 100644 index 0000000000..ce90816376 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessAccepted.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} +This email is to notify you that {{GranteeEmail}} has accepted your invitation to become an emergency access contact. + +To confirm this user, log into the Bitwarden web vault, go to settings and confirm the user. + +If you do not wish to confirm this user, you can also remove them on the same page. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.html.hbs new file mode 100644 index 0000000000..f61412420d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.html.hbs @@ -0,0 +1,9 @@ +{{#>FullHtmlLayout}} + + + + +
+ {{Name}} has approved your emergency request. You may now login on the web vault and access their account. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.text.hbs new file mode 100644 index 0000000000..65e624d8a1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessApproved.text.hbs @@ -0,0 +1,3 @@ +{{#>BasicTextLayout}} +{{Name}} has approved your emergency request. You may now login on the web vault and access their account. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.html.hbs new file mode 100644 index 0000000000..bf8cf21fc8 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.html.hbs @@ -0,0 +1,14 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ This email is to notify you that you have been confirmed as an emergency access contect for {{Name}}. +
+ You can now initiate emergency access requests from the web vault. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.text.hbs new file mode 100644 index 0000000000..c2c175143f --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessConfirmed.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +This email is to notify you that you have been confirmed as an emergency access contect for {{Name}}. + +You can now initiate emergency access requests from the web vault. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.html.hbs new file mode 100644 index 0000000000..585634e3ad --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.html.hbs @@ -0,0 +1,21 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ You have been invited to become an emergency contact for {{Name}}. +
+ + Become emergency contact + +
+ If you do not wish to become an emergency contact for {{Name}}, you can safely ignore this email. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.text.hbs new file mode 100644 index 0000000000..24ff2d6368 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessInvited.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} +You have been invited to become an emergency contact for {{Name}}. To accept this invite, click the following link: + +{{{Url}}} + +If you do not wish to become an emergency contact for {{Name}}, you can safely ignore this email. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.html.hbs new file mode 100644 index 0000000000..2cfcee9e5e --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.html.hbs @@ -0,0 +1,14 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ {{Name}} has initiated an emergency request to {{Action}} your account. You may login on the web vault and manually approve or reject this request. +
+ If you do nothing, the request will be automatically approved after {{DaysLeft}} day(s). +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.text.hbs new file mode 100644 index 0000000000..07f8be3d9b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecovery.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +{{Name}} has initiated an emergency request to {{Action}} your account. You may login on the web vault and manually approve or reject this request. + +If you do nothing, the request will automatically be approved after {{DaysLeft}} day(s). +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.html.hbs new file mode 100644 index 0000000000..f190eb98e6 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.html.hbs @@ -0,0 +1,14 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ {{Name}} has a pending emergency request to {{Action}} your account. You may login on the web vault and manually approve or reject this request. +
+ If you do nothing, the request will be automatically approved after {{DaysLeft}} day(s). +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.text.hbs new file mode 100644 index 0000000000..e3a427f934 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryReminder.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +{{Name}} has a pending emergency request to {{Action}} your account. You may login on the web vault and manually approve or reject this request. + +If you do nothing, the request will automatically be approved after {{DaysLeft}} day(s). +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.html.hbs new file mode 100644 index 0000000000..cc619df394 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.html.hbs @@ -0,0 +1,9 @@ +{{#>FullHtmlLayout}} + + + + +
+ {{Name}} has been granted emergency request to {{Action}} your account. You may login on the web vault and manually revoke this request. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.text.hbs new file mode 100644 index 0000000000..f1f79b1e0b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRecoveryTimedOut.text.hbs @@ -0,0 +1,3 @@ +{{#>BasicTextLayout}} +{{Name}} has been granted emergency request to {{Action}} your account. You may login on the web vault and manually revoke this request. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.html.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.html.hbs new file mode 100644 index 0000000000..60a9d79a27 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.html.hbs @@ -0,0 +1,9 @@ +{{#>FullHtmlLayout}} + + + + +
+ {{Name}} has rejected your emergency request. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.text.hbs b/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.text.hbs new file mode 100644 index 0000000000..ec6a3df78a --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/EmergencyAccessRejected.text.hbs @@ -0,0 +1,3 @@ +{{#>BasicTextLayout}} +{{Name}} has rejected your emergency request. +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Api/Request/EmergencyAccessRequstModels.cs b/src/Core/Models/Api/Request/EmergencyAccessRequstModels.cs new file mode 100644 index 0000000000..c4a5696214 --- /dev/null +++ b/src/Core/Models/Api/Request/EmergencyAccessRequstModels.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Api.Request +{ + public class EmergencyAccessInviteRequestModel + { + [Required] + [EmailAddress] + [StringLength(50)] + public string Email { get; set; } + [Required] + public Enums.EmergencyAccessType? Type { get; set; } + [Required] + public int WaitTimeDays { get; set; } + } + + public class EmergencyAccessUpdateRequestModel + { + [Required] + public Enums.EmergencyAccessType Type { get; set; } + [Required] + public int WaitTimeDays { get; set; } + public string KeyEncrypted { get; set; } + + public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess) + { + // Ensure we only set keys for a confirmed emergency access. + if (!string.IsNullOrWhiteSpace(existingEmergencyAccess.KeyEncrypted) && !string.IsNullOrWhiteSpace(KeyEncrypted)) + { + existingEmergencyAccess.KeyEncrypted = KeyEncrypted; + } + existingEmergencyAccess.Type = Type; + existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + return existingEmergencyAccess; + } + } + + public class EmergencyAccessPasswordRequestModel + { + [Required] + [StringLength(300)] + public string NewMasterPasswordHash { get; set; } + [Required] + public string Key { get; set; } + } +} diff --git a/src/Core/Models/Api/Response/EmergencyAccessResponseModel.cs b/src/Core/Models/Api/Response/EmergencyAccessResponseModel.cs new file mode 100644 index 0000000000..4545f82616 --- /dev/null +++ b/src/Core/Models/Api/Response/EmergencyAccessResponseModel.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; +using Core.Models.Data; + +namespace Bit.Core.Models.Api.Response +{ + public class EmergencyAccessResponseModel : ResponseModel + { + public EmergencyAccessResponseModel(EmergencyAccess emergencyAccess, string obj = "emergencyAccess") : base(obj) + { + if (emergencyAccess == null) + { + throw new ArgumentNullException(nameof(emergencyAccess)); + } + + Id = emergencyAccess.Id.ToString(); + Status = emergencyAccess.Status; + Type = emergencyAccess.Type; + WaitTimeDays = emergencyAccess.WaitTimeDays; + } + + public EmergencyAccessResponseModel(EmergencyAccessDetails emergencyAccess, string obj = "emergencyAccess") : base(obj) + { + if (emergencyAccess == null) + { + throw new ArgumentNullException(nameof(emergencyAccess)); + } + + Id = emergencyAccess.Id.ToString(); + Status = emergencyAccess.Status; + Type = emergencyAccess.Type; + WaitTimeDays = emergencyAccess.WaitTimeDays; + } + + public string Id { get; private set; } + public EmergencyAccessStatusType Status { get; private set; } + public EmergencyAccessType Type { get; private set; } + public int WaitTimeDays { get; private set; } + } + + public class EmergencyAccessGranteeDetailsResponseModel : EmergencyAccessResponseModel + { + public EmergencyAccessGranteeDetailsResponseModel(EmergencyAccessDetails emergencyAccess) + : base(emergencyAccess, "emergencyAccessGranteeDetails") + { + if (emergencyAccess == null) + { + throw new ArgumentNullException(nameof(emergencyAccess)); + } + + GranteeId = emergencyAccess.GranteeId.ToString(); + Email = emergencyAccess.GranteeEmail; + Name = emergencyAccess.GranteeName; + } + + public string GranteeId { get; private set; } + public string Name { get; private set; } + public string Email { get; private set; } + } + + public class EmergencyAccessGrantorDetailsResponseModel : EmergencyAccessResponseModel + { + public EmergencyAccessGrantorDetailsResponseModel(EmergencyAccessDetails emergencyAccess) + : base(emergencyAccess, "emergencyAccessGrantorDetails") + { + if (emergencyAccess == null) + { + throw new ArgumentNullException(nameof(emergencyAccess)); + } + + GrantorId = emergencyAccess.GrantorId.ToString(); + Email = emergencyAccess.GrantorEmail; + Name = emergencyAccess.GrantorName; + } + + public string GrantorId { get; private set; } + public string Name { get; private set; } + public string Email { get; private set; } + } + + public class EmergencyAccessTakeoverResponseModel : ResponseModel + { + public EmergencyAccessTakeoverResponseModel(EmergencyAccess emergencyAccess, User grantor, string obj = "emergencyAccessTakeover") : base(obj) + { + if (emergencyAccess == null) + { + throw new ArgumentNullException(nameof(emergencyAccess)); + } + + KeyEncrypted = emergencyAccess.KeyEncrypted; + Kdf = grantor.Kdf; + KdfIterations = grantor.KdfIterations; + } + + public int KdfIterations { get; private set; } + public KdfType Kdf { get; private set; } + public string KeyEncrypted { get; private set; } + } + + public class EmergencyAccessViewResponseModel : ResponseModel + { + public EmergencyAccessViewResponseModel( + GlobalSettings globalSettings, + EmergencyAccess emergencyAccess, + IEnumerable ciphers) + : base("emergencyAccessView") + { + KeyEncrypted = emergencyAccess.KeyEncrypted; + Ciphers = ciphers.Select(c => new CipherResponseModel(c, globalSettings)); + } + + public string KeyEncrypted { get; set; } + public IEnumerable Ciphers { get; set; } + } +} diff --git a/src/Core/Models/Data/EmergencyAccessDetails.cs b/src/Core/Models/Data/EmergencyAccessDetails.cs new file mode 100644 index 0000000000..bd25189554 --- /dev/null +++ b/src/Core/Models/Data/EmergencyAccessDetails.cs @@ -0,0 +1,12 @@ +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Data +{ + public class EmergencyAccessDetails : EmergencyAccess + { + public string GranteeName { get; set; } + public string GranteeEmail { get; set; } + public string GrantorName { get; set; } + public string GrantorEmail { get; set; } + } +} diff --git a/src/Core/Models/Data/EmergencyAccessNotify.cs b/src/Core/Models/Data/EmergencyAccessNotify.cs new file mode 100644 index 0000000000..179137703e --- /dev/null +++ b/src/Core/Models/Data/EmergencyAccessNotify.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Bit.Core.Enums; +using Bit.Core.Models.Table; +using Newtonsoft.Json; + +namespace Bit.Core.Models.Data +{ + public class EmergencyAccessNotify : EmergencyAccess + { + public string GrantorEmail { get; set; } + public string GranteeName { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessAcceptedViewModel.cs b/src/Core/Models/Mail/EmergencyAccessAcceptedViewModel.cs new file mode 100644 index 0000000000..1073ea8590 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessAcceptedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessAcceptedViewModel : BaseMailModel + { + public string GranteeEmail { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessApprovedViewModel.cs b/src/Core/Models/Mail/EmergencyAccessApprovedViewModel.cs new file mode 100644 index 0000000000..b8cb13b7f1 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessApprovedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessApprovedViewModel : BaseMailModel + { + public string Name { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessConfirmedViewModel.cs b/src/Core/Models/Mail/EmergencyAccessConfirmedViewModel.cs new file mode 100644 index 0000000000..c7f457e338 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessConfirmedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessConfirmedViewModel : BaseMailModel + { + public string Name { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessInvitedViewModel.cs b/src/Core/Models/Mail/EmergencyAccessInvitedViewModel.cs new file mode 100644 index 0000000000..a211208c47 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessInvitedViewModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessInvitedViewModel : BaseMailModel + { + public string Name { get; set; } + public string Id { get; set; } + public string Email { get; set; } + public string Token { get; set; } + public string Url => $"{WebVaultUrl}/accept-emergency?id={Id}&name={Name}&email={Email}&token={Token}"; + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs b/src/Core/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs new file mode 100644 index 0000000000..2c0a287ca1 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel + { + public string Name { get; set; } + public string Action { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessRecoveryViewModel.cs b/src/Core/Models/Mail/EmergencyAccessRecoveryViewModel.cs new file mode 100644 index 0000000000..bea6059fc4 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessRecoveryViewModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessRecoveryViewModel : BaseMailModel + { + public string Name { get; set; } + public string Action { get; set; } + public int DaysLeft { get; set; } + } +} diff --git a/src/Core/Models/Mail/EmergencyAccessRejectedViewModel.cs b/src/Core/Models/Mail/EmergencyAccessRejectedViewModel.cs new file mode 100644 index 0000000000..4cf1887261 --- /dev/null +++ b/src/Core/Models/Mail/EmergencyAccessRejectedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail +{ + public class EmergencyAccessRejectedViewModel : BaseMailModel + { + public string Name { get; set; } + } +} diff --git a/src/Core/Models/Table/EmergencyAccess.cs b/src/Core/Models/Table/EmergencyAccess.cs new file mode 100644 index 0000000000..fe118ca76a --- /dev/null +++ b/src/Core/Models/Table/EmergencyAccess.cs @@ -0,0 +1,46 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.Table +{ + public class EmergencyAccess : ITableObject + { + public Guid Id { get; set; } + public Guid GrantorId { get; set; } + public Guid? GranteeId { get; set; } + public string Email { get; set; } + public string KeyEncrypted { get; set; } + public EmergencyAccessType Type { get; set; } + public EmergencyAccessStatusType Status { get; set; } + public int WaitTimeDays { get; set; } + public DateTime? RecoveryInitiatedDate { get; internal set; } + public DateTime? LastNotificationDate { get; internal set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + public EmergencyAccess ToEmergencyAccess() + { + return new EmergencyAccess + { + Id = Id, + GrantorId = GrantorId, + GranteeId = GranteeId, + Email = Email, + KeyEncrypted = KeyEncrypted, + Type = Type, + Status = Status, + WaitTimeDays = WaitTimeDays, + RecoveryInitiatedDate = RecoveryInitiatedDate, + LastNotificationDate = LastNotificationDate, + CreationDate = CreationDate, + RevisionDate = RevisionDate, + }; + } + } +} diff --git a/src/Core/Repositories/IEmergencyAccessRepository.cs b/src/Core/Repositories/IEmergencyAccessRepository.cs new file mode 100644 index 0000000000..e07444db12 --- /dev/null +++ b/src/Core/Repositories/IEmergencyAccessRepository.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; + +namespace Bit.Core.Repositories +{ + public interface IEmergencyAccessRepository : IRepository + { + Task GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers); + Task> GetManyDetailsByGrantorIdAsync(Guid grantorId); + Task> GetManyDetailsByGranteeIdAsync(Guid granteeId); + Task GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId); + Task> GetManyToNotifyAsync(); + Task> GetExpiredRecoveriesAsync(); + } +} diff --git a/src/Core/Repositories/SqlServer/EmergencyAccessRepository.cs b/src/Core/Repositories/SqlServer/EmergencyAccessRepository.cs new file mode 100644 index 0000000000..289ccf3635 --- /dev/null +++ b/src/Core/Repositories/SqlServer/EmergencyAccessRepository.cs @@ -0,0 +1,99 @@ +using System; +using Bit.Core.Models.Table; +using System.Data; +using System.Data.SqlClient; +using System.Threading.Tasks; +using Dapper; +using System.Linq; +using System.Collections.Generic; +using Bit.Core.Models.Data; + +namespace Bit.Core.Repositories.SqlServer +{ + public class EmergencyAccessRepository : Repository, IEmergencyAccessRepository + { + public EmergencyAccessRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public EmergencyAccessRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + "[dbo].[EmergencyAccess_ReadCountByGrantorIdEmail]", + new { GrantorId = grantorId, Email = email, OnlyUsers = onlyRegisteredUsers }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + + public async Task> GetManyDetailsByGrantorIdAsync(Guid grantorId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[EmergencyAccessDetails_ReadByGrantorId]", + new { GrantorId = grantorId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task> GetManyDetailsByGranteeIdAsync(Guid granteeId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[EmergencyAccessDetails_ReadByGranteeId]", + new { GranteeId = granteeId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[EmergencyAccessDetails_ReadByIdGrantorId]", + new { Id = id, GrantorId = grantorId }, + commandType: CommandType.StoredProcedure); + + return results.FirstOrDefault(); + } + } + + public async Task> GetManyToNotifyAsync() + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[EmergencyAccess_ReadToNotify]", + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task> GetExpiredRecoveriesAsync() + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[EmergencyAccessDetails_ReadExpiredRecoveries]", + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + } +} diff --git a/src/Core/Services/IEmergencyAccessService.cs b/src/Core/Services/IEmergencyAccessService.cs new file mode 100644 index 0000000000..1cc1d01709 --- /dev/null +++ b/src/Core/Services/IEmergencyAccessService.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Enums; +using Bit.Core.Models.Api.Response; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; + +namespace Bit.Core.Services +{ + public interface IEmergencyAccessService + { + Task InviteAsync(User invitingUser, string invitingUsersName, string email, EmergencyAccessType type, int waitTime); + Task ResendInviteAsync(Guid invitingUserId, Guid emergencyAccessId, string invitingUsersName); + Task AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService); + Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); + Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); + Task GetAsync(Guid emergencyAccessId, Guid userId); + Task SaveAsync(EmergencyAccess emergencyAccess, Guid savingUserId); + Task InitiateAsync(Guid id, User initiatingUser); + Task ApproveAsync(Guid id, User approvingUser); + Task RejectAsync(Guid id, User rejectingUser); + Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User initiatingUser); + Task PasswordAsync(Guid id, User user, string newMasterPasswordHash, string key); + Task SendNotificationsAsync(); + Task HandleTimedOutRequestsAsync(); + Task ViewAsync(Guid id, User user); + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 88459ece91..d1d8dace64 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -29,5 +29,13 @@ namespace Bit.Core.Services Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip); Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip); Task SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(string organizationName, string email); + Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token); + Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email); + Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email); + Task SendEmergencyAccessRecoveryInitiated(EmergencyAccess emergencyAccess, string initiatingName, string email); + Task SendEmergencyAccessRecoveryApproved(EmergencyAccess emergencyAccess, string approvingName, string email); + Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email); + Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email); + Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email); } } diff --git a/src/Core/Services/Implementations/EmergencyAccessService.cs b/src/Core/Services/Implementations/EmergencyAccessService.cs new file mode 100644 index 0000000000..b4241b67a8 --- /dev/null +++ b/src/Core/Services/Implementations/EmergencyAccessService.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Models.Api.Response; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Services +{ + public class EmergencyAccessService : IEmergencyAccessService + { + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IUserRepository _userRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IMailService _mailService; + private readonly IDataProtector _dataProtector; + private readonly GlobalSettings _globalSettings; + private readonly IPasswordHasher _passwordHasher; + + public EmergencyAccessService( + IEmergencyAccessRepository emergencyAccessRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + IMailService mailService, + IPasswordHasher passwordHasher, + IDataProtectionProvider dataProtectionProvider, + GlobalSettings globalSettings) + { + _emergencyAccessRepository = emergencyAccessRepository; + _userRepository = userRepository; + _cipherRepository = cipherRepository; + _mailService = mailService; + _passwordHasher = passwordHasher; + _dataProtector = dataProtectionProvider.CreateProtector("EmergencyAccessServiceDataProtector"); + _globalSettings = globalSettings; + } + + public async Task InviteAsync(User invitingUser, string invitingUsersName, string email, EmergencyAccessType type, int waitTime) + { + if (!invitingUser.Premium) + { + throw new BadRequestException("Not a premium user."); + } + + var emergencyAccess = new EmergencyAccess + { + GrantorId = invitingUser.Id, + Email = email.ToLowerInvariant(), + Status = EmergencyAccessStatusType.Invited, + Type = type, + WaitTimeDays = waitTime, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await _emergencyAccessRepository.CreateAsync(emergencyAccess); + await SendInviteAsync(emergencyAccess, invitingUsersName); + + return emergencyAccess; + } + + public async Task GetAsync(Guid emergencyAccessId, Guid userId) + { + var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, userId); + if (emergencyAccess == null) + { + throw new BadRequestException("Emergency Access not valid."); + } + + return emergencyAccess; + } + + public async Task ResendInviteAsync(Guid invitingUserId, Guid emergencyAccessId, string invitingUsersName) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + if (emergencyAccess == null || emergencyAccess.GrantorId != invitingUserId || + emergencyAccess.Status != EmergencyAccessStatusType.Invited) + { + throw new BadRequestException("Emergency Access not valid."); + } + + await SendInviteAsync(emergencyAccess, invitingUsersName); + } + + public async Task AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + if (emergencyAccess == null) + { + throw new BadRequestException("Emergency Access not valid."); + } + + if (!CoreHelpers.TokenIsValid("EmergencyAccessInvite", _dataProtector, token, user.Email, emergencyAccessId, _globalSettings)) + { + throw new BadRequestException("Invalid token."); + } + + if (string.IsNullOrWhiteSpace(emergencyAccess.Email) || + !emergencyAccess.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + { + throw new BadRequestException("User email does not match invite."); + } + + if (emergencyAccess.Status != EmergencyAccessStatusType.Invited) + { + throw new BadRequestException("Already accepted."); + } + + var granteeEmail = emergencyAccess.Email; + + emergencyAccess.Status = EmergencyAccessStatusType.Accepted; + emergencyAccess.GranteeId = user.Id; + emergencyAccess.Email = null; + + var grantor = await userService.GetUserByIdAsync(emergencyAccess.GrantorId); + + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + await _mailService.SendEmergencyAccessAcceptedEmailAsync(granteeEmail, grantor.Email); + + return emergencyAccess; + } + + public async Task DeleteAsync(Guid emergencyAccessId, Guid grantorId) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + if (emergencyAccess == null || (emergencyAccess.GrantorId != grantorId && emergencyAccess.GranteeId != grantorId)) + { + throw new BadRequestException("Emergency Access not valid."); + } + + await _emergencyAccessRepository.DeleteAsync(emergencyAccess); + } + + public async Task ConfirmUserAsync(Guid emergencyAcccessId, string key, Guid confirmingUserId) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAcccessId); + if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || + emergencyAccess.GrantorId != confirmingUserId) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var grantor = await _userRepository.GetByIdAsync(confirmingUserId); + var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); + + emergencyAccess.Status = EmergencyAccessStatusType.Confirmed; + emergencyAccess.KeyEncrypted = key; + emergencyAccess.Email = null; + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + await _mailService.SendEmergencyAccessConfirmedEmailAsync(grantor.Name, grantee.Email); + + return emergencyAccess; + } + + public async Task SaveAsync(EmergencyAccess emergencyAccess, Guid savingUserId) + { + if (emergencyAccess.GrantorId != savingUserId) + { + throw new BadRequestException("Emergency Access not valid."); + } + + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + } + + public async Task InitiateAsync(Guid id, User initiatingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GranteeId != initiatingUser.Id || + emergencyAccess.Status != EmergencyAccessStatusType.Confirmed) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var now = DateTime.UtcNow; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated; + emergencyAccess.RevisionDate = now; + emergencyAccess.RecoveryInitiatedDate = now; + emergencyAccess.LastNotificationDate = now; + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + + var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); + + await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, initiatingUser.Name, grantor.Email); + } + + public async Task ApproveAsync(Guid id, User approvingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GrantorId != approvingUser.Id || + emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated) + { + throw new BadRequestException("Emergency Access not valid."); + } + + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + + var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); + await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, approvingUser.Name, grantee.Email); + } + + public async Task RejectAsync(Guid id, User rejectingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GrantorId != rejectingUser.Id || + (emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated && + emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved)) + { + throw new BadRequestException("Emergency Access not valid."); + } + + emergencyAccess.Status = EmergencyAccessStatusType.Confirmed; + await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); + + var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); + await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, rejectingUser.Name, grantee.Email); + } + + public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User requestingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GranteeId != requestingUser.Id || + emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); + + return (emergencyAccess, grantor); + } + + public async Task PasswordAsync(Guid id, User requestingUser, string newMasterPasswordHash, string key) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GranteeId != requestingUser.Id || + emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); + + grantor.MasterPassword = _passwordHasher.HashPassword(grantor, newMasterPasswordHash); + grantor.Key = key; + // Disable TwoFactor providers since they will otherwise block logins + grantor.SetTwoFactorProviders(new Dictionary()); + await _userRepository.ReplaceAsync(grantor); + } + + public async Task SendNotificationsAsync() + { + var toNotify = await _emergencyAccessRepository.GetManyToNotifyAsync(); + + foreach (var notify in toNotify) + { + var ea = notify.ToEmergencyAccess(); + ea.LastNotificationDate = DateTime.UtcNow; + await _emergencyAccessRepository.ReplaceAsync(ea); + + await _mailService.SendEmergencyAccessRecoveryReminder(ea, notify.GranteeName, notify.GrantorEmail); + } + } + + public async Task HandleTimedOutRequestsAsync() + { + var expired = await _emergencyAccessRepository.GetExpiredRecoveriesAsync(); + + foreach (var details in expired) + { + var ea = details.ToEmergencyAccess(); + ea.Status = EmergencyAccessStatusType.RecoveryApproved; + await _emergencyAccessRepository.ReplaceAsync(ea); + + await _mailService.SendEmergencyAccessRecoveryApproved(ea, details.GrantorName, details.GranteeEmail); + await _mailService.SendEmergencyAccessRecoveryTimedOut(ea, details.GranteeName, details.GrantorEmail); + } + } + + public async Task ViewAsync(Guid id, User requestingUser) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + + if (emergencyAccess == null || emergencyAccess.GranteeId != requestingUser.Id || + emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(emergencyAccess.GrantorId, false); + + return new EmergencyAccessViewResponseModel(_globalSettings, emergencyAccess, ciphers); + } + + private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) + { + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}"); + await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); + } + } +} diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 1a6d05c0aa..6256f5ccea 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -489,5 +489,121 @@ namespace Bit.Core.Services writer.WriteSafeString($"{text}"); }); } + + public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) + { + var message = CreateDefaultMessage($"Emergency Access Contact Invite", emergencyAccess.Email); + var model = new EmergencyAccessInvitedViewModel + { + Name = CoreHelpers.SanitizeForEmail(name), + Email = WebUtility.UrlEncode(emergencyAccess.Email), + Id = emergencyAccess.Id.ToString(), + Token = WebUtility.UrlEncode(token), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "EmergencyAccessInvited", model); + message.Category = "EmergencyAccessInvited"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email) + { + var message = CreateDefaultMessage($"Accepted Emergency Access", email); + var model = new EmergencyAccessAcceptedViewModel + { + GranteeEmail = CoreHelpers.SanitizeForEmail(granteeEmail), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "EmergencyAccessAccepted", model); + message.Category = "EmergencyAccessAccepted"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email) + { + var message = CreateDefaultMessage($"You Have Been Confirmed as Emergency Access Contact", email); + var model = new EmergencyAccessConfirmedViewModel + { + Name = CoreHelpers.SanitizeForEmail(grantorName), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "EmergencyAccessConfirmed", model); + message.Category = "EmergencyAccessConfirmed"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessRecoveryInitiated(EmergencyAccess emergencyAccess, string initiatingName, string email) + { + var message = CreateDefaultMessage("Emergency Access Initiated", email); + + var remainingTime = DateTime.UtcNow - emergencyAccess.RecoveryInitiatedDate.GetValueOrDefault(); + + var model = new EmergencyAccessRecoveryViewModel + { + Name = CoreHelpers.SanitizeForEmail(initiatingName), + Action = emergencyAccess.Type.ToString(), + DaysLeft = emergencyAccess.WaitTimeDays - Convert.ToInt32((remainingTime).TotalDays), + }; + await AddMessageContentAsync(message, "EmergencyAccessRecovery", model); + message.Category = "EmergencyAccessRecovery"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessRecoveryApproved(EmergencyAccess emergencyAccess, string approvingName, string email) + { + var message = CreateDefaultMessage("Emergency Access Approved", email); + var model = new EmergencyAccessApprovedViewModel + { + Name = CoreHelpers.SanitizeForEmail(approvingName), + }; + await AddMessageContentAsync(message, "EmergencyAccessApproved", model); + message.Category = "EmergencyAccessApproved"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email) + { + var message = CreateDefaultMessage("Emergency Access Rejected", email); + var model = new EmergencyAccessRejectedViewModel + { + Name = CoreHelpers.SanitizeForEmail(rejectingName), + }; + await AddMessageContentAsync(message, "EmergencyAccessRejected", model); + message.Category = "EmergencyAccessRejected"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email) + { + var message = CreateDefaultMessage("Pending Emergency Access Request", email); + + var remainingTime = DateTime.UtcNow - emergencyAccess.RecoveryInitiatedDate.GetValueOrDefault(); + + var model = new EmergencyAccessRecoveryViewModel + { + Name = CoreHelpers.SanitizeForEmail(initiatingName), + Action = emergencyAccess.Type.ToString(), + DaysLeft = emergencyAccess.WaitTimeDays - Convert.ToInt32((remainingTime).TotalDays), + }; + await AddMessageContentAsync(message, "EmergencyAccessRecoveryReminder", model); + message.Category = "EmergencyAccessRecoveryReminder"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess emergencyAccess, string initiatingName, string email) + { + var message = CreateDefaultMessage("Emergency Access Granted", email); + var model = new EmergencyAccessRecoveryTimedOutViewModel + { + Name = CoreHelpers.SanitizeForEmail(initiatingName), + Action = emergencyAccess.Type.ToString(), + }; + await AddMessageContentAsync(message, "EmergencyAccessRecoveryTimedOut", model); + message.Category = "EmergencyAccessRecoveryTimedOut"; + await _mailDeliveryService.SendEmailAsync(message); + } } } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index a0f8e2aca0..e65b2358ef 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -107,5 +107,45 @@ namespace Bit.Core.Services { return Task.FromResult(0); } + + public Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessRecoveryInitiated(EmergencyAccess emergencyAccess, string initiatingName, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessRecoveryApproved(EmergencyAccess emergencyAccess, string approvingName, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email) + { + return Task.FromResult(0); + } + + public Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email) + { + return Task.FromResult(0); + } } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index bf01cf225c..f00c9a7e84 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -553,14 +553,20 @@ namespace Bit.Core.Utilities public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail, Guid orgUserId, GlobalSettings globalSettings) + { + return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId, globalSettings); + } + + public static bool TokenIsValid(string firstTokenPart, IDataProtector protector, string token, string userEmail, + Guid id, GlobalSettings globalSettings) { var invalid = true; try { var unprotectedData = protector.Unprotect(token); var dataParts = unprotectedData.Split(' '); - if (dataParts.Length == 4 && dataParts[0] == "OrganizationUserInvite" && - new Guid(dataParts[1]) == orgUserId && + if (dataParts.Length == 4 && dataParts[0] == firstTokenPart && + new Guid(dataParts[1]) == id && dataParts[2].Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)) { var creationTime = FromEpocMilliseconds(Convert.ToInt64(dataParts[3])); diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index d81d6ac50b..2ddcdebcf1 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -80,6 +80,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } if (globalSettings.SelfHosted) @@ -112,6 +113,7 @@ namespace Bit.Core.Utilities services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 70b9b3edf9..c99ed203f0 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -69,6 +69,7 @@ + @@ -287,6 +288,18 @@ + + + + + + + + + + + + @@ -298,4 +311,3 @@ - diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGranteeId.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGranteeId.sql new file mode 100644 index 0000000000..c6952b7a94 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGranteeId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGranteeId] + @GranteeId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [GranteeId] = @GranteeId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGrantorId.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGrantorId.sql new file mode 100644 index 0000000000..0a9efb0c38 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByGrantorId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGrantorId] + @GrantorId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [GrantorId] = @GrantorId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByIdGrantorId.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByIdGrantorId.sql new file mode 100644 index 0000000000..476a7ac234 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadByIdGrantorId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByIdGrantorId] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [Id] = @Id + AND + [GrantorId] = @GrantorId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadExpiredRecoveries.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadExpiredRecoveries.sql new file mode 100644 index 0000000000..0920e60f0f --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccessDetails_ReadExpiredRecoveries.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadExpiredRecoveries] +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [Status] = 3 + AND + DATEADD(DAY, [WaitTimeDays], [RecoveryInitiatedDate]) <= GETUTCDATE() +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_Create.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_Create.sql new file mode 100644 index 0000000000..d37c4738de --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_Create.sql @@ -0,0 +1,48 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_Create] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER, + @GranteeId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @KeyEncrypted VARCHAR(MAX), + @Type TINYINT, + @Status TINYINT, + @WaitTimeDays SMALLINT, + @RecoveryInitiatedDate DATETIME2(7), + @LastNotificationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[EmergencyAccess] + ( + [Id], + [GrantorId], + [GranteeId], + [Email], + [KeyEncrypted], + [Type], + [Status], + [WaitTimeDays], + [RecoveryInitiatedDate], + [LastNotificationDate], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @GrantorId, + @GranteeId, + @Email, + @KeyEncrypted, + @Type, + @Status, + @WaitTimeDays, + @RecoveryInitiatedDate, + @LastNotificationDate, + @CreationDate, + @RevisionDate + ) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_DeleteById.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_DeleteById.sql new file mode 100644 index 0000000000..11f04aed42 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_DeleteById.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId] @Id + + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadById.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadById.sql new file mode 100644 index 0000000000..72a758d708 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccess] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadCountByGrantorIdEmail.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadCountByGrantorIdEmail.sql new file mode 100644 index 0000000000..0f1ec535a4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadCountByGrantorIdEmail.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadCountByGrantorIdEmail] + @GrantorId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @OnlyUsers BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[EmergencyAccess] EA + LEFT JOIN + [dbo].[User] U ON EA.[GranteeId] = U.[Id] + WHERE + EA.[GrantorId] = @GrantorId + AND ( + (@OnlyUsers = 0 AND (EA.[Email] = @Email OR U.[Email] = @Email)) + OR (@OnlyUsers = 1 AND U.[Email] = @Email) + ) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadToNotify.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadToNotify.sql new file mode 100644 index 0000000000..195257d09c --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_ReadToNotify.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadToNotify] +AS +BEGIN + SET NOCOUNT ON + + SELECT + EA.*, + Grantee.Name as GranteeName, + Grantor.Email as GrantorEmail + FROM + [dbo].[EmergencyAccess] EA + LEFT JOIN + [dbo].[User] Grantor ON Grantor.[Id] = EA.[GrantorId] + LEFT JOIN + [dbo].[User] Grantee On Grantee.[Id] = EA.[GranteeId] + WHERE + EA.[Status] = 3 + AND + DATEADD(DAY, EA.[WaitTimeDays] - 1, EA.[RecoveryInitiatedDate]) <= GETUTCDATE() + AND + DATEADD(DAY, 1, EA.[LastNotificationDate]) <= GETUTCDATE() +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/EmergencyAccess_Update.sql b/src/Sql/dbo/Stored Procedures/EmergencyAccess_Update.sql new file mode 100644 index 0000000000..0831c7dc78 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/EmergencyAccess_Update.sql @@ -0,0 +1,36 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_Update] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER, + @GranteeId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @KeyEncrypted VARCHAR(MAX), + @Type TINYINT, + @Status TINYINT, + @WaitTimeDays SMALLINT, + @RecoveryInitiatedDate DATETIME2(7), + @LastNotificationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[EmergencyAccess] + SET + [GrantorId] = @GrantorId, + [GranteeId] = @GranteeId, + [Email] = @Email, + [KeyEncrypted] = @KeyEncrypted, + [Type] = @Type, + [Status] = @Status, + [WaitTimeDays] = @WaitTimeDays, + [RecoveryInitiatedDate] = @RecoveryInitiatedDate, + [LastNotificationDate] = @LastNotificationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + + EXEC [dbo].[User_BumpAccountRevisionDate] @GranteeId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByEmergencyAccessGranteeId.sql b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByEmergencyAccessGranteeId.sql new file mode 100644 index 0000000000..b2d70f4151 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByEmergencyAccessGranteeId.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId] + @EmergencyAccessId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + [dbo].[EmergencyAccess] EA ON EA.[GranteeId] = U.[Id] + WHERE + EA.[Id] = @EmergencyAccessId + AND EA.[Status] = 2 -- Confirmed +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 94ab830192..534ca29346 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -79,6 +79,15 @@ BEGIN WHERE [UserId] = @Id + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + -- Finally, delete the user DELETE FROM diff --git a/src/Sql/dbo/Tables/EmergencyAccess.sql b/src/Sql/dbo/Tables/EmergencyAccess.sql new file mode 100644 index 0000000000..c1ba952677 --- /dev/null +++ b/src/Sql/dbo/Tables/EmergencyAccess.sql @@ -0,0 +1,18 @@ +CREATE TABLE [dbo].[EmergencyAccess] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [GrantorId] UNIQUEIDENTIFIER NOT NULL, + [GranteeId] UNIQUEIDENTIFIER NULL, + [Email] NVARCHAR (50) NULL, + [KeyEncrypted] VARCHAR (MAX) NULL, + [WaitTimeDays] SMALLINT NULL, + [Type] TINYINT NOT NULL, + [Status] TINYINT NOT NULL, + [RecoveryInitiatedDate] DATETIME2 (7) NULL, + [LastNotificationDate] DATETIME2 (7) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_EmergencyAccess] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_EmergencyAccess_GrantorId] FOREIGN KEY ([GrantorId]) REFERENCES [dbo].[User] ([Id]), + CONSTRAINT [FK_EmergencyAccess_GranteeId] FOREIGN KEY ([GranteeId]) REFERENCES [dbo].[User] ([Id]) +) diff --git a/src/Sql/dbo/Views/EmergencyAccessDetailsView.sql b/src/Sql/dbo/Views/EmergencyAccessDetailsView.sql new file mode 100644 index 0000000000..8c31f783e6 --- /dev/null +++ b/src/Sql/dbo/Views/EmergencyAccessDetailsView.sql @@ -0,0 +1,14 @@ +CREATE VIEW [dbo].[EmergencyAccessDetailsView] +AS +SELECT + EA.*, + GranteeU.[Name] GranteeName, + ISNULL(GranteeU.[Email], EA.[Email]) GranteeEmail, + GrantorU.[Name] GrantorName, + GrantorU.[Email] GrantorEmail +FROM + [dbo].[EmergencyAccess] EA +LEFT JOIN + [dbo].[User] GranteeU ON GranteeU.[Id] = EA.[GranteeId] +LEFT JOIN + [dbo].[User] GrantorU ON GrantorU.[Id] = EA.[GrantorId] \ No newline at end of file diff --git a/util/Migrator/DbScripts/2020-11-18_00_EmergencyAccess.sql b/util/Migrator/DbScripts/2020-11-18_00_EmergencyAccess.sql new file mode 100644 index 0000000000..576672666a --- /dev/null +++ b/util/Migrator/DbScripts/2020-11-18_00_EmergencyAccess.sql @@ -0,0 +1,473 @@ +/* + * Add support for Emergency Access + */ +IF OBJECT_ID('[dbo].[EmergencyAccess]') IS NULL +BEGIN + CREATE TABLE [dbo].[EmergencyAccess] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [GrantorId] UNIQUEIDENTIFIER NOT NULL, + [GranteeId] UNIQUEIDENTIFIER NULL, + [Email] NVARCHAR (50) NULL, + [KeyEncrypted] VARCHAR (MAX) NULL, + [WaitTimeDays] SMALLINT NULL, + [Type] TINYINT NOT NULL, + [Status] TINYINT NOT NULL, + [RecoveryInitiatedDate] DATETIME2 (7) NULL, + [LastNotificationDate] DATETIME2 (7) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_EmergencyAccess] PRIMARY KEY CLUSTERED ([Id] ASC) + ); + + ALTER TABLE [dbo].[EmergencyAccess] WITH NOCHECK + ADD CONSTRAINT [FK_EmergencyAccess_GrantorId] FOREIGN KEY ([GrantorId]) REFERENCES [dbo].[User] ([Id]); + + ALTER TABLE [dbo].[EmergencyAccess] WITH NOCHECK + ADD CONSTRAINT [FK_EmergencyAccess_GranteeId] FOREIGN KEY ([GranteeId]) REFERENCES [dbo].[User] ([Id]); +END +GO + +IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'EmergencyAccessDetailsView') +BEGIN + DROP VIEW [dbo].[EmergencyAccessDetailsView] +END +GO + +CREATE VIEW [dbo].[EmergencyAccessDetailsView] +AS +SELECT + EA.*, + GranteeU.[Name] GranteeName, + ISNULL(GranteeU.[Email], EA.[Email]) GranteeEmail, + GrantorU.[Name] GrantorName, + GrantorU.[Email] GrantorEmail +FROM + [dbo].[EmergencyAccess] EA +LEFT JOIN + [dbo].[User] GranteeU ON GranteeU.[Id] = EA.[GranteeId] +LEFT JOIN + [dbo].[User] GrantorU ON GrantorU.[Id] = EA.[GrantorId] +GO + +IF OBJECT_ID('[dbo].[User_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[User_DeleteById] +@Id UNIQUEIDENTIFIER + WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] = @Id + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] = @Id + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] = @Id + + -- Delete U2F logins + DELETE + FROM + [dbo].[U2f] + WHERE + [UserId] = @Id + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] = @Id + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] = @Id + + COMMIT TRANSACTION User_DeleteById +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_Create]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_Create] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_Create] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER, + @GranteeId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @KeyEncrypted VARCHAR(MAX), + @Type TINYINT, + @Status TINYINT, + @WaitTimeDays SMALLINT, + @RecoveryInitiatedDate DATETIME2(7), + @LastNotificationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[EmergencyAccess] + ( + [Id], + [GrantorId], + [GranteeId], + [Email], + [KeyEncrypted], + [Type], + [Status], + [WaitTimeDays], + [RecoveryInitiatedDate], + [LastNotificationDate], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @GrantorId, + @GranteeId, + @Email, + @KeyEncrypted, + @Type, + @Status, + @WaitTimeDays, + @RecoveryInitiatedDate, + @LastNotificationDate, + @CreationDate, + @RevisionDate + ) +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_ReadById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_ReadById] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccess] + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_ReadCountByGrantorIdEmail]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_ReadCountByGrantorIdEmail] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadCountByGrantorIdEmail] + @GrantorId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @OnlyUsers BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[EmergencyAccess] EA + LEFT JOIN + [dbo].[User] U ON EA.[GranteeId] = U.[Id] + WHERE + EA.[GrantorId] = @GrantorId + AND ( + (@OnlyUsers = 0 AND (EA.[Email] = @Email OR U.[Email] = @Email)) + OR (@OnlyUsers = 1 AND U.[Email] = @Email) + ) +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_ReadToNotify]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_ReadToNotify] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_ReadToNotify] +AS +BEGIN + SET NOCOUNT ON + + SELECT + EA.*, + Grantee.Name as GranteeName, + Grantor.Email as GrantorEmail + FROM + [dbo].[EmergencyAccess] EA + LEFT JOIN + [dbo].[User] Grantor ON Grantor.[Id] = EA.[GrantorId] + LEFT JOIN + [dbo].[User] Grantee On Grantee.[Id] = EA.[GranteeId] + WHERE + EA.[Status] = 3 + AND + DATEADD(DAY, EA.[WaitTimeDays] - 1, EA.[RecoveryInitiatedDate]) <= GETUTCDATE() + AND + DATEADD(DAY, 1, EA.[LastNotificationDate]) <= GETUTCDATE() +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_Update]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_Update] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_Update] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER, + @GranteeId UNIQUEIDENTIFIER, + @Email NVARCHAR(50), + @KeyEncrypted VARCHAR(MAX), + @Type TINYINT, + @Status TINYINT, + @WaitTimeDays SMALLINT, + @RecoveryInitiatedDate DATETIME2(7), + @LastNotificationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[EmergencyAccess] + SET + [GrantorId] = @GrantorId, + [GranteeId] = @GranteeId, + [Email] = @Email, + [KeyEncrypted] = @KeyEncrypted, + [Type] = @Type, + [Status] = @Status, + [WaitTimeDays] = @WaitTimeDays, + [RecoveryInitiatedDate] = @RecoveryInitiatedDate, + [LastNotificationDate] = @LastNotificationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + + EXEC [dbo].[User_BumpAccountRevisionDate] @GranteeId +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccessDetails_ReadByGranteeId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGranteeId] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGranteeId] + @GranteeId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [GranteeId] = @GranteeId +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccessDetails_ReadByGrantorId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGrantorId] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGrantorId] + @GrantorId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [GrantorId] = @GrantorId +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccessDetails_ReadByIdGrantorId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccessDetails_ReadByIdGrantorId] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByIdGrantorId] + @Id UNIQUEIDENTIFIER, + @GrantorId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [Id] = @Id + AND + [GrantorId] = @GrantorId +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccessDetails_ReadExpiredRecoveries]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccessDetails_ReadExpiredRecoveries] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadExpiredRecoveries] +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EmergencyAccessDetailsView] + WHERE + [Status] = 3 + AND + DATEADD(DAY, [WaitTimeDays], [RecoveryInitiatedDate]) <= GETUTCDATE() +END +GO + +IF OBJECT_ID('[dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId] +END +GO + +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId] + @EmergencyAccessId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + [dbo].[EmergencyAccess] EA ON EA.[GranteeId] = U.[Id] + WHERE + EA.[Id] = @EmergencyAccessId + AND EA.[Status] = 2 -- Confirmed +END +GO + +IF OBJECT_ID('[dbo].[EmergencyAccess_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[EmergencyAccess_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[EmergencyAccess_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByEmergencyAccessGranteeId] @Id + + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [Id] = @Id +END +GO