19

I have an ASP.NET Core 2.2 application running on multiple instances on an Azure Web App; it uses EF Core 2.2 and ASP.NET Identity.

Everything works fine except the Password Reset flow where a user receives a link with token per e-mail and needs to choose a new password by clicking on that link. It works perfectly locally, but on Azure it always fails with an "Invalid Token" error.

The tokens are HTML encoded and decoded as necessary; and I have checks in place to ensure they match those on the database; URL encoding is not the issue.

I've configured DataProtection to store the keys to an Azure Blob storage, but to no avail. The keys are stored in the blob store all right, but I still get an "Invalid Token" error.

Here's my set up on Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // This needs to happen before "AddMvc"
    // Code for this method shown below
    AddDataProtecion(services);

    services.AddDbContext<MissDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    var sp = services.BuildServiceProvider();

    services.ConfigureApplicationCookie(x =>
    {
        x.Cookie.Name = ".MISS.SharedCookie";
        x.ExpireTimeSpan = TimeSpan.FromHours(8);
        // We need to set the cookie's DataProtectionProvider to ensure it will get stored in the azure blob storage
        x.DataProtectionProvider = sp.GetService<IDataProtectionProvider>();
    });

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


    // https://tech.trailmax.info/2017/07/user-impersonation-in-asp-net-core/
    services.Configure<SecurityStampValidatorOptions>(options => 
    {
        options.ValidationInterval = TimeSpan.FromMinutes(10);
        options.OnRefreshingPrincipal = context =>
        {
            var originalUserIdClaim = context.CurrentPrincipal.FindFirst("OriginalUserId");
            var isImpersonatingClaim = context.CurrentPrincipal.FindFirst("IsImpersonating");
            if (isImpersonatingClaim?.Value == "true" && originalUserIdClaim != null)
            {
                context.NewPrincipal.Identities.First().AddClaim(originalUserIdClaim);
                context.NewPrincipal.Identities.First().AddClaim(isImpersonatingClaim);
            }
            return Task.FromResult(0);
        };
    });

     // some more initialisations here
}

And here is the AddDataProtection method:

/// <summary>
/// Add Data Protection so that cookies don't get invalidated when swapping slots.
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
void AddDataProtecion(IServiceCollection services)
{
    var sasUrl = Configuration.GetValue<string>("DataProtection:SaSUrl");
    var containerName = Configuration.GetValue<string>("DataProtection:ContainerName");
    var applicationName = Configuration.GetValue<string>("DataProtection:ApplicationName");
    var blobName = Configuration.GetValue<string>("DataProtection:BlobName");
    var keyIdentifier = Configuration.GetValue<string>("DataProtection:KeyVaultIdentifier");

    if (sasUrl == null || containerName == null || applicationName == null || blobName == null)
        return;

    var storageUri = new Uri($"{sasUrl}");

    var blobClient = new CloudBlobClient(storageUri);

    var container = blobClient.GetContainerReference(containerName);
    container.CreateIfNotExistsAsync().GetAwaiter().GetResult();

    applicationName = $"{applicationName}-{Environment.EnvironmentName}";
    blobName = $"{applicationName}-{blobName}";

    services.AddDataProtection()
        .SetApplicationName(applicationName)
        .PersistKeysToAzureBlobStorage(container, blobName);
}

I've also tried persisting the keys to the DbContext, but the result is the same: keys are stored, but I still get anInvalid token message when attempting a password reset, Every. Single. Time.

the Request Password Reset method

public async Task RequestPasswordReset(string emailAddress, string ip, Request httpRequest) 
{
    var user = await _userManager.FindByEmailAsync(emailAddress);

    var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);

    var resetRequest = new PasswordResetRequest
    {
        CreationDate = DateTime.Now,
        ExpirationDate = DateTime.Now.AddDays(1),
        UserId = user.Id,
        Token = resetToken,
        IP = ip
    };

    _context.PasswordResetRequests.Add(resetRequest);
    await _context.SaveChangesAsync();

    await SendPasswordResetEmail(user, resetRequest, httpRequest);
}

The Reset password method

Once the user requests a password reset, they receive an e-mail with a link and a token; here's how I attempt to reset the user's password after the user clicks on that link:

public async Task<IdentityResult> ResetPassword(string token, string password) 
{
    // NO PROBLEM HERE - The received token matches with the one in the Db
    var resetRequest = await _context.PasswordResetRequests
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.Token == token);

    var user = await _userManager.FindByIdAsync(resetRequest.UserId);

    // PROBLEM - This method returns "Invalid Token"
    var result = await _userManager.ResetPasswordAsync(user, resetRequest.Token, password);

    if (result.Succeeded)
        await SendPasswordChangedEmail(user);

    return result;
}

As I state in the code comments, the token received in the request matches the one generated in the database, but ResetPasswordAsync does it's own token validation, and that fails.

Any help would still be appreciated

Sergi Papaseit
  • 15,999
  • 16
  • 67
  • 101
  • Have you tried to also protect the keys explicitly by using a multi-intance supported method? I used Azure Key Vault without problems, but there should be other methods, see [here](https://learn.microsoft.com/it-it/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-2.2). – Federico Dipuma May 25 '19 at 10:26
  • Hi @FedericoDipuma, thanks for the suggestion. I've tried creating a Key Vault, but there seems to be a problem with the AD in my Azure account and I cannot create one. I'm starting to wonder whether this is all connected... – Sergi Papaseit May 27 '19 at 07:53
  • @FedericoDipuma, could you share your code using the key vault? I finally managed to sort my Azure account problems and have created a Key Vault – Sergi Papaseit May 28 '19 at 09:40
  • did you find a solution to this? I think I might have the same problem. – Zack Aug 20 '20 at 22:31

1 Answers1

2

It seams your token was generated with a different way. Could you try this ? generate new token :

var code = await UserManager.GeneratePasswordResetTokenAsync(resetRequest.UserId);

and reset password :

var resetResult = await userManager.ResetPasswordAsync(resetRequest.UserId, code, password);

the other case is incorrect HTML encoding for token:

token = HttpUtility.UrlDecode(token) ;

The next case is userManager has to be singleton (or at least tokenProvider class) for each requests.

this is link to source code https://github.com/aspnet/Identity/blob/rel/2.0.0/src/Microsoft.Extensions.Identity.Core/UserManager.cs#L29

manually token processing in case different instances for token providers due to storing tokens into private variable:

private readonly Dictionary<string, IUserTwoFactorTokenProvider<TUser>> _tokenProviders =
            new Dictionary<string, IUserTwoFactorTokenProvider<TUser>>();

The next code might be implemented:

  public override async Task<bool> VerifyUserTokenAsync(TUser user, string tokenProvider, string purpose, string token)
        {
            ThrowIfDisposed();
            if (user == null)
            {
                throw new ArgumentNullException(nameof(user));
            }
            if (tokenProvider == null)
            {
                throw new ArgumentNullException(nameof(tokenProvider));
            }
//should be overriden
// if (!_tokenProviders.ContainsKey(tokenProvider))
//           {
//              throw new 
//NotSupportedException(string.Format(CultureInfo.CurrentCulture, 
//Resources.NoTokenProvider, tokenProvider));
//          }
// Make sure the token is valid
//        var result = await _tokenProviders[tokenProvider].ValidateAsync(purpose, token, this, user);

  //          if (!result)
  //        {
  //          Logger.LogWarning(9, "VerifyUserTokenAsync() failed with //purpose: {purpose} for user {userId}.", purpose, await GetUserIdAsync(user));
       //    }
var resetRequest = await _context.PasswordResetRequests
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.Token == token);
            if (resetRequest == null )
            {
                return IdentityResult.Failed(ErrorDescriber.InvalidToken());
            }

            // Make sure the token is valid
            var result = resetRequest.IsValid();

            if (!result)
            {
                Logger.LogWarning(9, "VerifyUserTokenAsync() failed with purpose: {purpose} for user {userId}.", purpose, await GetUserIdAsync(user));
            }
            return result;
        }
Oleg Bondarenko
  • 1,694
  • 1
  • 16
  • 19
  • Hi Oleg, That's what I'm doing already; I have a method called `RequestPasswordReset`, where I call `var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);`. Then, on the method shown in my question, I handle the request of the user having clicked the password reset link they receive by e-mail. – Sergi Papaseit May 28 '19 at 09:22
  • What is value of TokenLifespan for UserTokenProvider ? – Oleg Bondarenko May 28 '19 at 09:24
  • Also could you validate your token with VerifyUserTokenAsync before using ? Actually this method throws exception await VerifyUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, ResetPasswordTokenPurpose, token) – Oleg Bondarenko May 28 '19 at 09:28
  • Token lifespan is set to the default (1 day). And sure, I will try `VeryfyUserTokenAsync`, but that method gets called inside `userManager.ResetPasswordAsync` anyway, and that's probably where it fails, of course. – Sergi Papaseit May 28 '19 at 09:29
  • it seems issue with encoding url data - could decode it HttpUtility.UrlDecode(token) ? – Oleg Bondarenko May 28 '19 at 09:37
  • Hi Oleg, it isnt; that, otherwise the token received from the request would not match the one in the database (see the first line of my last code snippet). Also, if that were the case, it wouldn't work on my machine. But it does; it's only when deployed to Azure that I have problems. – Sergi Papaseit May 28 '19 at 09:38
  • You are right -token value is correct, could you try use email pair methods for it: GenerateEmailConfirmationTokenAsync and ConfirmEmailAsync , it seams you are using email confirmation functionality. – Oleg Bondarenko May 28 '19 at 09:52
  • `ConfirmEmailAsync` is used to confirm the user's e-mail as far as I knwo, not to validate their password reset token. – Sergi Papaseit May 28 '19 at 09:56
  • When you define your UserManager do you use the same instance of TokenProvider ? It have to be the same (singletone) not new. – Oleg Bondarenko May 28 '19 at 10:19
  • Or at least _userManager should be created only once and used the same instance for each request. – Oleg Bondarenko May 28 '19 at 10:29
  • That is what my question is about, of course. I use `.AddDefaultTokenProviders();` extension method on `AddIdenityt()` to initialise the TokenProvider (see last line of first code snippet); that should take take care of proper initialisation. I'm persisting the token keys to azure (or db) to try and get this all to work, because It will never be the same instance of `UserManager`, since we're dealing multiple instances, different sessions, etc.. But none of it works. – Sergi Papaseit May 28 '19 at 14:39
  • You can define UserManager manually and set it with singletone TokenProvider. If your solution has single instance.But if it is auto-scalable with separate application instances you could override ResetPasswordAsync method and manually validate token stored in database. – Oleg Bondarenko May 28 '19 at 14:58
  • Actually overriding VerifyUserTokenAsync method is better. IsValid() is custom method for validation. – Oleg Bondarenko May 28 '19 at 17:37
  • Oleg, Thanks, this suggestion will probably work, but it's more of a workaround than a solution. I'm making the application less secure, because I'm just checking the token on the database, as opposed to verifying the token against the encryption algorithm that ASP.NET Identity uses. There is no reason why the "normal" setup shouldn't work in Azure, plenty of applications use it :( – Sergi Papaseit May 29 '19 at 07:36
  • Provided workaround is only for automatically extensible application where one instance of application generates token and other one resets password. If both operations executes inside single instance of application just use singleton for TokenProvider and no reason for implementing any workarounds. Could you provide implementation for AddDefaultTokenProviders method ? – Oleg Bondarenko May 29 '19 at 07:49