我们有一个使用Identity来处理用户身份的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);
});
在发送链接的电子邮件以确认用户电子邮件时,将令牌以这种方式获取:
var token = await userContext.GenerateEmailConfirmationTokenAsync(user);
该链接工作正常,但即使链接的持续时间验证设置为生产72小时。链接出现无效。
确认电子邮件(在用户单击链接之后(时,负责执行此操作的控制器基本上是利用ConfirmEmailAsync
方法:
var result = await UserManager.ConfirmEmailAsync(user, token);
事实证明,在一个或两个小时内result.Succeeded
是true
(实际上是有点随机的,有时它仅在45分钟后返回false
,而无需对用户进行任何更改或修改(,然后开始返回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
有关或未过期。
Protector.Unprotect
的实现是由KeyRingBasedDataProtector
类提供的,对我来说有点伏都教。
然而,似乎错误在这里完全发生:
IAuthenticatedEncryptor encryptorByKeyId = currentKeyRing.GetAuthenticatedEncryptorByKeyId(guid, out isRevoked);
if (encryptorByKeyId == null)
{
this._logger.KeyWasNotFoundInTheKeyRingUnprotectOperationCannotProceed(guid);
throw Error.Common_KeyNotFound(guid);
}
考虑到这是我不应该触摸的东西(身份的内部类别(,我想知道为什么在一定持续时间后,有时同样的令牌有时会不会受到保护。对我来说似乎真的是Voodoo。唯一似乎不确定性的是KeyRingProvider
,但我太确定这与我的问题有何关系。
我还在ms github上发布了我的问题
并意识到代码库实际上缺乏关键存储提供商的配置。
我最终在Startup.cs
中的ConfigureServices
中添加了安全路径,作为数据保护配置的一部分:
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(@"our-secret-path-goes-here"));
}
从那以后,一切都像魅力一样工作。