From 0e210dadea7f7089dc01a0aa736290cc5dc164df Mon Sep 17 00:00:00 2001 From: Pavel Krymets Date: Tue, 6 Sep 2016 08:09:13 -0700 Subject: [PATCH] Port DataProtection blob XmlRepository (#163) --- DataProtection.sln | 61 +++- NuGetPackageVerifier.json | 1 + samples/AzureBlob/AzureBlob.xproj | 21 ++ samples/AzureBlob/Program.cs | 42 +++ samples/AzureBlob/project.json | 26 ++ .../AzureBlobXmlRepository.cs | 295 ++++++++++++++++++ .../AzureDataProtectionBuilderExtensions.cs | 171 ++++++++++ ...AspNetCore.DataProtection.Azure.Blob.xproj | 19 ++ .../Properties/AssemblyInfo.cs | 12 + .../project.json | 35 +++ .../AzureBlobXmlRepositoryTests.cs | 112 +++++++ ...tCore.DataProtection.Azure.Blob.Test.xproj | 21 ++ .../project.json | 38 +++ ...AspNetCore.DataProtection.Redis.Test.xproj | 4 +- 14 files changed, 846 insertions(+), 12 deletions(-) create mode 100644 samples/AzureBlob/AzureBlob.xproj create mode 100644 samples/AzureBlob/Program.cs create mode 100644 samples/AzureBlob/project.json create mode 100644 src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs create mode 100644 src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj create mode 100644 src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs create mode 100644 src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json create mode 100644 test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs create mode 100644 test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj create mode 100644 test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json diff --git a/DataProtection.sln b/DataProtection.sln index 462c11dab8..20808113c9 100644 --- a/DataProtection.sln +++ b/DataProtection.sln @@ -34,12 +34,24 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataPr EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Redis", "src\Microsoft.AspNetCore.DataProtection.Redis\Microsoft.AspNetCore.DataProtection.Redis.xproj", "{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Redis", "samples\Redis\Redis.xproj", "{24AAEC96-DF46-4F61-B2FF-3D5E056685D9}" +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Azure.Blob", "src\Microsoft.AspNetCore.DataProtection.Azure.Blob\Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj", "{CC799B57-81E2-4F45-8A32-0D5F49753C3F}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{3A6C77DB-FD3D-4B20-A52B-34F7A7E1AED2}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5A3A5DE3-49AD-431C-971D-B01B62D94AE2}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "AzureBlob", "samples\AzureBlob\AzureBlob.xproj", "{B07435B3-CD81-4E3B-88A5-6384821E1C01}" EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Redis.Test", "test\Microsoft.AspNetCore.DataProtection.Redis.Test\Microsoft.AspNetCore.DataProtection.Redis.Test.xproj", "{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E1D86B1B-41D8-43C9-97FD-C2BF65C414E2}" + ProjectSection(SolutionItems) = preProject + global.json = global.json + NuGet.config = NuGet.config + EndProjectSection +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Azure.Blob.Test", "test\Microsoft.AspNetCore.DataProtection.Azure.Blob.Test\Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj", "{8C41240E-48F8-402F-9388-74CFE27F4D76}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Redis", "samples\Redis\Redis.xproj", "{24AAEC96-DF46-4F61-B2FF-3D5E056685D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -156,14 +168,22 @@ Global {0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|Any CPU.Build.0 = Release|Any CPU {0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|x86.ActiveCfg = Release|Any CPU {0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|x86.Build.0 = Release|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.ActiveCfg = Debug|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.Build.0 = Debug|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.Build.0 = Release|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.ActiveCfg = Release|Any CPU - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.Build.0 = Release|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|x86.Build.0 = Debug|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|Any CPU.Build.0 = Release|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|x86.ActiveCfg = Release|Any CPU + {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|x86.Build.0 = Release|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|x86.ActiveCfg = Debug|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|x86.Build.0 = Debug|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|Any CPU.Build.0 = Release|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|x86.ActiveCfg = Release|Any CPU + {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|x86.Build.0 = Release|Any CPU {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|Any CPU.Build.0 = Debug|Any CPU {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -172,6 +192,22 @@ Global {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|Any CPU.Build.0 = Release|Any CPU {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|x86.ActiveCfg = Release|Any CPU {ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|x86.Build.0 = Release|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|x86.Build.0 = Debug|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|Any CPU.Build.0 = Release|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|x86.ActiveCfg = Release|Any CPU + {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|x86.Build.0 = Release|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.Build.0 = Debug|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.Build.0 = Release|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.ActiveCfg = Release|Any CPU + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -191,7 +227,10 @@ Global {04AA8E60-A053-4D50-89FE-E76C3DF45200} = {60336AB3-948D-4D15-A5FB-F32A2B91E814} {BF8681DB-C28B-441F-BD92-0DCFE9537A9F} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA} {0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA} - {24AAEC96-DF46-4F61-B2FF-3D5E056685D9} = {3A6C77DB-FD3D-4B20-A52B-34F7A7E1AED2} + {CC799B57-81E2-4F45-8A32-0D5F49753C3F} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA} + {B07435B3-CD81-4E3B-88A5-6384821E1C01} = {5A3A5DE3-49AD-431C-971D-B01B62D94AE2} {ABCF00E5-5B2F-469C-90DC-908C5A04C08D} = {60336AB3-948D-4D15-A5FB-F32A2B91E814} + {8C41240E-48F8-402F-9388-74CFE27F4D76} = {60336AB3-948D-4D15-A5FB-F32A2B91E814} + {24AAEC96-DF46-4F61-B2FF-3D5E056685D9} = {5A3A5DE3-49AD-431C-971D-B01B62D94AE2} EndGlobalSection EndGlobal diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index af9e7d025d..96174b2f82 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -8,6 +8,7 @@ "Microsoft.AspNetCore.Cryptography.KeyDerivation": { }, "Microsoft.AspNetCore.DataProtection": { }, "Microsoft.AspNetCore.DataProtection.Abstractions": { }, + "Microsoft.AspNetCore.DataProtection.Azure.Blob": { }, "Microsoft.AspNetCore.DataProtection.Extensions": { }, "Microsoft.AspNetCore.DataProtection.Redis": { }, "Microsoft.AspNetCore.DataProtection.SystemWeb": { } diff --git a/samples/AzureBlob/AzureBlob.xproj b/samples/AzureBlob/AzureBlob.xproj new file mode 100644 index 0000000000..52a7e78b7e --- /dev/null +++ b/samples/AzureBlob/AzureBlob.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + b07435b3-cd81-4e3b-88a5-6384821e1c01 + AzureBlob + .\obj + .\bin\ + v4.5.2 + + + + 2.0 + + + diff --git a/samples/AzureBlob/Program.cs b/samples/AzureBlob/Program.cs new file mode 100644 index 0000000000..45a69fb10c --- /dev/null +++ b/samples/AzureBlob/Program.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage; +using Microsoft.AspNetCore.DataProtection.Azure.Blob; + +namespace AzureBlob +{ + public class Program + { + public static void Main(string[] args) + { + var storageAccount = CloudStorageAccount.DevelopmentStorageAccount; + var client = storageAccount.CreateCloudBlobClient(); + var container = client.GetContainerReference("key-container"); + + // The container must exist before calling the DataProtection APIs. + // The specific file within the container does not have to exist, + // as it will be created on-demand. + + container.CreateIfNotExistsAsync().GetAwaiter().GetResult(); + + // Configure + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddDataProtection() + .PersistKeysToAzureBlobStorage(container, "keys.xml"); + + var services = serviceCollection.BuildServiceProvider(); + var loggerFactory = services.GetService(); + loggerFactory.AddConsole(Microsoft.Extensions.Logging.LogLevel.Trace); + + // Run a sample payload + + var protector = services.GetDataProtector("sample-purpose"); + var protectedData = protector.Protect("Hello world!"); + Console.WriteLine(protectedData); + } + } +} diff --git a/samples/AzureBlob/project.json b/samples/AzureBlob/project.json new file mode 100644 index 0000000000..2294f4df5f --- /dev/null +++ b/samples/AzureBlob/project.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0-*", + "buildOptions": { + "emitEntryPoint": true + }, + + "dependencies": { + "Microsoft.AspNetCore.DataProtection": "1.1.0-*", + "Microsoft.AspNetCore.DataProtection.Azure.Blob": "1.1.0-*", + "Microsoft.Extensions.DependencyInjection": "1.1.0-*", + "Microsoft.Extensions.Logging": "1.1.0-*", + "Microsoft.Extensions.Logging.Console": "1.1.0-*", + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.0" + } + }, + + "frameworks": { + "netcoreapp1.0": { + "imports": [ + "portable-net45+win8+wp8+wpa81" + ] + } + } +} diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs new file mode 100644 index 0000000000..0020fdc693 --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs @@ -0,0 +1,295 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.Repositories; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace Microsoft.AspNetCore.DataProtection.Azure.Blob +{ + /// + /// An which is backed by Azure Blob Storage. + /// + /// + /// Instances of this type are thread-safe. + /// + public sealed class AzureBlobXmlRepository : IXmlRepository + { + private const int ConflictMaxRetries = 5; + private static readonly TimeSpan ConflictBackoffPeriod = TimeSpan.FromMilliseconds(200); + + private static readonly XName RepositoryElementName = "repository"; + + private readonly Func _blobRefFactory; + private readonly Random _random; + private BlobData _cachedBlobData; + + /// + /// Creates a new instance of the . + /// + /// A factory which can create + /// instances. The factory must be thread-safe for invocation by multiple + /// concurrent threads, and each invocation must return a new object. + public AzureBlobXmlRepository(Func blobRefFactory) + { + if (blobRefFactory == null) + { + throw new ArgumentNullException(nameof(blobRefFactory)); + } + + _blobRefFactory = blobRefFactory; + _random = new Random(); + } + + public IReadOnlyCollection GetAllElements() + { + var blobRef = CreateFreshBlobRef(); + + // Shunt the work onto a ThreadPool thread so that it's independent of any + // existing sync context or other potentially deadlock-causing items. + + var elements = Task.Run(() => GetAllElementsAsync(blobRef)).GetAwaiter().GetResult(); + return new ReadOnlyCollection(elements); + } + + public void StoreElement(XElement element, string friendlyName) + { + if (element == null) + { + throw new ArgumentNullException(nameof(element)); + } + + var blobRef = CreateFreshBlobRef(); + + // Shunt the work onto a ThreadPool thread so that it's independent of any + // existing sync context or other potentially deadlock-causing items. + + Task.Run(() => StoreElementAsync(blobRef, element)).GetAwaiter().GetResult(); + } + + private XDocument CreateDocumentFromBlob(byte[] blob) + { + using (var memoryStream = new MemoryStream(blob)) + { + var xmlReaderSettings = new XmlReaderSettings() + { + DtdProcessing = DtdProcessing.Prohibit, IgnoreProcessingInstructions = true + }; + + using (var xmlReader = XmlReader.Create(memoryStream, xmlReaderSettings)) + { + return XDocument.Load(xmlReader); + } + } + } + + private ICloudBlob CreateFreshBlobRef() + { + // ICloudBlob instances aren't thread-safe, so we need to make sure we're working + // with a fresh instance that won't be mutated by another thread. + + var blobRef = _blobRefFactory(); + if (blobRef == null) + { + throw new InvalidOperationException("The ICloudBlob factory method returned null."); + } + + return blobRef; + } + + private async Task> GetAllElementsAsync(ICloudBlob blobRef) + { + var data = await GetLatestDataAsync(blobRef); + + if (data == null) + { + // no data in blob storage + return new XElement[0]; + } + + // The document will look like this: + // + // + // + // + // ... + // + // + // We want to return the first-level child elements to our caller. + + var doc = CreateDocumentFromBlob(data.BlobContents); + return doc.Root.Elements().ToList(); + } + + private async Task GetLatestDataAsync(ICloudBlob blobRef) + { + // Set the appropriate AccessCondition based on what we believe the latest + // file contents to be, then make the request. + + var latestCachedData = Volatile.Read(ref _cachedBlobData); // local ref so field isn't mutated under our feet + var accessCondition = (latestCachedData != null) + ? AccessCondition.GenerateIfNoneMatchCondition(latestCachedData.ETag) + : null; + + try + { + using (var memoryStream = new MemoryStream()) + { + await blobRef.DownloadToStreamAsync( + target: memoryStream, + accessCondition: accessCondition, + options: null, + operationContext: null); + + // At this point, our original cache either didn't exist or was outdated. + // We'll update it now and return the updated value; + + latestCachedData = new BlobData() + { + BlobContents = memoryStream.ToArray(), + ETag = blobRef.Properties.ETag + }; + + } + Volatile.Write(ref _cachedBlobData, latestCachedData); + } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 304) + { + // 304 Not Modified + // Thrown when we already have the latest cached data. + // This isn't an error; we'll return our cached copy of the data. + } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) + { + // 404 Not Found + // Thrown when no file exists in storage. + // This isn't an error; we'll delete our cached copy of data. + + latestCachedData = null; + Volatile.Write(ref _cachedBlobData, latestCachedData); + } + + return latestCachedData; + } + + private int GetRandomizedBackoffPeriod() + { + // returns a TimeSpan in the range [0.8, 1.0) * ConflictBackoffPeriod + // not used for crypto purposes + var multiplier = 0.8 + (_random.NextDouble() * 0.2); + return (int) (multiplier * ConflictBackoffPeriod.Ticks); + } + + private async Task StoreElementAsync(ICloudBlob blobRef, XElement element) + { + // holds the last error in case we need to rethrow it + ExceptionDispatchInfo lastError = null; + + for (var i = 0; i < ConflictMaxRetries; i++) + { + if (i > 1) + { + // If multiple conflicts occurred, wait a small period of time before retrying + // the operation so that other writers can make forward progress. + await Task.Delay(GetRandomizedBackoffPeriod()); + } + + if (i > 0) + { + // If at least one conflict occurred, make sure we have an up-to-date + // view of the blob contents. + await GetLatestDataAsync(blobRef); + } + + // Merge the new element into the document. If no document exists, + // create a new default document and inject this element into it. + + var latestData = Volatile.Read(ref _cachedBlobData); + var doc = (latestData != null) + ? CreateDocumentFromBlob(latestData.BlobContents) + : new XDocument(new XElement(RepositoryElementName)); + doc.Root.Add(element); + + // Turn this document back into a byte[]. + + var serializedDoc = new MemoryStream(); + doc.Save(serializedDoc, SaveOptions.DisableFormatting); + + // Generate the appropriate precondition header based on whether or not + // we believe data already exists in storage. + + AccessCondition accessCondition; + if (latestData != null) + { + accessCondition = AccessCondition.GenerateIfMatchCondition(blobRef.Properties.ETag); + } + else + { + accessCondition = AccessCondition.GenerateIfNotExistsCondition(); + blobRef.Properties.ContentType = "application/xml; charset=utf-8"; // set content type on first write + } + + try + { + // Send the request up to the server. + + var serializedDocAsByteArray = serializedDoc.ToArray(); + + await blobRef.UploadFromByteArrayAsync( + buffer: serializedDocAsByteArray, + index: 0, + count: serializedDocAsByteArray.Length, + accessCondition: accessCondition, + options: null, + operationContext: null); + + // If we got this far, success! + // We can update the cached view of the remote contents. + + Volatile.Write(ref _cachedBlobData, new BlobData() + { + BlobContents = serializedDocAsByteArray, + ETag = blobRef.Properties.ETag // was updated by Upload routine + }); + + return; + } + catch (StorageException ex) + when (ex.RequestInformation.HttpStatusCode == 409 || ex.RequestInformation.HttpStatusCode == 412) + { + // 409 Conflict + // This error is rare but can be thrown in very special circumstances, + // such as if the blob in the process of being created. We treat it + // as equivalent to 412 for the purposes of retry logic. + + // 412 Precondition Failed + // We'll get this error if another writer updated the repository and we + // have an outdated view of its contents. If this occurs, we'll just + // refresh our view of the remote contents and try again up to the max + // retry limit. + + lastError = ExceptionDispatchInfo.Capture(ex); + } + } + + // if we got this far, something went awry + lastError.Throw(); + } + + private sealed class BlobData + { + internal byte[] BlobContents; + internal string ETag; + } + } +} diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs new file mode 100644 index 0000000000..0c5ac7299c --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs @@ -0,0 +1,171 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.DataProtection.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Auth; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace Microsoft.AspNetCore.DataProtection.Azure.Blob +{ + /// + /// Contains Azure-specific extension methods for modifying a + /// . + /// + public static class AzureDataProtectionBuilderExtensions + { + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The which + /// should be utilized. + /// A relative path where the key file should be + /// stored, generally specified as "/containerName/[subDir/]keys.xml". + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudStorageAccount storageAccount, string relativePath) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (storageAccount == null) + { + throw new ArgumentNullException(nameof(storageAccount)); + } + if (relativePath == null) + { + throw new ArgumentNullException(nameof(relativePath)); + } + + // Simply concatenate the root storage endpoint with the relative path, + // which includes the container name and blob name. + + var uriBuilder = new UriBuilder(storageAccount.BlobEndpoint); + uriBuilder.Path = uriBuilder.Path.TrimEnd('/') + "/" + relativePath.TrimStart('/'); + + // We can create a CloudBlockBlob from the storage URI and the creds. + + var blobAbsoluteUri = uriBuilder.Uri; + var credentials = storageAccount.Credentials; + + return PersistKeystoAzureBlobStorageInternal(builder, () => new CloudBlockBlob(blobAbsoluteUri, credentials)); + } + + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The full URI where the key file should be stored. + /// The URI must contain the SAS token as a query string parameter. + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, Uri blobUri) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (blobUri == null) + { + throw new ArgumentNullException(nameof(blobUri)); + } + + var uriBuilder = new UriBuilder(blobUri); + + // The SAS token is present in the query string. + + if (string.IsNullOrEmpty(uriBuilder.Query)) + { + throw new ArgumentException( + message: "URI does not have a SAS token in the query string.", + paramName: nameof(blobUri)); + } + + var credentials = new StorageCredentials(uriBuilder.Query); + uriBuilder.Query = null; // no longer needed + var blobAbsoluteUri = uriBuilder.Uri; + + return PersistKeystoAzureBlobStorageInternal(builder, () => new CloudBlockBlob(blobAbsoluteUri, credentials)); + } + + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The where the + /// key file should be stored. + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudBlockBlob blobReference) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (blobReference == null) + { + throw new ArgumentNullException(nameof(blobReference)); + } + + // We're basically just going to make a copy of this blob. + // Use (container, blobName) instead of (storageuri, creds) since the container + // is tied to an existing service client, which contains user-settable defaults + // like retry policy and secondary connection URIs. + + var container = blobReference.Container; + var blobName = blobReference.Name; + + return PersistKeystoAzureBlobStorageInternal(builder, () => container.GetBlockBlobReference(blobName)); + } + + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The in which the + /// key file should be stored. + /// The name of the key file, generally specified + /// as "[subdir/]keys.xml" + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudBlobContainer container, string blobName) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + if (blobName == null) + { + throw new ArgumentNullException(nameof(blobName)); + } + return PersistKeystoAzureBlobStorageInternal(builder, () => container.GetBlockBlobReference(blobName)); + } + + // important: the Func passed into this method must return a new instance with each call + private static IDataProtectionBuilder PersistKeystoAzureBlobStorageInternal(IDataProtectionBuilder config, Func blobRefFactory) + { + config.Services.AddSingleton(services => new AzureBlobXmlRepository(blobRefFactory)); + return config; + } + } +} diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj new file mode 100644 index 0000000000..10f72048c6 --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj @@ -0,0 +1,19 @@ + + + + 14.0.25420 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + cc799b57-81e2-4f45-8a32-0d5f49753c3f + Microsoft.AspNetCore.DataProtection.Azure + .\obj + .\bin\ + + + + 2.0 + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..8c1d02d738 --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; + +[assembly: AssemblyMetadata("Serviceable", "True")] +[assembly: NeutralResourcesLanguage("en-US")] +[assembly: AssemblyCompany("Microsoft Corporation.")] +[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyProduct("Microsoft ASP.NET Core")] diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json new file mode 100644 index 0000000000..27d11056c3 --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json @@ -0,0 +1,35 @@ +{ + "version": "0.1.0-*", + "description": "Microsoft Azure Blob storrage support as key store.", + "packOptions": { + "repository": { + "type": "git", + "url": "git://github.com/aspnet/dataprotection" + }, + "tags": [ + "aspnetcore", + "dataprotection", + "azure", + "blob" + ] + }, + "dependencies": { + "Microsoft.AspNetCore.DataProtection": "1.1.0-*", + "WindowsAzure.Storage": "7.0.2-preview" + }, + "frameworks": { + "net451": {}, + "netstandard1.5": { + "imports": "portable-net45+win8+wp8+wpa81" + } + }, + "buildOptions": { + "allowUnsafe": true, + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk", + "nowarn": [ + "CS1591" + ], + "xmlDoc": true + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs new file mode 100644 index 0000000000..fefee2dad1 --- /dev/null +++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.Azure.Blob; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Blob; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.DataProtection.Azure.Test +{ + public class AzureBlobXmlRepositoryTests + { + [Fact] + public void StoreCreatesBlobWhenNotExist() + { + AccessCondition downloadCondition = null; + AccessCondition uploadCondition = null; + byte[] bytes = null; + BlobProperties properties = new BlobProperties(); + + var mock = new Mock(); + mock.SetupGet(c => c.Properties).Returns(properties); + mock.Setup(c => c.UploadFromByteArrayAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (byte[] buffer, int index, int count, AccessCondition accessCondition, BlobRequestOptions options, OperationContext operationContext) => + { + bytes = buffer.Skip(index).Take(count).ToArray(); + uploadCondition = accessCondition; + await Task.Yield(); + }); + + var repository = new AzureBlobXmlRepository(() => mock.Object); + repository.StoreElement(new XElement("Element"), null); + + Assert.Null(downloadCondition); + Assert.Equal("*", uploadCondition.IfNoneMatchETag); + Assert.Equal("application/xml; charset=utf-8", properties.ContentType); + var element = ""; + + Assert.Equal(bytes, GetEnvelopedContent(element)); + } + + [Fact] + public void StoreUpdatesWhenExistsAndNewerExists() + { + AccessCondition downloadCondition = null; + byte[] bytes = null; + BlobProperties properties = new BlobProperties(); + + var mock = new Mock(); + mock.SetupGet(c => c.Properties).Returns(properties); + mock.Setup(c => c.DownloadToStreamAsync( + It.IsAny(), + It.IsAny(), + null, + null)) + .Returns(async (Stream target, AccessCondition condition, BlobRequestOptions options, OperationContext context) => + { + var data = GetEnvelopedContent(""); + await target.WriteAsync(data, 0, data.Length); + }) + .Verifiable(); + + mock.Setup(c => c.UploadFromByteArrayAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is((AccessCondition cond) => cond.IfNoneMatchETag == "*"), + It.IsAny(), + It.IsAny())) + .Throws(new StorageException(new RequestResult { HttpStatusCode = 412 }, null, null)) + .Verifiable(); + + mock.Setup(c => c.UploadFromByteArrayAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is((AccessCondition cond) => cond.IfNoneMatchETag != "*"), + It.IsAny(), + It.IsAny())) + .Returns(async (byte[] buffer, int index, int count, AccessCondition accessCondition, BlobRequestOptions options, OperationContext operationContext) => + { + bytes = buffer.Skip(index).Take(count).ToArray(); + await Task.Yield(); + }) + .Verifiable(); + + var repository = new AzureBlobXmlRepository(() => mock.Object); + repository.StoreElement(new XElement("Element2"), null); + + mock.Verify(); + Assert.Null(downloadCondition); + Assert.Equal(bytes, GetEnvelopedContent("")); + } + + private static byte[] GetEnvelopedContent(string element) + { + return Encoding.UTF8.GetBytes($"{element}"); + } + } +} diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj new file mode 100644 index 0000000000..5f0d8f3acc --- /dev/null +++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj @@ -0,0 +1,21 @@ + + + + 14.0.25420 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 8c41240e-48f8-402f-9388-74cfe27f4d76 + Microsoft.AspNetCore.DataProtection.Azure.Test + .\obj + .\bin\ + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json new file mode 100644 index 0000000000..4f8f44d11b --- /dev/null +++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json @@ -0,0 +1,38 @@ +{ + "dependencies": { + "dotnet-test-xunit": "2.2.0-*", + "Microsoft.AspNetCore.DataProtection": "1.1.0-*", + "Microsoft.AspNetCore.DataProtection.Azure.Blob": "1.1.0-*", + "Microsoft.AspNetCore.Testing": "1.1.0-*", + "Microsoft.Extensions.DependencyInjection": "1.1.0-*", + "xunit": "2.2.0-*", + "Moq": "4.6.36-*" + }, + "frameworks": { + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0-*", + "type": "platform" + }, + "System.Diagnostics.Process": "4.1.0-*", + "System.Diagnostics.TraceSource": "4.0.0-*" + }, + "imports": [ + "dnxcore50", + "portable-net451+win8" + ] + }, + "net451": { + "frameworkAssemblies": { + "System.Threading.Tasks": "" + } + } + }, + "testRunner": "xunit", + "buildOptions": { + "allowUnsafe": true, + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk" + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj b/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj index 123ca898a3..723cb30927 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj +++ b/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj @@ -11,9 +11,11 @@ .\obj .\bin\ - 2.0 + + + \ No newline at end of file