Data Protection provider across Asp.NET Core and Framework (generate password reset link)

CularBytes picture CularBytes · Feb 19, 2018 · Viewed 8.4k times · Source

I am running into this problem relating to the DataProtectionProvider, first we only had 2 .NET Framework projects, now a .NET Core project is added which confuses me how to do the following: Generate a password reset link from a .NET Framework project, and use it in the .NET Core project. Both use the same database and user table, which have been made compatible with each other. .NET Framework is still the leading project in Code-First database generation.

In both .NET Frameworks projects, I use one shared code bases, which has the following code:

//not sure where I got this from but it is part of the solution for solving
//password link generating and using in two different applications.
public class MachineKeyProtectionProvider : IDataProtectionProvider
{
    public IDataProtector Create(params string[] purposes)
    {
        return new MachineKeyDataProtector(purposes);
    }
}

public class MachineKeyDataProtector : IDataProtector
{
    private readonly string[] _purposes;

    public MachineKeyDataProtector(string[] purposes)
    {
        _purposes = purposes;
    }

    public byte[] Protect(byte[] userData)
    {
        return MachineKey.Protect(userData, _purposes);
    }

    public byte[] Unprotect(byte[] protectedData)
    {
        return MachineKey.Unprotect(protectedData, _purposes);
    }
}

Then inside the User Repository:

    private readonly UserManager<ApplicationUser> _userManager = null;
    private readonly RoleManager<IdentityRole> _roleManager = null;

    internal static IDataProtectionProvider DataProtectionProvider { get; private set; }
    public UserRepository(DatabaseContext dbContext)
    {
        _userManager = new UserManager<ApplicationUser>(new UserStore<ApplicationUser>(dbContext));
        _roleManager = new RoleManager<IdentityRole>(new RoleStore<IdentityRole>(dbContext));
        _userManager.UserValidator = new UserValidator<ApplicationUser>(_userManager) { AllowOnlyAlphanumericUserNames = false };
        if (DataProtectionProvider == null)
        {
            DataProtectionProvider = new MachineKeyProtectionProvider();
        }
        _userManager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser, string>(DataProtectionProvider.Create("Identity"));
    }

In both .NET Framework projects I have a <machineKey> set. After that I can use simply:

    public string GeneratePasswordResetCode(string userId)
    {
        return _userManager.GeneratePasswordResetToken(userId);
    }

    public void ChangeUserPassword(string oldPassword, string newPassword)
    {
        string id = InfrastructureUserHelper.User.GetUserId();
        IdentityResult result = _userManager.ChangePassword(id, oldPassword, newPassword);
        ...
    }

So, now a .NET Core project is added, which already has its own password reset mechanism working, but all the automated jobs are sent from one of the .NET Framework projects. Reason being that a user should set a password for an automatically created account.

How do I do this? I have been looking at this: https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?tabs=aspnetcore2x and this: How to implement machineKey in ASP.NET Core 2.0

But I cannot really figure out what a simple and easy solution is. I prefer to avoid creating an additional redis server. Something similar to machine key would do the job. I tried starting with this documentation but I could not really figure out what part goes in the .NET Core project and what part goes into the .NET Framework project.

I have tried playing around with this part without luck so far:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDataProtection().SetApplicationName("Identity")
            .SetDefaultKeyLifetime(TimeSpan.FromDays(60))
            .ProtectKeysWithDpapi();

        services.AddIdentity<ApplicationUser, ApplicationRole>().AddDefaultTokenProviders();
    }

EDIT: I eventually could use our Blob Storage for this, seeing this page: https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?tabs=aspnetcore2x

Adding this does seem to help a bit, both apps run fine but still in the .NET Framework project does not use the correct DataProtectionProvider. The UserManager cannot find it (No ITokenProvider error).

Eventually I kind of gave up and stored the token in the user database totally not ideal but to much time spent trying to solve something what is to much undocumented. -1 for Microsoft.

Answer

McGuireV10 picture McGuireV10 · Feb 19, 2018

The Data Protection API will work the same in .NET Framework and .NET Core.

Instead of a machine key, encrypt the keys with an X509 certificate rather than the old machine-key approach. A self-generated certificate is fine since the encryption is purely for internal use.

Important: If you control the servers, the certificate must be installed in the certificate store. There's a ridiculous, undocumented catch to using the ProtectWithCertificate overloads that accept an X509 certificate instance: it can only use that instance to encrypt. Decryption fails if the cert isn't in the store. Microsoft claims this is some limitation of "the underlying framework," whatever that means, but workarounds are available (and not complicated). I use a variation on this and the cert is serialized to Azure Key Vault so that we don't have to touch each individual server.

You also need to specify one of the DPAPI persistence options to ensure the data is accessible to all the servers. Since we're using Azure, my code below uses blob storage, but if you're running your own servers, this could be a network share via PersistKeysToFileSystem.

My setup in ConfigureServices looks like this:

var x509 = GetDpApiCert(); // library utility
var container = GetBlobStorageRef(); // library

services.AddDataProtection()
    .SetApplicationName(appconfig["DpapiSiteName"])
    .ProtectKeysWithProvidedCertificate(x509)
    .PersistKeysToAzureBlobStorage(container, appconfig["DpapiFileName"]);

Here's my Powershell script to generate a certificate:

[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)][string]$password = "",
    [Parameter(Mandatory=$true)][string]$rootDomain = ""
)

$cwd = Convert-Path .
$CerFile = "$cwd\aspnet_dpapi.cer"
$PfxFile = "$cwd\aspnet_dpapi.pfx"

# abort if files exist
if((Test-Path($PfxFile)) -or (Test-Path($CerFile)))
{
    Write-Warning "Failed, aspnet_dpapi already exists in $cwd"
    Exit
}

$cert = New-SelfSignedCertificate `
        -Subject $rootDomain `
        -DnsName $rootDomain `
        -FriendlyName "ASP.NET Data Protection $rootDomain" `
        -NotBefore (Get-Date) `
        -NotAfter (Get-Date).AddYears(10) `
        -CertStoreLocation "cert:CurrentUser\My" `
        -KeyAlgorithm RSA `
        -Provider "Microsoft Enhanced RSA and AES Cryptographic Provider" `
        -KeyLength 2048 `
        -KeyUsage KeyEncipherment, DataEncipherment
        # -HashAlgorithm SHA256 `
        # -Type Custom,DocumentEncryptionCert `
        # -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.1")

$store = 'Cert:\CurrentUser\My\' + ($cert.ThumbPrint)  
$securePass = ConvertTo-SecureString -String $password -Force -AsPlainText

Export-Certificate -Cert $store -FilePath $CerFile
Export-PfxCertificate -Cert $store -FilePath $PfxFile -Password $securePass