ASP.NET Core + Identity: token 在一定时间后无法解除保护

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

我们有一个 ASP.NET 核心应用程序使用身份来处理用户识别。当用户想要确认他/她的电子邮件时出现错误。

Startup.ConfigureServices(IServiceCollection services)中,设置是按照以下配置实现的:

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);
        });

当发送带有 link 的电子邮件以确认用户电子邮件时,令牌是这样获取的:

var token = await userContext.GenerateEmailConfirmationTokenAsync(user);

link 工作正常,但即使 link 的持续时间验证在生产中设置为 72 小时。 link 似乎无效。

确认电子邮件时(在用户单击 link 之后),负责执行此操作的控制器基本上是在利用 ConfirmEmailAsync 方法:

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

原来 result.Succeeded 在一两个小时内是 true (持续时间实际上有点随机,有时仅在 45 分钟后 returns false无需对用户进行任何更改或修改)然后开始 return false.

我检查了 ConfirmEmailAsync 在做什么,本质上:


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);
}

VerifyUserTokenAsync 是这样实现的:

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;
}

通过反思,我检查了 manager._tokenProviders[tokenProvider]DataProtectorTokenProvider<ApplicationUser> 类型,因此它对 ValidateAsync 的实现几乎是我们问题的实际内容:

    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;
    }

经过多次试验,问题似乎是在某些时候对于 完全相同的标记 ,行:

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

在某个时间点失败。

这是在检查令牌是否包含关于 UtcNow.

是否已过期的日期时间偏移量之前的方法

The implementation of Protector.Unprotect is one provided by the KeyRingBasedDataProtector class 对我来说有点巫毒。

然而,错误似乎正好发生在here:

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

考虑到这是我不应该接触的东西(身份的内部类)我想知道为什么同一个令牌有时会在一段时间后无法解除保护。这对我来说真的很巫毒。唯一似乎不确定的是 KeyRingProvider,但我很确定这与我的问题有何关系。

我也在 MS Github

上发布了我的问题

并意识到代码库实际上缺少 key storage providers 的配置。

我最终在 Startup.cs 中的 ConfigureServices 中添加了安全路径作为数据保护配置的一部分:

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

从那以后,一切都很顺利。