关于ASP.NET Core Identity双因素令牌使用后仍有效及限制其单次使用的技术问询
嘿,这个问题提得很实在!确实,ASP.NET Core Identity默认生成的双因素令牌(就像你用await _userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider);生成的邮件令牌)是时间驱动型的,默认有效期一般是5分钟左右,而且在有效期内可以重复使用——这确实存在安全风险,要是令牌不小心泄露,别人在有效期内就能多次用它登录。
那能不能改成用一次就失效呢?答案是肯定的,但默认Identity并没有内置这个功能,得咱们自己扩展一下逻辑。下面给你两个可行的实现方案:
方案一:自定义令牌提供器(TokenProvider)
你可以实现一个自定义的IUserTwoFactorTokenProvider<TUser>,核心思路是:生成令牌时把它和用户ID、使用状态、过期时间存在数据库里(比如新增一张TwoFactorTokenHistory表),验证时先查令牌是否过期、是否未被使用,验证通过后立刻标记为已使用。
给你个简化版的代码参考:
public class SingleUseEmailTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser> where TUser : class { private readonly ApplicationDbContext _dbContext; private readonly IOptions<DataProtectionTokenProviderOptions> _tokenOptions; private readonly IDataProtector _dataProtector; public SingleUseEmailTokenProvider(ApplicationDbContext dbContext, IOptions<DataProtectionTokenProviderOptions> tokenOptions, IDataProtectionProvider dataProtectionProvider) { _dbContext = dbContext; _tokenOptions = tokenOptions; _dataProtector = dataProtectionProvider.CreateProtector("SingleUseEmailTokenProtection"); } public async Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user) { var userId = await manager.GetUserIdAsync(user); // 生成随机令牌 var rawToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); var expiresAt = DateTime.UtcNow.Add(_tokenOptions.Value.TokenLifespan); // 加密后存入数据库 _dbContext.TwoFactorTokenHistories.Add(new TwoFactorTokenHistory { UserId = userId, Token = _dataProtector.Protect(rawToken), Purpose = purpose, IsUsed = false, ExpiresAt = expiresAt }); await _dbContext.SaveChangesAsync(); return rawToken; } public async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user) { var userId = await manager.GetUserIdAsync(user); var protectedToken = _dataProtector.Protect(token); // 查询有效未使用的令牌 var tokenRecord = await _dbContext.TwoFactorTokenHistories .FirstOrDefaultAsync(t => t.UserId == userId && t.Purpose == purpose && t.Token == protectedToken && !t.IsUsed && t.ExpiresAt > DateTime.UtcNow); if (tokenRecord == null) return false; // 标记为已使用 tokenRecord.IsUsed = true; await _dbContext.SaveChangesAsync(); return true; } // 实现接口要求的其他方法 public Task<bool> CanGenerateAsync(UserManager<TUser> manager, TUser user) { return Task.FromResult(true); } }
然后在Program.cs(或Startup.cs)里注册这个自定义提供器:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddTokenProvider<SingleUseEmailTokenProvider<ApplicationUser>>("SingleUseEmail");
之后生成令牌时就用这个自定义的提供器名称:
await _userManager.GenerateTwoFactorTokenAsync(user, "SingleUseEmail");
方案二:复用Identity自带的AspNetUserTokens表
Identity本身有一张AspNetUserTokens表,专门用来存储用户的各类令牌。你可以利用这张表,在生成令牌后把它存进去,验证时先查是否存在且未被使用,验证通过后立刻删除。
生成令牌的示例代码:
// 生成默认时间令牌 var token = await _userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); // 存入AspNetUserTokens表,标记为单次使用的邮件令牌 await _userManager.SetAuthenticationTokenAsync(user, "SingleUseTwoFactor", "EmailToken", token);
验证令牌的逻辑:
// 取出存储的令牌 var storedToken = await _userManager.GetAuthenticationTokenAsync(user, "SingleUseTwoFactor", "EmailToken"); if (storedToken == null || storedToken != token) { return false; } // 先验证原令牌是否在有效期内 var isTokenValid = await _userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider, token); if (isTokenValid) { // 删除令牌,确保无法再次使用 await _userManager.RemoveAuthenticationTokenAsync(user, "SingleUseTwoFactor", "EmailToken"); return true; } return false;
这个方案的优点是不用额外建表,缺点是如果用户短时间内生成多个令牌,你需要定时清理过期的旧令牌,避免表数据冗余。
额外提示
- 不管用哪个方案,都要记得清理过期的令牌数据,比如加个定时任务,每天删除过期且未使用的令牌。
- 存储令牌时尽量加密,避免明文存储带来的安全风险,方案一里用的
IDataProtector就是个不错的选择。
总的来说,默认的时间令牌不支持单次使用,但通过扩展Identity的令牌逻辑,完全能实现这个需求,你可以根据自己项目的实际情况选合适的方案~
备注:内容来源于stack exchange,提问作者roczstar

