// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.DataProtection.Repositories
{
///
/// An XML repository backed by a file system.
///
public class FileSystemXmlRepository : IXmlRepository
{
private static readonly Lazy _defaultDirectoryLazy = new Lazy(GetDefaultKeyStorageDirectory);
private readonly ILogger _logger;
///
/// Creates a with keys stored at the given directory.
///
/// The directory in which to persist key material.
/// The .
public FileSystemXmlRepository(DirectoryInfo directory, ILoggerFactory loggerFactory)
{
if (directory == null)
{
throw new ArgumentNullException(nameof(directory));
}
Directory = directory;
_logger = loggerFactory.CreateLogger();
}
///
/// The default key storage directory.
/// On Windows, this currently corresponds to "Environment.SpecialFolder.LocalApplication/ASP.NET/DataProtection-Keys".
/// On Linux and macOS, this currently corresponds to "$HOME/.aspnet/DataProtection-Keys".
///
///
/// This property can return null if no suitable default key storage directory can
/// be found, such as the case when the user profile is unavailable.
///
public static DirectoryInfo DefaultKeyStorageDirectory => _defaultDirectoryLazy.Value;
///
/// The directory into which key material will be written.
///
public DirectoryInfo Directory { get; }
private const string DataProtectionKeysFolderName = "DataProtection-Keys";
private static DirectoryInfo GetKeyStorageDirectoryFromBaseAppDataPath(string basePath)
{
return new DirectoryInfo(Path.Combine(basePath, "ASP.NET", DataProtectionKeysFolderName));
}
public virtual IReadOnlyCollection GetAllElements()
{
// forces complete enumeration
return GetAllElementsCore().ToList().AsReadOnly();
}
private IEnumerable GetAllElementsCore()
{
Directory.Create(); // won't throw if the directory already exists
// Find all files matching the pattern "*.xml".
// Note: Inability to read any file is considered a fatal error (since the file may contain
// revocation information), and we'll fail the entire operation rather than return a partial
// set of elements. If a file contains well-formed XML but its contents are meaningless, we
// won't fail that operation here. The caller is responsible for failing as appropriate given
// that scenario.
foreach (var fileSystemInfo in Directory.EnumerateFileSystemInfos("*.xml", SearchOption.TopDirectoryOnly))
{
yield return ReadElementFromFile(fileSystemInfo.FullName);
}
}
private static DirectoryInfo GetDefaultKeyStorageDirectory()
{
DirectoryInfo retVal;
// Environment.GetFolderPath returns null if the user profile isn't loaded.
var localAppDataFromSystemPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var localAppDataFromEnvPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
var userProfilePath = Environment.GetEnvironmentVariable("USERPROFILE");
var homePath = Environment.GetEnvironmentVariable("HOME");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(localAppDataFromSystemPath))
{
// To preserve backwards-compatibility with 1.x, Environment.SpecialFolder.LocalApplicationData
// cannot take precedence over $LOCALAPPDATA and $HOME/.aspnet on non-Windows platforms
retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath);
}
else if (localAppDataFromEnvPath != null)
{
retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromEnvPath);
}
else if (userProfilePath != null)
{
retVal = GetKeyStorageDirectoryFromBaseAppDataPath(Path.Combine(userProfilePath, "AppData", "Local"));
}
else if (homePath != null)
{
// If LOCALAPPDATA and USERPROFILE are not present but HOME is,
// it's a good guess that this is a *NIX machine. Use *NIX conventions for a folder name.
retVal = new DirectoryInfo(Path.Combine(homePath, ".aspnet", DataProtectionKeysFolderName));
}
else if (!string.IsNullOrEmpty(localAppDataFromSystemPath))
{
// Starting in 2.x, non-Windows platforms may use Environment.SpecialFolder.LocalApplicationData
// but only after checking for $LOCALAPPDATA, $USERPROFILE, and $HOME.
retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath);
}
else
{
return null;
}
Debug.Assert(retVal != null);
try
{
retVal.Create(); // throws if we don't have access, e.g., user profile not loaded
return retVal;
}
catch
{
return null;
}
}
internal static DirectoryInfo GetKeyStorageDirectoryForAzureWebSites()
{
// Azure Web Sites needs to be treated specially, as we need to store the keys in a
// correct persisted location. We use the existence of the %WEBSITE_INSTANCE_ID% env
// variable to determine if we're running in this environment, and if so we then use
// the %HOME% variable to build up our base key storage path.
if (!String.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID")))
{
var homeEnvVar = Environment.GetEnvironmentVariable("HOME");
if (!String.IsNullOrEmpty(homeEnvVar))
{
return GetKeyStorageDirectoryFromBaseAppDataPath(homeEnvVar);
}
}
// nope
return null;
}
private static bool IsSafeFilename(string filename)
{
// Must be non-empty and contain only a-zA-Z0-9, hyphen, and underscore.
return (!String.IsNullOrEmpty(filename) && filename.All(c =>
c == '-'
|| c == '_'
|| ('0' <= c && c <= '9')
|| ('A' <= c && c <= 'Z')
|| ('a' <= c && c <= 'z')));
}
private XElement ReadElementFromFile(string fullPath)
{
_logger.ReadingDataFromFile(fullPath);
using (var fileStream = File.OpenRead(fullPath))
{
return XElement.Load(fileStream);
}
}
public virtual void StoreElement(XElement element, string friendlyName)
{
if (element == null)
{
throw new ArgumentNullException(nameof(element));
}
if (!IsSafeFilename(friendlyName))
{
var newFriendlyName = Guid.NewGuid().ToString();
_logger.NameIsNotSafeFileName(friendlyName, newFriendlyName);
friendlyName = newFriendlyName;
}
StoreElementCore(element, friendlyName);
}
private void StoreElementCore(XElement element, string filename)
{
// We're first going to write the file to a temporary location. This way, another consumer
// won't try reading the file in the middle of us writing it. Additionally, if our process
// crashes mid-write, we won't end up with a corrupt .xml file.
Directory.Create(); // won't throw if the directory already exists
var tempFilename = Path.Combine(Directory.FullName, Guid.NewGuid().ToString() + ".tmp");
var finalFilename = Path.Combine(Directory.FullName, filename + ".xml");
try
{
using (var tempFileStream = File.OpenWrite(tempFilename))
{
element.Save(tempFileStream);
}
// Once the file has been fully written, perform the rename.
// Renames are atomic operations on the file systems we support.
_logger.WritingDataToFile(finalFilename);
File.Move(tempFilename, finalFilename);
}
finally
{
File.Delete(tempFilename); // won't throw if the file doesn't exist
}
}
}
}