Search code examples
c#asp.net-coreasp.net-identitytokenidentity

ASP.NET Core + Identity: the token cannot be unprotected after a certain time


We have an ASP.NET Core application using identity to deal with user identification. There is something wrong that happens when the user wants to confirm his /her email.

In the Startup.ConfigureServices(IServiceCollection services), the setup is achieved following this configuration:

services  
.AddIdentity<ApplicationUser, IdentityRole>(options =>
            {
                options.SignIn.RequireConfirmedEmail = true;
                options.Password.RequireDigit = true;
                options.Password.RequiredLength = 6;
                options.Password.RequireLowercase = true;
                options.Password.RequireNonAlphanumeric = true;
                options.Password.RequireUppercase = true;
                options.User.RequireUniqueEmail = true;
                options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ@-._0123456789";
                options.Lockout.MaxFailedAccessAttempts = 5;
                options.Lockout.AllowedForNewUsers = true;
                options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                options.Tokens.ProviderMap.Add("Default", new TokenProviderDescriptor(typeof(IUserTwoFactorTokenProvider<ApplicationUser>)));
            })
            .AddEntityFrameworkStores<OurDataContext>()
            .AddDefaultTokenProviders();

        services.Configure<DataProtectionTokenProviderOptions>(options =>
        {
            options.Name = "Default";
            var tokenDurationActivationLinkString = ConfigurationManager.Instance["TokenDurationInHoursOfActivationAccountLink"];
            var tokenDurationActivationLink = double.Parse(tokenDurationActivationLinkString);
            options.TokenLifespan = TimeSpan.FromHours(tokenDurationActivationLink);
        });

When sending the email with the link to confirm the user email, the token is fetched this way:

var token = await userContext.GenerateEmailConfirmationTokenAsync(user);

The link works fine but even though the duration validation of the link is set to 72 hours in production. The link appeared invalid.

When confirming the email (following the user clicking on link), the controller in charge of doing that, is basically leveraging the ConfirmEmailAsync method:

 var result = await UserManager.ConfirmEmailAsync(user, token);

Turns out that result.Succeeded is true during one or two hours (the duration is actually a bit random, sometimes it returns false after only 45 minutes without any change or modifications on the user) and then starts to return false.

I inspected what ConfirmEmailAsync was doing, in essence:


public virtual async Task<IdentityResult> ConfirmEmailAsync(
  TUser user,
  string token)
{
  this.ThrowIfDisposed();
  IUserEmailStore<TUser> store = this.GetEmailStore(true);
  if ((object) user == null)
    throw new ArgumentNullException(nameof (user));
  if (!await this.VerifyUserTokenAsync(user, this.Options.Tokens.EmailConfirmationTokenProvider, "EmailConfirmation", token))
    return IdentityResult.Failed(this.ErrorDescriber.InvalidToken());
  await store.SetEmailConfirmedAsync(user, true, this.CancellationToken);
  return await this.UpdateUserAsync(user);
}

and VerifyUserTokenAsync is implemented as such:

public virtual async Task<bool> VerifyUserTokenAsync(
  TUser user,
  string tokenProvider,
  string purpose,
  string token)
{
  UserManager<TUser> manager = this;
  manager.ThrowIfDisposed();
  if ((object) user == null)
    throw new ArgumentNullException(nameof (user));
  if (tokenProvider == null)
    throw new ArgumentNullException(nameof (tokenProvider));
  if (!manager._tokenProviders.ContainsKey(tokenProvider))
    throw new NotSupportedException(Microsoft.Extensions.Identity.Core.Resources.FormatNoTokenProvider((object) nameof (TUser), (object) tokenProvider));
  bool result = await manager._tokenProviders[tokenProvider].ValidateAsync(purpose, token, manager, user);
  if (!result)
  {
    ILogger logger = manager.Logger;
    EventId eventId = (EventId) 9;
    object obj = (object) purpose;
    string userIdAsync = await manager.GetUserIdAsync(user);
    logger.LogWarning(eventId, "VerifyUserTokenAsync() failed with purpose: {purpose} for user {userId}.", obj, (object) userIdAsync);
    logger = (ILogger) null;
    eventId = new EventId();
    obj = (object) null;
  }
  return result;
}

By reflection I inspected that the manager._tokenProviders[tokenProvider] is of the type DataProtectorTokenProvider<ApplicationUser> hence its implementation of ValidateAsync is almost the actual meat of our problem:

    public virtual async Task<bool> ValidateAsync(
      string purpose,
      string token,
      UserManager<TUser> manager,
      TUser user)
    {
      try
      {
        using (BinaryReader reader = new MemoryStream(this.Protector.Unprotect(Convert.FromBase64String(token))).CreateReader())
        {
          if (reader.ReadDateTimeOffset() + this.Options.TokenLifespan < DateTimeOffset.UtcNow)
            return false;
          string userId = reader.ReadString();
          if (userId != await manager.GetUserIdAsync(user) || !string.Equals(reader.ReadString(), purpose))
            return false;
          string str1 = reader.ReadString();
          if (reader.PeekChar() != -1)
            return false;
          if (!manager.SupportsUserSecurityStamp)
            return str1 == "";
          string str = str1;
          return str == await manager.GetSecurityStampAsync(user);
        }
      }
      catch
      {
      }
      return false;
    }

After many trials it seems that the issue is that at some point for the very same token, the line:

this.Protector.Unprotect(Convert.FromBase64String(token))

fails at some point in time.

And this is way before even checking that the token contains a date time offset which is expired or not in regard to UtcNow.

The implementation of Protector.Unprotect is one provided by the KeyRingBasedDataProtector class and is a bit Voodoo to me.

However it seems that the error occurs precisely here:

IAuthenticatedEncryptor encryptorByKeyId = currentKeyRing.GetAuthenticatedEncryptorByKeyId(guid, out isRevoked);
if (encryptorByKeyId == null)
{
    this._logger.KeyWasNotFoundInTheKeyRingUnprotectOperationCannotProceed(guid);
    throw Error.Common_KeyNotFound(guid);
}

Considering that this is something I am not supposed to touch (internal classes of Identity) I am wondering why the same token would sometimes fail to be unprotected after a certain duration. It seems really voodoo to me. The only thing that seems not deterministic is in the KeyRingProvider but I am too sure how this is related to my issue.


Solution

  • I also posted my issues on MS Github

    And realized the codebase actually lacked the configuration for the key storage providers.

    I ended up adding a safe path as part of the data protection configuration in the ConfigureServices in Startup.cs:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(@"our-secret-path-goes-here"));
    }
    

    And since then, everything works like a charm.