Attachment azure upload blobs (#1345)

* Update Size limits

* Add new Api paths for direct upload of Cipher Attachments

* Add Attachment upload to fileUploadService

* Save with direct upload and fallback to legacy uplaod

CipherID is required for direct upload to request an upload URL

* Inform on when to remove legacy code

* Test Attachment upload
This commit is contained in:
Matt Gibson 2021-03-30 18:42:43 -05:00 committed by GitHub
parent 04aeddc5de
commit ce0b8bc62d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 292 additions and 25 deletions

View File

@ -227,7 +227,7 @@
Clicked="ChooseFile_Clicked" />
<Label
Margin="0, 5, 0, 0"
Text="{u:I18n MaxFileSizeSend}"
Text="{u:I18n MaxFileSize}"
StyleClass="text-sm, text-muted"
HorizontalOptions="FillAndExpand"
HorizontalTextAlignment="Center" />

View File

@ -102,7 +102,7 @@ namespace Bit.App.Pages
AppResources.AnErrorHasOccurred);
return false;
}
if (FileData.Length > 104857600) // 100 MB
if (FileData.Length > 524288000) // 500 MB
{
await _platformUtilsService.ShowDialogAsync(AppResources.MaxFileSize,
AppResources.AnErrorHasOccurred);

View File

@ -477,7 +477,7 @@ namespace Bit.App.Pages
await _deviceActionService.ShowLoadingAsync(AppResources.Downloading);
try
{
var data = await _cipherService.DownloadAndDecryptAttachmentAsync(attachment, Cipher.OrganizationId);
var data = await _cipherService.DownloadAndDecryptAttachmentAsync(Cipher.Id, attachment, Cipher.OrganizationId);
await _deviceActionService.HideLoadingAsync();
if (data == null)
{

View File

@ -926,9 +926,6 @@
<value>Feature Unavailable</value>
</data>
<data name="MaxFileSize" xml:space="preserve">
<value>Maximum file size is 100 MB.</value>
</data>
<data name="MaxFileSizeSend" xml:space="preserve">
<value>Maximum file size is 500 MB.</value>
</data>
<data name="UpdateKey" xml:space="preserve">

View File

@ -46,9 +46,14 @@ namespace Bit.Core.Abstractions
Task<TResponse> SendAsync<TRequest, TResponse>(HttpMethod method, string path,
TRequest body, bool authed, bool hasResponse);
void SetUrls(EnvironmentUrls urls);
Task<CipherResponse> PostCipherAttachmentAsync(string id, MultipartFormDataContent data);
[Obsolete("Mar 25 2021: This method has been deprecated in favor of direct uploads. This method still exists for backward compatibility with old server versions.")]
Task<CipherResponse> PostCipherAttachmentLegacyAsync(string id, MultipartFormDataContent data);
Task<AttachmentUploadDataResponse> PostCipherAttachmentAsync(string id, AttachmentRequest request);
Task<AttachmentResponse> GetAttachmentData(string cipherId, string attachmentId);
Task PostShareCipherAttachmentAsync(string id, string attachmentId, MultipartFormDataContent data,
string organizationId);
Task<AttachmentUploadDataResponse> RenewAttachmentUploadUrlAsync(string id, string attachmentId);
Task PostAttachmentFileAsync(string id, string attachmentId, MultipartFormDataContent data);
Task<List<BreachAccountResponse>> GetHibpBreachAsync(string username);
Task PostTwoFactorEmailAsync(TwoFactorEmailRequest request);
Task PutDeviceTokenAsync(string identifier, DeviceTokenRequest request);

View File

@ -35,7 +35,7 @@ namespace Bit.Core.Abstractions
Task UpdateLastUsedDateAsync(string id);
Task UpsertAsync(CipherData cipher);
Task UpsertAsync(List<CipherData> cipher);
Task<byte[]> DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId);
Task<byte[]> DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId);
Task SoftDeleteWithServerAsync(string id);
Task RestoreWithServerAsync(string id);
}

View File

@ -4,6 +4,7 @@ using Bit.Core.Models.Response;
namespace Bit.Core.Abstractions {
public interface IFileUploadService {
Task UploadCipherAttachmentFileAsync(AttachmentUploadDataResponse uploadData, string fileName, byte[] encryptedFileData);
Task UploadSendFileAsync(SendFileUploadDataResponse uploadData, CipherString fileName, byte[] encryptedFileData);
}
}

View File

@ -6,5 +6,6 @@ namespace Bit.Core.Models.Request
{
public string FileName { get; set; }
public string Key { get; set; }
public long FileSize { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Enums;
namespace Bit.Core.Models.Response
{
public class AttachmentUploadDataResponse
{
public string AttachmentId { get; set; }
public FileUploadType FileUploadType { get; set; }
public CipherResponse CipherResponse { get; set; }
public string Url { get; set; }
}
}

View File

@ -303,16 +303,26 @@ namespace Bit.Core.Services
#region Attachments APIs
public Task<CipherResponse> PostCipherAttachmentAsync(string id, MultipartFormDataContent data)
[Obsolete("Mar 25 2021: This method has been deprecated in favor of direct uploads. This method still exists for backward compatibility with old server versions.")]
public Task<CipherResponse> PostCipherAttachmentLegacyAsync(string id, MultipartFormDataContent data)
{
return SendAsync<MultipartFormDataContent, CipherResponse>(HttpMethod.Post,
string.Concat("/ciphers/", id, "/attachment"), data, true, true);
}
public Task<AttachmentUploadDataResponse> PostCipherAttachmentAsync(string id, AttachmentRequest request)
{
return SendAsync<AttachmentRequest, AttachmentUploadDataResponse>(HttpMethod.Post,
$"/ciphers/{id}/attachment/v2", request, true, true);
}
public Task<AttachmentResponse> GetAttachmentData(string cipherId, string attachmentId) =>
SendAsync<AttachmentResponse>(HttpMethod.Get, $"/ciphers/{cipherId}/attachment/{attachmentId}", true);
public Task DeleteCipherAttachmentAsync(string id, string attachmentId)
{
return SendAsync<object, object>(HttpMethod.Delete,
string.Concat("/ciphers/", id, "/attachment/", attachmentId), null, true, false);
return SendAsync(HttpMethod.Delete,
string.Concat("/ciphers/", id, "/attachment/", attachmentId), true);
}
public Task PostShareCipherAttachmentAsync(string id, string attachmentId, MultipartFormDataContent data,
@ -323,6 +333,13 @@ namespace Bit.Core.Services
data, true, false);
}
public Task<AttachmentUploadDataResponse> RenewAttachmentUploadUrlAsync(string cipherId, string attachmentId) =>
SendAsync<AttachmentUploadDataResponse>(HttpMethod.Get, $"/ciphers/{cipherId}/attachment/{attachmentId}", true);
public Task PostAttachmentFileAsync(string cipherId, string attachmentId, MultipartFormDataContent data) =>
SendAsync(HttpMethod.Post,
$"/ciphers/{cipherId}/attachment/{attachmentId}", data, true);
#endregion
#region Sync APIs
@ -437,6 +454,12 @@ namespace Bit.Core.Services
}
}
public Task SendAsync(HttpMethod method, string path, bool authed) =>
SendAsync<object, object>(method, path, null, authed, false);
public Task SendAsync<TRequest>(HttpMethod method, string path, TRequest body, bool authed) =>
SendAsync<TRequest, object>(method, path, body, authed, false);
public Task<TResponse> SendAsync<TResponse>(HttpMethod method, string path, bool authed) =>
SendAsync<object, TResponse>(method, path, null, authed, true);
public async Task<TResponse> SendAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest body,
bool authed, bool hasResponse)
{

View File

@ -30,6 +30,7 @@ namespace Bit.Core.Services
private readonly IUserService _userService;
private readonly ISettingsService _settingsService;
private readonly IApiService _apiService;
private readonly IFileUploadService _fileUploadService;
private readonly IStorageService _storageService;
private readonly II18nService _i18nService;
private readonly Func<ISearchService> _searchService;
@ -47,6 +48,7 @@ namespace Bit.Core.Services
IUserService userService,
ISettingsService settingsService,
IApiService apiService,
IFileUploadService fileUploadService,
IStorageService storageService,
II18nService i18nService,
Func<ISearchService> searchService,
@ -57,6 +59,7 @@ namespace Bit.Core.Services
_userService = userService;
_settingsService = settingsService;
_apiService = apiService;
_fileUploadService = fileUploadService;
_storageService = storageService;
_i18nService = i18nService;
_searchService = searchService;
@ -553,21 +556,47 @@ namespace Bit.Core.Services
public async Task<Cipher> SaveAttachmentRawWithServerAsync(Cipher cipher, string filename, byte[] data)
{
var key = await _cryptoService.GetOrgKeyAsync(cipher.OrganizationId);
var encFileName = await _cryptoService.EncryptAsync(filename, key);
var dataEncKey = await _cryptoService.MakeEncKeyAsync(key);
var encData = await _cryptoService.EncryptToBytesAsync(data, dataEncKey.Item1);
var boundary = string.Concat("--BWMobileFormBoundary", DateTime.UtcNow.Ticks);
var fd = new MultipartFormDataContent(boundary);
fd.Add(new StringContent(dataEncKey.Item2.EncryptedString), "key");
fd.Add(new StreamContent(new MemoryStream(encData)), "data", encFileName.EncryptedString);
var response = await _apiService.PostCipherAttachmentAsync(cipher.Id, fd);
var orgKey = await _cryptoService.GetOrgKeyAsync(cipher.OrganizationId);
var encFileName = await _cryptoService.EncryptAsync(filename, orgKey);
var (attachmentKey, orgEncAttachmentKey) = await _cryptoService.MakeEncKeyAsync(orgKey);
var encFileData = await _cryptoService.EncryptToBytesAsync(data, attachmentKey);
CipherResponse response;
try
{
var request = new AttachmentRequest
{
Key = orgEncAttachmentKey.EncryptedString,
FileName = encFileName.EncryptedString,
FileSize = encFileData.Length,
};
var uploadDataResponse = await _apiService.PostCipherAttachmentAsync(cipher.Id, request);
response = uploadDataResponse.CipherResponse;
await _fileUploadService.UploadCipherAttachmentFileAsync(uploadDataResponse, encFileName.EncryptedString, encFileData);
}
catch (ApiException e) when (e.Error.StatusCode == System.Net.HttpStatusCode.NotFound || e.Error.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed)
{
response = await LegacyServerAttachmentFileUploadAsync(cipher.Id, encFileName, encFileData, orgEncAttachmentKey);
}
var userId = await _userService.GetUserIdAsync();
var cData = new CipherData(response, userId, cipher.CollectionIds);
await UpsertAsync(cData);
return new Cipher(cData);
}
[Obsolete("Mar 25 2021: This method has been deprecated in favor of direct uploads. This method still exists for backward compatibility with old server versions.")]
private async Task<CipherResponse> LegacyServerAttachmentFileUploadAsync(string cipherId,
CipherString encFileName, byte[] encFileData, CipherString key)
{
var boundary = string.Concat("--BWMobileFormBoundary", DateTime.UtcNow.Ticks);
var fd = new MultipartFormDataContent(boundary);
fd.Add(new StringContent(key.EncryptedString), "key");
fd.Add(new StreamContent(new MemoryStream(encFileData)), "data", encFileName.EncryptedString);
return await _apiService.PostCipherAttachmentLegacyAsync(cipherId, fd);
}
public async Task SaveCollectionsWithServerAsync(Cipher cipher)
{
var request = new CipherCollectionsRequest(cipher.CollectionIds?.ToList());
@ -706,11 +735,23 @@ namespace Bit.Core.Services
}
}
public async Task<byte[]> DownloadAndDecryptAttachmentAsync(AttachmentView attachment, string organizationId)
public async Task<byte[]> DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId)
{
string url;
try
{
var response = await _httpClient.GetAsync(new Uri(attachment.Url));
var attachmentDownloadResponse = await _apiService.GetAttachmentData(cipherId, attachment.Id);
url = attachmentDownloadResponse.Url;
}
// TODO: Delete this catch when all Servers are updated to respond to the above method
catch (ApiException e) when (e.Error.StatusCode == System.Net.HttpStatusCode.NotFound)
{
url = attachment.Url;
}
try
{
var response = await _httpClient.GetAsync(new Uri(url));
if (!response.IsSuccessStatusCode)
{
return null;

View File

@ -19,6 +19,35 @@ namespace Bit.Core.Services {
private readonly AzureFileUploadService _azureFileUploadService;
private readonly ApiService _apiService;
public async Task UploadCipherAttachmentFileAsync(AttachmentUploadDataResponse uploadData,
string encryptedFileName, byte[] encryptedFileData)
{
try
{
switch (uploadData.FileUploadType)
{
case FileUploadType.Direct:
await _bitwardenFileUploadService.Upload(encryptedFileName, encryptedFileData,
fd => _apiService.PostAttachmentFileAsync(uploadData.CipherResponse.Id, uploadData.AttachmentId, fd));
break;
case FileUploadType.Azure:
Func<Task<string>> renewalCallback = async () =>
{
var response = await _apiService.RenewAttachmentUploadUrlAsync(uploadData.CipherResponse.Id, uploadData.AttachmentId);
return response.Url;
};
await _azureFileUploadService.Upload(uploadData.Url, encryptedFileData, renewalCallback);
break;
default:
throw new Exception($"Unkown file upload type: {uploadData.FileUploadType}");
}
} catch
{
await _apiService.DeleteCipherAttachmentAsync(uploadData.CipherResponse.Id, uploadData.AttachmentId);
throw;
}
}
public async Task UploadSendFileAsync(SendFileUploadDataResponse uploadData, CipherString fileName, byte[] encryptedFileData)
{
try
@ -41,10 +70,10 @@ namespace Bit.Core.Services {
throw new Exception("Unknown file upload type");
}
}
catch (Exception e)
catch (Exception)
{
await _apiService.DeleteSendAsync(uploadData.SendResponse.Id);
throw e;
throw;
}
}
}

View File

@ -41,7 +41,7 @@ namespace Bit.Core.Utilities
var userService = new UserService(storageService, tokenService);
var settingsService = new SettingsService(userService, storageService);
var fileUploadService = new FileUploadService(apiService);
var cipherService = new CipherService(cryptoService, userService, settingsService, apiService,
var cipherService = new CipherService(cryptoService, userService, settingsService, apiService, fileUploadService,
storageService, i18nService, () => searchService, clearCipherCacheKey, allClearCipherCacheKeys);
var folderService = new FolderService(cryptoService, userService, apiService, storageService,
i18nService, cipherService);

View File

@ -0,0 +1,62 @@
using System;
using AutoFixture;
using Bit.Core.Models.Domain;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
namespace Bit.Core.Test.AutoFixture
{
internal class OrganizationCipher : ICustomization
{
public string OrganizationId { get; set; }
public void Customize(IFixture fixture)
{
fixture.Customize<Cipher>(composer => composer
.With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid().ToString()));
}
}
internal class UserCipher : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Customize<Cipher>(composer => composer
.Without(c => c.OrganizationId));
}
}
internal class UserCipherAutoDataAttribute : CustomAutoDataAttribute
{
public UserCipherAutoDataAttribute() : base(new SutProviderCustomization(),
new UserCipher())
{ }
}
internal class InlineUserCipherAutoDataAttribute : InlineCustomAutoDataAttribute
{
public InlineUserCipherAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
typeof(UserCipher) }, values)
{ }
}
internal class InlineKnownUserCipherAutoDataAttribute : InlineCustomAutoDataAttribute
{
public InlineKnownUserCipherAutoDataAttribute(string userId, params object[] values) : base(new ICustomization[]
{ new SutProviderCustomization(), new UserCipher() }, values)
{ }
}
internal class OrganizationCipherAutoDataAttribute : CustomAutoDataAttribute
{
public OrganizationCipherAutoDataAttribute(string organizationId = null) : base(new SutProviderCustomization(),
new OrganizationCipher { OrganizationId = organizationId ?? null })
{ }
}
internal class InlineOrganizationCipherAutoDataAttribute : InlineCustomAutoDataAttribute
{
public InlineOrganizationCipherAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
typeof(OrganizationCipher) }, values)
{ }
}
}

View File

@ -0,0 +1,96 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Exceptions;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Request;
using Bit.Core.Models.Response;
using Bit.Core.Models.View;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services
{
public class CipherServiceTests
{
[Theory, UserCipherAutoData]
public async Task SaveWithServerAsync_PrefersFileUploadService(SutProvider<CipherService> sutProvider,
Cipher cipher, string fileName, byte[] data, AttachmentUploadDataResponse uploadDataResponse, CipherString encKey)
{
sutProvider.GetDependency<ICryptoService>().EncryptAsync(fileName, Arg.Any<SymmetricCryptoKey>())
.Returns(new CipherString(fileName));
sutProvider.GetDependency<ICryptoService>().EncryptToBytesAsync(data, Arg.Any<SymmetricCryptoKey>())
.Returns(data);
sutProvider.GetDependency<ICryptoService>().MakeEncKeyAsync(Arg.Any<SymmetricCryptoKey>()).Returns(new Tuple<SymmetricCryptoKey, CipherString>(null, encKey));
sutProvider.GetDependency<IApiService>().PostCipherAttachmentAsync(cipher.Id, Arg.Any<AttachmentRequest>())
.Returns(uploadDataResponse);
await sutProvider.Sut.SaveAttachmentRawWithServerAsync(cipher, fileName, data);
await sutProvider.GetDependency<IFileUploadService>().Received(1)
.UploadCipherAttachmentFileAsync(uploadDataResponse, fileName, data);
}
[Theory]
[InlineUserCipherAutoData(HttpStatusCode.NotFound)]
[InlineUserCipherAutoData(HttpStatusCode.MethodNotAllowed)]
public async Task SaveWithServerAsync_FallsBackToLegacyFormData(HttpStatusCode statusCode,
SutProvider<CipherService> sutProvider, Cipher cipher, string fileName, byte[] data,
CipherResponse response, CipherString encKey)
{
sutProvider.GetDependency<ICryptoService>().EncryptAsync(fileName, Arg.Any<SymmetricCryptoKey>())
.Returns(new CipherString(fileName));
sutProvider.GetDependency<ICryptoService>().EncryptToBytesAsync(data, Arg.Any<SymmetricCryptoKey>())
.Returns(data);
sutProvider.GetDependency<ICryptoService>().MakeEncKeyAsync(Arg.Any<SymmetricCryptoKey>()).Returns(new Tuple<SymmetricCryptoKey, CipherString>(null, encKey));
sutProvider.GetDependency<IApiService>().PostCipherAttachmentAsync(cipher.Id, Arg.Any<AttachmentRequest>())
.Throws(new ApiException(new ErrorResponse {StatusCode = statusCode}));
sutProvider.GetDependency<IApiService>().PostCipherAttachmentLegacyAsync(cipher.Id, Arg.Any<MultipartFormDataContent>())
.Returns(response);
await sutProvider.Sut.SaveAttachmentRawWithServerAsync(cipher, fileName, data);
await sutProvider.GetDependency<IApiService>().Received(1)
.PostCipherAttachmentLegacyAsync(cipher.Id, Arg.Any<MultipartFormDataContent>());
}
[Theory, UserCipherAutoData]
public async Task SaveWithServerAsync_ThrowsOnBadRequestApiException(SutProvider<CipherService> sutProvider,
Cipher cipher, string fileName, byte[] data, CipherString encKey)
{
sutProvider.GetDependency<ICryptoService>().EncryptAsync(fileName, Arg.Any<SymmetricCryptoKey>())
.Returns(new CipherString(fileName));
sutProvider.GetDependency<ICryptoService>().EncryptToBytesAsync(data, Arg.Any<SymmetricCryptoKey>())
.Returns(data);
sutProvider.GetDependency<ICryptoService>().MakeEncKeyAsync(Arg.Any<SymmetricCryptoKey>())
.Returns(new Tuple<SymmetricCryptoKey, CipherString>(null, encKey));
var expectedException = new ApiException(new ErrorResponse { StatusCode = HttpStatusCode.BadRequest });
sutProvider.GetDependency<IApiService>().PostCipherAttachmentAsync(cipher.Id, Arg.Any<AttachmentRequest>())
.Throws(expectedException);
var actualException = await Assert.ThrowsAsync<ApiException>(async () =>
await sutProvider.Sut.SaveAttachmentRawWithServerAsync(cipher, fileName, data));
Assert.Equal(expectedException.Error.StatusCode, actualException.Error.StatusCode);
}
[Theory, CustomAutoData(typeof(SutProviderCustomization), typeof(SymmetricCryptoKeyCustomization))]
public async Task DownloadAndDecryptAttachmentAsync_RequestsTimeLimitedUrl(SutProvider<CipherService> sutProvider,
string cipherId, AttachmentView attachment, AttachmentResponse response)
{
sutProvider.GetDependency<IApiService>().GetAttachmentData(cipherId, attachment.Id)
.Returns(response);
await sutProvider.Sut.DownloadAndDecryptAttachmentAsync(cipherId, attachment, null);
sutProvider.GetDependency<IApiService>().Received(1).GetAttachmentData(cipherId, attachment.Id);
}
}
}