We have a customer facing website and a back office for creating users. Creating a new user with a welcome email with password reset works flawlessly when running both application on IIS Express on our Developer machines. However when we deploy the applications and the applications are hosted on different IIS servers with different Application Pool Identities it stops working.
We have been able to replicate the error offline on the same server but with different Application Pool Identities. If we switch so that the applications use the same Application Pool Identity in IIS everything starts working again.
Back Office:
applicationDbContext = new ApplicationDbContext();
userManager = new ApplicationUserManager(new ApplicationUserStore(applicationDbContext), applicationDbContext);
var createdUser = userManager.FindByEmail(newUser.Email);
var provider = new DpapiDataProtectionProvider("Application.Project");
userManager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser, int>(provider.Create("ASP.NET Identity"));
var token = userManager.GeneratePasswordResetToken(createdUser.Id);
Customer Portal:
var applicationDbContext = new ApplicationDbContext();
userManager = new ApplicationUserManager(new ApplicationUserStore(applicationDbContext), applicationDbContext);
var user = await userManager.FindByEmailAsync(model.Email);
if (user == null)
{
return GetErrorResult(IdentityResult.Failed());
}
var provider = new DpapiDataProtectionProvider("Application.Project");
userManager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser, int>(provider.Create("ASP.NET Identity"));
//This code fails with different Application Pool Identities
if (!await userManager.UserTokenProvider.ValidateAsync("ResetPassword", model.Token, userManager, user))
{
return GetErrorResult(IdentityResult.Failed());
}
var result = await userManager.ResetPasswordAsync(user.Id, model.Token, model.NewPassword);
IdentityResult says Succeeded
false but no error code. Is there anyway around this or do we need to implement token generation and validation ourselves?
This turned out to be a bit tricky. Found a few references but they used MachineKey
on the same server. I wanted it to be across different servers and users altogether.
Data Protection provider across Asp.NET Core and Framework (generate password reset link)
Since I did not get an error code I started with implementing my own ValidateAsync
with the help from DataProtectionTokenProvider.cs
for ASP.NET Core Identity. This Class really helped me find a solution.
https://github.com/aspnet/Identity/blob/master/src/Identity/DataProtectionTokenProvider.cs
I ended up with the following error:
Key not valid for use in specified state.
Tokens are generated from the SecurityStamp
when using DataProtectorTokenProvider<TUser, TKey>
but its hard to dig deeper. However given that the verification fails when the Application Pool Identity
is changed on a single server points to that the actual protection mechanism would look something like this:
System.Security.Cryptography.ProtectedData.Protect(userData, entropy, DataProtectionScope.CurrentUser);
Given that it works if all sites use the same Application Pool Identity
points to this as well. It could also be DataProtectionProvider
with protectionDescriptor
"LOCAL=user"
.
new DataProtectionProvider("LOCAL=user")
https://docs.microsoft.com/en-us/previous-versions/aspnet/dn613280(v%3dvs.108)
https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotector?view=netframework-4.7.2
https://docs.microsoft.com/en-us/uwp/api/windows.security.cryptography.dataprotection.dataprotectionprovider
When reading about DpapiDataProtectionProvider
(DPAPI stands for Data Protection Application Programming Interface) the description says:
Used to provide the data protection services that are derived from the Data Protection API. It is the best choice of data protection when you application is not hosted by ASP.NET and all processes are running as the same domain identity.
The Create method purposes are described as:
Additional entropy used to ensure protected data may only be unprotected for the correct purposes.
https://docs.microsoft.com/en-us/previous-versions/aspnet/dn253784(v%3dvs.113)
Given this information I saw no way forward in trying to use the normal classes provided by Microsoft
.
I ended up implementing my own IUserTokenProvider<TUser, TKey>
, IDataProtectionProvider
and IDataProtector
to get it right instead.
I choose to implement IDataProtector
with certificates since I can relatively easy transfer these between servers. I can also pick it up from the X509Store
with the Application Pool Identity
that runs the website so no keys are stored in the application itself.
public class CertificateProtectorTokenProvider<TUser, TKey> : IUserTokenProvider<TUser, TKey>
where TUser : class, IUser<TKey>
where TKey : IEquatable<TKey>
{
private IDataProtector protector;
public CertificateProtectorTokenProvider(IDataProtector protector)
{
this.protector = protector;
}
public virtual async Task<string> GenerateAsync(string purpose, UserManager<TUser, TKey> manager, TUser user)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
var ms = new MemoryStream();
using (var writer = new BinaryWriter(ms, new UTF8Encoding(false, true), true))
{
writer.Write(DateTimeOffset.UtcNow.UtcTicks);
writer.Write(Convert.ToInt32(user.Id));
writer.Write(purpose ?? "");
string stamp = null;
if (manager.SupportsUserSecurityStamp)
{
stamp = await manager.GetSecurityStampAsync(user.Id);
}
writer.Write(stamp ?? "");
}
var protectedBytes = protector.Protect(ms.ToArray());
return Convert.ToBase64String(protectedBytes);
}
public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser, TKey> manager, TUser user)
{
try
{
var unprotectedData = protector.Unprotect(Convert.FromBase64String(token));
var ms = new MemoryStream(unprotectedData);
using (var reader = new BinaryReader(ms, new UTF8Encoding(false, true), true))
{
var creationTime = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero);
var expirationTime = creationTime + TimeSpan.FromDays(1);
if (expirationTime < DateTimeOffset.UtcNow)
{
return false;
}
var userId = reader.ReadInt32();
var actualUser = await manager.FindByIdAsync(user.Id);
var actualUserId = Convert.ToInt32(actualUser.Id);
if (userId != actualUserId)
{
return false;
}
var purp = reader.ReadString();
if (!string.Equals(purp, purpose))
{
return false;
}
var stamp = reader.ReadString();
if (reader.PeekChar() != -1)
{
return false;
}
if (manager.SupportsUserSecurityStamp)
{
return stamp == await manager.GetSecurityStampAsync(user.Id);
}
return stamp == "";
}
}
catch (Exception e)
{
// Do not leak exception
}
return false;
}
public Task NotifyAsync(string token, UserManager<TUser, TKey> manager, TUser user)
{
throw new NotImplementedException();
}
public Task<bool> IsValidProviderForUserAsync(UserManager<TUser, TKey> manager, TUser user)
{
throw new NotImplementedException();
}
}
public class CertificateProtectionProvider : IDataProtectionProvider
{
public IDataProtector Create(params string[] purposes)
{
return new CertificateDataProtector(purposes);
}
}
public class CertificateDataProtector : IDataProtector
{
private readonly string[] _purposes;
private X509Certificate2 cert;
public CertificateDataProtector(string[] purposes)
{
_purposes = purposes;
X509Store store = null;
store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
var certificateThumbprint = ConfigurationManager.AppSettings["CertificateThumbprint"].ToUpper();
cert = store.Certificates.Cast<X509Certificate2>()
.FirstOrDefault(x => x.GetCertHashString()
.Equals(certificateThumbprint, StringComparison.InvariantCultureIgnoreCase));
}
public byte[] Protect(byte[] userData)
{
using (RSA rsa = cert.GetRSAPrivateKey())
{
// OAEP allows for multiple hashing algorithms, what was formermly just "OAEP" is
// now OAEP-SHA1.
return rsa.Encrypt(userData, RSAEncryptionPadding.OaepSHA1);
}
}
public byte[] Unprotect(byte[] protectedData)
{
// GetRSAPrivateKey returns an object with an independent lifetime, so it should be
// handled via a using statement.
using (RSA rsa = cert.GetRSAPrivateKey())
{
return rsa.Decrypt(protectedData, RSAEncryptionPadding.OaepSHA1);
}
}
}
Customer website reset:
var provider = new CertificateProtectionProvider();
var protector = provider.Create("ResetPassword");
userManager.UserTokenProvider = new CertificateProtectorTokenProvider<ApplicationUser, int>(protector);
if (!await userManager.UserTokenProvider.ValidateAsync("ResetPassword", model.Token, UserManager, user))
{
return GetErrorResult(IdentityResult.Failed());
}
var result = await userManager.ResetPasswordAsync(user.Id, model.Token, model.NewPassword);
Back Office:
var createdUser = userManager.FindByEmail(newUser.Email);
var provider = new CertificateProtectionProvider();
var protector = provider.Create("ResetPassword");
userManager.UserTokenProvider = new CertificateProtectorTokenProvider<ApplicationUser, int>(protector);
var token = userManager.GeneratePasswordResetToken(createdUser.Id);
A bit more information about how the normal DataProtectorTokenProvider<TUser, TKey>
works:
https://stackoverflow.com/a/53390287/3850405
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With