diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 2a3dfbd045..a9a50e7bc1 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -218,5 +218,31 @@ namespace Bit.Api.Controllers model.Sites.Select(s => s.ToSite(User.GetUserId())).ToList(), model.SiteRelationships); } + + [HttpPost("delete")] + public async Task PostDelete([FromBody]DeleteAccountRequestModel model) + { + var user = _currentContext.User; + if(!await _userManager.CheckPasswordAsync(user, model.MasterPasswordHash)) + { + ModelState.AddModelError("MasterPasswordHash", "Invalid password."); + await Task.Delay(2000); + } + else + { + var result = await _userService.DeleteAsync(user); + if(result.Succeeded) + { + return; + } + + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + throw new BadRequestException(ModelState); + } } } diff --git a/src/Api/Models/Request/Accounts/DeleteAccountRequestModel.cs b/src/Api/Models/Request/Accounts/DeleteAccountRequestModel.cs new file mode 100644 index 0000000000..0a0f5f9bd9 --- /dev/null +++ b/src/Api/Models/Request/Accounts/DeleteAccountRequestModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Models +{ + public class DeleteAccountRequestModel + { + [Required] + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Core/Identity/UserStore.cs b/src/Core/Identity/UserStore.cs index 8e265dfd9f..354ce6e804 100644 --- a/src/Core/Identity/UserStore.cs +++ b/src/Core/Identity/UserStore.cs @@ -18,10 +18,14 @@ namespace Bit.Core.Identity IUserSecurityStampStore { private readonly IUserRepository _userRepository; + private readonly CurrentContext _currentContext; - public UserStore(IUserRepository userRepository) + public UserStore( + IUserRepository userRepository, + CurrentContext currentContext) { _userRepository = userRepository; + _currentContext = currentContext; } public void Dispose() { } @@ -40,16 +44,31 @@ namespace Bit.Core.Identity public async Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken)) { + if(_currentContext?.User != null && _currentContext.User.Email == normalizedEmail) + { + return _currentContext.User; + } + return await _userRepository.GetByEmailAsync(normalizedEmail); } public async Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)) { + if(_currentContext?.User != null && _currentContext.User.Id == userId) + { + return _currentContext.User; + } + return await _userRepository.GetByIdAsync(userId); } public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)) { + if(_currentContext?.User != null && _currentContext.User.Email == normalizedUserName) + { + return _currentContext.User; + } + return await _userRepository.GetByEmailAsync(normalizedUserName); } diff --git a/src/Core/Repositories/DocumentDB/CipherRepository.cs b/src/Core/Repositories/DocumentDB/CipherRepository.cs index 3b127cac2a..533ec6d1cd 100644 --- a/src/Core/Repositories/DocumentDB/CipherRepository.cs +++ b/src/Core/Repositories/DocumentDB/CipherRepository.cs @@ -16,7 +16,7 @@ namespace Bit.Core.Repositories.DocumentDB public async Task UpdateDirtyCiphersAsync(IEnumerable ciphers) { - await DocumentDBHelpers.QueryWithRetryAsync(async () => + await DocumentDBHelpers.ExecuteWithRetryAsync(async () => { // Make sure we are dealing with cipher types since we accept any via dynamic. var cleanedCiphers = ciphers.Where(c => c is Cipher); @@ -42,7 +42,7 @@ namespace Bit.Core.Repositories.DocumentDB public async Task CreateAsync(IEnumerable ciphers) { - await DocumentDBHelpers.QueryWithRetryAsync(async () => + await DocumentDBHelpers.ExecuteWithRetryAsync(async () => { // Make sure we are dealing with cipher types since we accept any via dynamic. var cleanedCiphers = ciphers.Where(c => c is Cipher); diff --git a/src/Core/Repositories/DocumentDB/Stored Procedures/bulkDelete.js b/src/Core/Repositories/DocumentDB/Stored Procedures/bulkDelete.js new file mode 100644 index 0000000000..f7fd97b287 --- /dev/null +++ b/src/Core/Repositories/DocumentDB/Stored Procedures/bulkDelete.js @@ -0,0 +1,81 @@ +/** + * A DocumentDB stored procedure that bulk deletes documents for a given query.
+ * Note: You may need to execute this sproc multiple times (depending whether the sproc is able to delete every document within the execution timeout limit). + * + * @function + * @param {string} query - A query that provides the documents to be deleted (e.g. "SELECT * FROM c WHERE c.founded_year = 2008") + * @returns {Object.} Returns an object with the two properties:
+ * deleted - contains a count of documents deleted
+ * continuation - a boolean whether you should execute the sproc again (true if there are more documents to delete; false otherwise). + */ + +function bulkDelete(query) { + var collection = getContext().getCollection(); + var collectionLink = collection.getSelfLink(); + var response = getContext().getResponse(); + var responseBody = { + deleted: 0, + continuation: true + }; + + // Validate input. + if (!query) throw new Error('The query is undefined or null.'); + + tryQueryAndDelete(); + + // Recursively runs the query w/ support for continuation tokens. + // Calls tryDelete(documents) as soon as the query returns documents. + function tryQueryAndDelete(continuation) { + var requestOptions = { continuation: continuation }; + + var isAccepted = collection.queryDocuments(collectionLink, query, requestOptions, function (err, retrievedDocs, responseOptions) { + if (err) throw err; + + if (retrievedDocs.length > 0) { + // Begin deleting documents as soon as documents are returned form the query results. + // tryDelete() resumes querying after deleting; no need to page through continuation tokens. + // - this is to prioritize writes over reads given timeout constraints. + tryDelete(retrievedDocs); + } + else if (responseOptions.continuation) { + // Else if the query came back empty, but with a continuation token; repeat the query w/ the token. + tryQueryAndDelete(responseOptions.continuation); + } + else { + // Else if there are no more documents and no continuation token - we are finished deleting documents. + responseBody.continuation = false; + response.setBody(responseBody); + } + }); + + // If we hit execution bounds - return continuation: true. + if (!isAccepted) { + response.setBody(responseBody); + } + } + + // Recursively deletes documents passed in as an array argument. + // Attempts to query for more on empty array. + function tryDelete(documents) { + if (documents.length > 0) { + // Delete the first document in the array. + var isAccepted = collection.deleteDocument(documents[0]._self, {}, function (err, responseOptions) { + if (err) throw err; + + responseBody.deleted++; + documents.shift(); + // Delete the next document in the array. + tryDelete(documents); + }); + + // If we hit execution bounds - return continuation: true. + if (!isAccepted) { + response.setBody(responseBody); + } + } + else { + // If the document array is empty, query for more documents. + tryQueryAndDelete(); + } + } +} diff --git a/src/Core/Repositories/DocumentDB/UserRepository.cs b/src/Core/Repositories/DocumentDB/UserRepository.cs index 418c12a107..214d8605ef 100644 --- a/src/Core/Repositories/DocumentDB/UserRepository.cs +++ b/src/Core/Repositories/DocumentDB/UserRepository.cs @@ -28,15 +28,35 @@ namespace Bit.Core.Repositories.DocumentDB public async Task ReplaceAndDirtyCiphersAsync(Domains.User user) { - await DocumentDBHelpers.QueryWithRetryAsync(async () => + await DocumentDBHelpers.ExecuteWithRetryAsync(async () => { - await Client.ExecuteStoredProcedureAsync(ResolveSprocIdLink(user, "replaceUserAndDirtyCiphers"), user); + await Client.ExecuteStoredProcedureAsync( + ResolveSprocIdLink(user, "replaceUserAndDirtyCiphers"), + user); }); } + public override async Task DeleteAsync(Domains.User user) + { + await DeleteByIdAsync(user.Id); + } + public override async Task DeleteByIdAsync(string id) { - await DeleteByPartitionIdAsync(id); + await DocumentDBHelpers.ExecuteWithRetryAsync(async () => + { + while(true) + { + StoredProcedureResponse sprocResponse = await Client.ExecuteStoredProcedureAsync( + ResolveSprocIdLink(id, "bulkDelete"), + string.Format("SELECT * FROM c WHERE c.id = '{0}' OR c.UserId = '{0}'", id)); + + if(!(bool)sprocResponse.Response.continuation) + { + break; + } + } + }); } } } diff --git a/src/Core/Repositories/DocumentDB/Utilities/DocumentDBHelpers.cs b/src/Core/Repositories/DocumentDB/Utilities/DocumentDBHelpers.cs index 0158137625..3ffe3ee4c1 100644 --- a/src/Core/Repositories/DocumentDB/Utilities/DocumentDBHelpers.cs +++ b/src/Core/Repositories/DocumentDB/Utilities/DocumentDBHelpers.cs @@ -31,15 +31,14 @@ namespace Bit.Core.Repositories.DocumentDB.Utilities return client; } - public static async Task QueryWithRetryAsync(Func func) + public static async Task ExecuteWithRetryAsync(Func func) { - var queryComplete = false; - while(!queryComplete) + while(true) { try { await func(); - queryComplete = true; + break; } catch(DocumentClientException e) { diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 6124c8ae71..9ca79570e8 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -18,5 +18,6 @@ namespace Bit.Core.Services Task ChangePasswordAsync(User user, string currentMasterPasswordHash, string newMasterPasswordHash, IEnumerable ciphers); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task GetTwoFactorAsync(User user, Enums.TwoFactorProvider provider); + Task DeleteAsync(User user); } }