1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05: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
60 changed files with 2073 additions and 3 deletions

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