1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-16 01:51:21 +01:00

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
This commit is contained in:
Oscar Hinton 2020-12-16 20:36:47 +01:00 committed by GitHub
parent 9bb63b86f0
commit 0f1af2333e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 2073 additions and 3 deletions

View File

@ -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<ListResponseModel<EmergencyAccessGranteeDetailsResponseModel>> GetContacts()
{
var userId = _userService.GetProperUserId(User);
var granteeDetails = await _emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(userId.Value);
var responses = granteeDetails.Select(d =>
new EmergencyAccessGranteeDetailsResponseModel(d));
return new ListResponseModel<EmergencyAccessGranteeDetailsResponseModel>(responses);
}
[HttpGet("granted")]
public async Task<ListResponseModel<EmergencyAccessGrantorDetailsResponseModel>> GetGrantees()
{
var userId = _userService.GetProperUserId(User);
var granteeDetails = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(userId.Value);
var responses = granteeDetails.Select(d => new EmergencyAccessGrantorDetailsResponseModel(d));
return new ListResponseModel<EmergencyAccessGrantorDetailsResponseModel>(responses);
}
[HttpGet("{id}")]
public async Task<EmergencyAccessGranteeDetailsResponseModel> 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<EmergencyAccessTakeoverResponseModel> 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<EmergencyAccessViewResponseModel> ViewCiphers(string id)
{
var user = await _userService.GetUserByPrincipalAsync(User);
return await _emergencyAccessService.ViewAsync(new Guid(id), user);
}
}
}

View File

@ -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<EmergencyAccessNotificationJob> 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();
}
}
}

View File

@ -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<EmergencyAccessNotificationJob> 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();
}
}
}

View File

@ -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<Tuple<Type, ITrigger>>
{
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger),
new Tuple<Type, ITrigger>(typeof(EmergencyAccessNotificationJob), emergencyAccessNotificationTrigger),
new Tuple<Type, ITrigger>(typeof(EmergencyAccessTimeoutJob), emergencyAccessTimeoutTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger)
};
@ -45,6 +55,8 @@ namespace Bit.Api.Jobs
public static void AddJobsServices(IServiceCollection services)
{
services.AddTransient<AliveJob>();
services.AddTransient<EmergencyAccessNotificationJob>();
services.AddTransient<EmergencyAccessTimeoutJob>();
services.AddTransient<ValidateUsersJob>();
services.AddTransient<ValidateOrganizationsJob>();
}

View File

@ -126,6 +126,8 @@ namespace Bit.Api
});
services.AddSwagger(globalSettings);
Jobs.JobsHostedService.AddJobsServices(services);
services.AddHostedService<Jobs.JobsHostedService>();
if (globalSettings.SelfHosted)
{

View File

@ -0,0 +1,11 @@
namespace Bit.Core.Enums
{
public enum EmergencyAccessStatusType : byte
{
Invited = 0,
Accepted = 1,
Confirmed = 2,
RecoveryInitiated = 3,
RecoveryApproved = 4,
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Enums
{
public enum EmergencyAccessType : byte
{
View = 0,
Takeover = 1,
}
}

View File

@ -0,0 +1,19 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
This email is to notify you that {{GranteeEmail}} has accepted your invitation to become an emergency access contact.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
To confirm this user, log into the Bitwarden web vault, go to settings and confirm the user.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
If you do not wish to confirm this user, you can also remove them on the same page.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,9 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
{{Name}} has approved your emergency request. You may now login on the web vault and access their account.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,3 @@
{{#>BasicTextLayout}}
{{Name}} has approved your emergency request. You may now login on the web vault and access their account.
{{/BasicTextLayout}}

View File

@ -0,0 +1,14 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
This email is to notify you that you have been confirmed as an emergency access contect for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"></b>{{Name}}</b>.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
You can now initiate emergency access requests from the web vault.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,21 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
You have been invited to become an emergency contact for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Name}}</b>.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Become emergency contact
</a>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you do not wish to become an emergency contact for {{Name}}, you can safely ignore this email.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,14 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
{{Name}} has initiated an emergency request to <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Action}}</b> your account. You may login on the web vault and manually approve or reject this request.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you do nothing, the request will be automatically approved after <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{DaysLeft}} day(s)</b>.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,14 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
{{Name}} has a pending emergency request to <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Action}}</b> your account. You may login on the web vault and manually approve or reject this request.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you do nothing, the request will be automatically approved after <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{DaysLeft}} day(s)</b>.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,9 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
{{Name}} has been granted emergency request to <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Action}}</b> your account. You may login on the web vault and manually revoke this request.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -0,0 +1,9 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
{{Name}} has rejected your emergency request.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,3 @@
{{#>BasicTextLayout}}
{{Name}} has rejected your emergency request.
{{/BasicTextLayout}}

View File

@ -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; }
}
}

View File

@ -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<CipherDetails> ciphers)
: base("emergencyAccessView")
{
KeyEncrypted = emergencyAccess.KeyEncrypted;
Ciphers = ciphers.Select(c => new CipherResponseModel(c, globalSettings));
}
public string KeyEncrypted { get; set; }
public IEnumerable<CipherResponseModel> Ciphers { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail
{
public class EmergencyAccessAcceptedViewModel : BaseMailModel
{
public string GranteeEmail { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail
{
public class EmergencyAccessApprovedViewModel : BaseMailModel
{
public string Name { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail
{
public class EmergencyAccessConfirmedViewModel : BaseMailModel
{
public string Name { get; set; }
}
}

View File

@ -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}";
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Models.Mail
{
public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel
{
public string Name { get; set; }
public string Action { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail
{
public class EmergencyAccessRejectedViewModel : BaseMailModel
{
public string Name { get; set; }
}
}

View File

@ -0,0 +1,46 @@
using System;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Table
{
public class EmergencyAccess : ITableObject<Guid>
{
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,
};
}
}
}

View File

@ -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<EmergencyAccess, Guid>
{
Task<int> GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers);
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGrantorIdAsync(Guid grantorId);
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGranteeIdAsync(Guid granteeId);
Task<EmergencyAccessDetails> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId);
Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync();
Task<ICollection<EmergencyAccessDetails>> GetExpiredRecoveriesAsync();
}
}

View File

@ -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<EmergencyAccess, Guid>, IEmergencyAccessRepository
{
public EmergencyAccessRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public EmergencyAccessRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<int> GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteScalarAsync<int>(
"[dbo].[EmergencyAccess_ReadCountByGrantorIdEmail]",
new { GrantorId = grantorId, Email = email, OnlyUsers = onlyRegisteredUsers },
commandType: CommandType.StoredProcedure);
return results;
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGrantorIdAsync(Guid grantorId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadByGrantorId]",
new { GrantorId = grantorId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGranteeIdAsync(Guid granteeId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadByGranteeId]",
new { GranteeId = granteeId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<EmergencyAccessDetails> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadByIdGrantorId]",
new { Id = id, GrantorId = grantorId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync()
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessNotify>(
"[dbo].[EmergencyAccess_ReadToNotify]",
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetExpiredRecoveriesAsync()
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadExpiredRecoveries]",
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}
}

View File

@ -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<EmergencyAccess> InviteAsync(User invitingUser, string invitingUsersName, string email, EmergencyAccessType type, int waitTime);
Task ResendInviteAsync(Guid invitingUserId, Guid emergencyAccessId, string invitingUsersName);
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService);
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
Task<EmergencyAccessDetails> 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<EmergencyAccessViewResponseModel> ViewAsync(Guid id, User user);
}
}

View File

@ -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);
}
}

View File

@ -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<User> _passwordHasher;
public EmergencyAccessService(
IEmergencyAccessRepository emergencyAccessRepository,
IUserRepository userRepository,
ICipherRepository cipherRepository,
IMailService mailService,
IPasswordHasher<User> passwordHasher,
IDataProtectionProvider dataProtectionProvider,
GlobalSettings globalSettings)
{
_emergencyAccessRepository = emergencyAccessRepository;
_userRepository = userRepository;
_cipherRepository = cipherRepository;
_mailService = mailService;
_passwordHasher = passwordHasher;
_dataProtector = dataProtectionProvider.CreateProtector("EmergencyAccessServiceDataProtector");
_globalSettings = globalSettings;
}
public async Task<EmergencyAccess> 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<EmergencyAccessDetails> 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<EmergencyAccess> 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<EmergencyAccess> 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<TwoFactorProviderType, TwoFactorProvider>());
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<EmergencyAccessViewResponseModel> 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);
}
}
}

View File

@ -489,5 +489,121 @@ namespace Bit.Core.Services
writer.WriteSafeString($"<a href=\"{href}\" target=\"_blank\" {clickTrackingText}>{text}</a>");
});
}
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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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]));

View File

@ -80,6 +80,7 @@ namespace Bit.Core.Utilities
services.AddSingleton<ISsoUserRepository, SqlServerRepos.SsoUserRepository>();
services.AddSingleton<ISendRepository, SqlServerRepos.SendRepository>();
services.AddSingleton<ITaxRateRepository, SqlServerRepos.TaxRateRepository>();
services.AddSingleton<IEmergencyAccessRepository, SqlServerRepos.EmergencyAccessRepository>();
}
if (globalSettings.SelfHosted)
@ -112,6 +113,7 @@ namespace Bit.Core.Utilities
services.AddScoped<IGroupService, GroupService>();
services.AddScoped<IPolicyService, PolicyService>();
services.AddScoped<Services.IEventService, EventService>();
services.AddScoped<IEmergencyAccessService, EmergencyAccessService>();
services.AddSingleton<IDeviceService, DeviceService>();
services.AddSingleton<IAppleIapService, AppleIapService>();
services.AddSingleton<ISsoConfigService, SsoConfigService>();

View File

@ -69,6 +69,7 @@
<Folder Include="dbo\User Defined Types\" />
</ItemGroup>
<ItemGroup>
<Build Include="dbo\Stored Procedures\EmergencyAccessDetails_ReadByIdGrantorId.sql" />
<Build Include="dbo\Stored Procedures\SsoConfig_Create.sql" />
<Build Include="dbo\Stored Procedures\SsoConfig_ReadByIdentifier.sql" />
<Build Include="dbo\Stored Procedures\SsoConfig_ReadByOrganizationId.sql" />
@ -287,6 +288,18 @@
<Build Include="dbo\Views\SendView.sql" />
<Build Include="dbo\Stored Procedures\OrganizationUser_ReadByUserIds.sql" />
<Build Include="dbo\Stored Procedures\Send_ReadByDeletionDateBefore.sql" />
<Build Include="dbo\Tables\EmergencyAccess.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_Create.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_ReadById.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_ReadCountByGrantorIdEmail.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_Update.sql" />
<Build Include="dbo\Views\EmergencyAccessDetailsView.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccessDetails_ReadByGrantorId.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_DeleteById.sql" />
<Build Include="dbo\Stored Procedures\User_BumpAccountRevisionDateByEmergencyAccessGranteeId.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccessDetails_ReadByGranteeId.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccess_ReadToNotify.sql" />
<Build Include="dbo\Stored Procedures\EmergencyAccessDetails_ReadExpiredRecoveries.sql" />
<Build Include="dbo\Tables\TaxRate.sql" />
<Build Include="dbo\Stored Procedures\TaxRate_Search.sql" />
<Build Include="dbo\Stored Procedures\TaxRate_ReadByLocation.sql" />
@ -298,4 +311,3 @@
<Build Include="dbo\Stored Procedures\OrganizationUserOrganizationDetails_ReadByUserIdStatusOrganizationId.sql" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGranteeId]
@GranteeId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[GranteeId] = @GranteeId
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadByGrantorId]
@GrantorId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[GrantorId] = @GrantorId
END

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[EmergencyAccess_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccess]
WHERE
[Id] = @Id
END

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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])
)

View File

@ -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]

View File

@ -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