Asp.NET身份验证2出现“无效令牌”错误

83

我正在使用 Asp.Net-Identity-2,并尝试使用以下方法验证电子邮件验证码。但是我收到了一个"Invalid Token"的错误消息。

  • 我的应用程序用户管理器如下所示:

    public class AppUserManager : UserManager<AppUser>
    {
        public AppUserManager(IUserStore<AppUser> store) : base(store) { }
    
        public static AppUserManager Create(IdentityFactoryOptions<AppUserManager> options, IOwinContext context)
        {
            AppIdentityDbContext db = context.Get<AppIdentityDbContext>();
            AppUserManager manager = new AppUserManager(new UserStore<AppUser>(db));
    
            manager.PasswordValidator = new PasswordValidator { 
                RequiredLength = 6,
                RequireNonLetterOrDigit = false,
                RequireDigit = false,
                RequireLowercase = true,
                RequireUppercase = true
            };
    
            manager.UserValidator = new UserValidator<AppUser>(manager)
            {
                AllowOnlyAlphanumericUserNames = true,
                RequireUniqueEmail = true
            };
    
            var dataProtectionProvider = options.DataProtectionProvider;
    
            //token life span is 3 hours
            if (dataProtectionProvider != null)
            {
                manager.UserTokenProvider =
                   new DataProtectorTokenProvider<AppUser>
                      (dataProtectionProvider.Create("ConfirmationToken"))
                   {
                       TokenLifespan = TimeSpan.FromHours(3)
                   };
            }
    
            manager.EmailService = new EmailService();
    
            return manager;
        } //Create
      } //class
    } //namespace
    
  • 我生成令牌的操作是(即使我在此处检查令牌,也会收到“无效令牌”消息):

  • [AllowAnonymous]
    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult ForgotPassword(string email)
    {
        if (ModelState.IsValid)
        {
            AppUser user = UserManager.FindByEmail(email);
            if (user == null || !(UserManager.IsEmailConfirmed(user.Id)))
            {
                // Returning without warning anything wrong...
                return View("../Home/Index");
    
            } //if
    
            string code = UserManager.GeneratePasswordResetToken(user.Id);
            string callbackUrl = Url.Action("ResetPassword", "Admin", new { Id = user.Id, code = HttpUtility.UrlEncode(code) }, protocol: Request.Url.Scheme);
    
            UserManager.SendEmail(user.Id, "Reset password Link", "Use the following  link to reset your password: <a href=\"" + callbackUrl + "\">link</a>");
    
            //This 2 lines I use tho debugger propose. The result is: "Invalid token" (???)
            IdentityResult result;
            result = UserManager.ConfirmEmail(user.Id, code);
        }
    
        // If we got this far, something failed, redisplay form
        return View();
    
    } //ForgotPassword
    
  • 我的检查令牌的操作是(在这里,每次我检查结果时都会得到“无效令牌”):

  • [AllowAnonymous]
    public async Task<ActionResult> ResetPassword(string id, string code)
    {
    
        if (id == null || code == null)
        {
            return View("Error", new string[] { "Invalid params to reset password." });
        }
    
        IdentityResult result;
    
        try
        {
            result = await UserManager.ConfirmEmailAsync(id, code);
        }
        catch (InvalidOperationException ioe)
        {
            // ConfirmEmailAsync throws when the id is not found.
            return View("Error", new string[] { "Error to reset password:<br/><br/><li>" + ioe.Message + "</li>" });
        }
    
        if (result.Succeeded)
        {
            AppUser objUser = await UserManager.FindByIdAsync(id);
            ResetPasswordModel model = new ResetPasswordModel();
    
            model.Id = objUser.Id;
            model.Name = objUser.UserName;
            model.Email = objUser.Email;
    
            return View(model);
        }
    
        // If we got this far, something failed.
        string strErrorMsg = "";
        foreach(string strError in result.Errors)
        {
            strErrorMsg += "<li>" + strError + "</li>";
        } //foreach
    
        return View("Error", new string[] { strErrorMsg });
    
    } //ForgotPasswordConfirmation
    
    我不知道可能缺少什么或有什么问题...

我不知道可能缺少什么或有什么问题...

24个回答

108

我遇到了这个问题并解决了它,可能会有几个原因。

1. URL编码问题(如果问题“随机”发生)

如果此问题随机发生,您可能会遇到URL编码问题。 由于某种未知的原因,令牌没有为URL安全设计,这意味着在通过URL传递时(例如通过电子邮件发送),它可能包含无效字符。

在这种情况下,应该使用 HttpUtility.UrlEncode(token)HttpUtility.UrlDecode(token)

如oão Pereira在评论中所说,UrlDecode 不是(或者有时不是)必需的。请尝试两种方法。谢谢。

2. 方法不匹配(电子邮件与密码令牌不匹配)

例如:

    var code = await userManager.GenerateEmailConfirmationTokenAsync(user.Id);

    var result = await userManager.ResetPasswordAsync(user.Id, code, newPassword);

电子邮件令牌提供程序生成的令牌无法由重置密码令牌提供程序确认。

但我们将查看为什么会发生这种情况的根本原因。

3. 令牌提供程序的不同实例

即使您正在使用:

var token = await _userManager.GeneratePasswordResetTokenAsync(user.Id);

与...一起

var result = await _userManager.ResetPasswordAsync(user.Id, HttpUtility.UrlDecode(token), newPassword);

错误仍然可能发生。

以下是我旧代码的原因:

public class AccountController : Controller
{
    private readonly UserManager _userManager = UserManager.CreateUserManager(); 

    [AllowAnonymous]
    [HttpPost]
    public async Task<ActionResult> ForgotPassword(FormCollection collection)
    {
        var token = await _userManager.GeneratePasswordResetTokenAsync(user.Id);
        var callbackUrl = Url.Action("ResetPassword", "Account", new { area = "", UserId = user.Id, token = HttpUtility.UrlEncode(token) }, Request.Url.Scheme);

        Mail.Send(...);
    }

并且:

public class UserManager : UserManager<IdentityUser>
{
    private static readonly UserStore<IdentityUser> UserStore = new UserStore<IdentityUser>();
    private static readonly UserManager Instance = new UserManager();

    private UserManager()
        : base(UserStore)
    {
    }

    public static UserManager CreateUserManager()
    {
        var dataProtectionProvider = new DpapiDataProtectionProvider();
        Instance.UserTokenProvider = new DataProtectorTokenProvider<IdentityUser>(dataProtectionProvider.Create());

        return Instance;
    }

注意,在这段代码中,每当创建一个 UserManager(或使用 new 关键字实例化)时,都会生成一个新的 dataProtectionProvider。所以当用户收到电子邮件并点击链接时:

public class AccountController : Controller
{
    private readonly UserManager _userManager = UserManager.CreateUserManager();
    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> ResetPassword(string userId, string token, FormCollection collection)
    {
        var result = await _userManager.ResetPasswordAsync(user.Id, HttpUtility.UrlDecode(token), newPassword);
        if (result != IdentityResult.Success)
            return Content(result.Errors.Aggregate("", (current, error) => current + error + "\r\n"));
        return RedirectToAction("Login");
    }

AccountController不再是旧的控制器,_userManager及其令牌提供程序也不再是旧的。因此,新的令牌提供程序会失败,因为它在内存中没有那个令牌。

因此,我们需要使用单个实例来提供令牌。以下是我的新代码,它可以正常工作:

public class UserManager : UserManager<IdentityUser>
{
    private static readonly UserStore<IdentityUser> UserStore = new UserStore<IdentityUser>();
    private static readonly UserManager Instance = new UserManager();

    private UserManager()
        : base(UserStore)
    {
    }

    public static UserManager CreateUserManager()
    {
        //...
        Instance.UserTokenProvider = TokenProvider.Provider;

        return Instance;
    }

并且:

public static class TokenProvider
{
    [UsedImplicitly] private static DataProtectorTokenProvider<IdentityUser> _tokenProvider;

    public static DataProtectorTokenProvider<IdentityUser> Provider
    {
        get
        {

            if (_tokenProvider != null)
                return _tokenProvider;
            var dataProtectionProvider = new DpapiDataProtectionProvider();
            _tokenProvider = new DataProtectorTokenProvider<IdentityUser>(dataProtectionProvider.Create());
            return _tokenProvider;
        }
    }
}

这可能不是一个优雅的解决方案,但它击中了根本并解决了我的问题。


12
对这个很棒的回答有一个注记! :) 在MVC中,当作为方法参数接收时,令牌必须是“UrlEncoded”,但不应该被“UrlDecoded”,因为它会自动解码。如果我们再次解码它,就会使令牌无效,因为“+”字符会被替换为空格。 - João Pereira
1
这并没有解决我的问题,因为我需要在项目、实例和计算机之间使用令牌。我为此实现了自定义的AES加密,请参见我的答案以获取详细信息:https://dev59.com/D18e5IYBdhLWcg3wwMlW#56355282 - cyptus
使用解决方案#3解决了一个Asp Net Core 3.1应用程序中的问题。 - Krusty
我又遇到了这个问题。上次我使用单个UserManager实例注册消耗UserManager的服务作为单例来解决它。但在这个项目中,如果我做同样的事情,它会抛出一个异常,说我不能将该服务注册为单例,因为UserManager需要瞬态范围。你上面的解决方案无法编译(有很多问题我可以报告)。那么什么是有效的修复方法呢?问题显然是#3(令牌提供程序的不同实例)。 - Krusty
1
我又修好了。问题是由于用户表中缺少SecurityStamp列引起的。我已经将其删除,但没有该列它就无法工作。 - Krusty

74

因为您在此处生成用于重置密码的令牌:

string code = UserManager.GeneratePasswordResetToken(user.Id);

但实际上是在尝试验证电子邮件的令牌:

result = await UserManager.ConfirmEmailAsync(id, code);

这是两个不同的令牌。

在你的问题中,你说你正在尝试验证电子邮件,但你的代码是用于重置密码。你在做哪一个?

如果你需要电子邮件确认,那么通过以下方式生成令牌:

var emailConfirmationCode = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);

并通过确认

var confirmResult = await UserManager.ConfirmEmailAsync(userId, code);

如果您需要重置密码,请使用以下方式生成令牌:

var code = await UserManager.GeneratePasswordResetTokenAsync(user.Id);

然后像这样确认:

var resetResult = await userManager.ResetPasswordAsync(user.Id, code, newPassword);

1
如何使ConfirmEmailAsync在令牌已被使用一次时返回“失败”而不是成功,例如用户尝试从其电子邮件地址重新访问链接? - user2904995
1
要使令牌失效,您需要更改 SecurityStamp 字段。这将使所有以前有效的令牌无效,包括过去使用过的令牌。 - trailmax

45

即使是像这样的代码,我也一直收到“无效令牌”错误:

var emailCode = UserManager.GenerateEmailConfirmationToken(id);
var result = UserManager.ConfirmEmail(id, emailCode);
在我的情况下,问题的原因是我手动创建了用户并将其添加到数据库中,但没有使用UserManager.Create(...)方法。该用户存在于数据库中,但没有安全戳(security stamp)。
有趣的是,GenerateEmailConfirmationToken返回了一个令牌,但没有抱怨缺少安全戳,但是该令牌永远无法验证。

7
在我的情况下,用户已经从旧数据库迁移过来,因此安全标记为空。我运行了以下命令进行修复:UPDATE AspNetUsers SET SecurityStamp = NewID() - user1069816
4
我建议使用 UPDATE AspNetUsers SET SecurityStamp = NewID() WHERE SecurityStamp is null。在我的情况下,一些用户的 SecurityStamp 是正确的,我更喜欢不要管它们。 - TNT
需要记住的一件事是,如果让Identity自行生成GUID,则生成的GUID为小写字母,而NewID()返回大写字母的GUID(至少在SSMS中是这样)。建议使用LOWER(NewID())。 - Christopher Berman
对我来说,问题实际上出在检查令牌上。我使用我的 repo 拉取了用户,而不是使用 UserManager,因此使用我的 repo 用户调用了 ResetPasswordAsync。基本上是同样的问题。 - Yeronimo

24

除此之外,如果代码没有被编码,我已经看到代码本身会失败。

我最近开始使用以下方式对我的代码进行编码:

string code = manager.GeneratePasswordResetToken(user.Id);
code = HttpUtility.UrlEncode(code);

然后当我准备好读回它时:

string code = IdentityHelper.GetCodeFromRequest(Request);
code = HttpUtility.UrlDecode(code);

说实话,我很惊讶它一开始并没有被正确编码。


5
只有在将重置链接的查询字符串值作为参数时才需要对其进行编码。如果您在应用程序中提供密码重置表单,并且代码作为隐藏值或类似内容传递,则可以在不编码的情况下使用它。 - Eric Carlson
嗨,先生。感谢您的指引!我在ASP.NET Core 5.0中以以下方式完成了它:Encoding.UTF8.GetString(Convert.FromBase64String(code)); - Victor HD

17

在我的情况下,我们的AngularJS应用将所有加号(+)转换为空格(“ ”),因此当令牌被传回时确实是无效的。

为了解决这个问题,在我们的AccountController中的ResetPassword方法中,我只需在更新密码之前添加一个替换操作:

code = code.Replace(" ", "+");
IdentityResult result = await AppUserManager.ResetPasswordAsync(user.Id, code, newPassword);

我希望这能对在Web API和AngularJS中使用身份验证的其他人有所帮助。


3
为了更正式地处理,我建议使用以下代码来对用户名和验证码进行 URL 编码,以便将其传递给客户端页面(例如 Angular),让用户设置密码并完成请求:var callbackUrl = new Uri(Request.RequestUri, RequestContext.VirtualPathRoot).AbsoluteUri + $"#/resetPassword?username={WebUtility.UrlEncode(user.UserName)}&code={WebUtility.UrlEncode(code)}"; - Victor
默认令牌是base64编码的,这不是URL安全的,需要进行URL编码。您可以覆盖或包装令牌提供程序,并返回base64url编码的令牌,避免像您已经做过的特殊字符。 - Bart Verkoeijen
它只对'+'执行此操作,还是对其他任何字符都有效。 - Heemanshu Bhalla

15

tl;dr:aspnet core 2.2中注册自定义令牌提供程序,以使用AES加密代替MachineKey保护,要点:
https://gist.github.com/cyptus/dd9b2f90c190aaed4e807177c45c3c8b

我遇到了与cheny指出的相同问题,即需要将令牌提供程序的实例设置为相同。但这对我不起作用,因为:

  • 我有不同的API项目,它们生成令牌并接收用于重置密码的令牌;
  • API可能在不同的虚拟机实例上运行,因此机器密钥不会相同;
  • API可能会重新启动,此时令牌将无效,因为它不再是同一实例。

我可以使用services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("path"))将令牌保存到文件系统中,从而避免重新启动和多个实例共享问题,但无法解决多个项目的问题,因为每个项目都会生成一个自己的文件。

我的解决方案是用自己的逻辑替换MachineKey数据保护逻辑,该逻辑使用AES then HMAC对令牌进行对称加密,并使用我自己设置的密钥在机器、实例和项目之间共享。我从https://gist.github.com/jbtule/4336842#file-aesthenhmac-cs中获取了加密逻辑,并实现了一个自定义的TokenProvider。
    public class AesDataProtectorTokenProvider<TUser> : DataProtectorTokenProvider<TUser> where TUser : class
    {
        public AesDataProtectorTokenProvider(IOptions<DataProtectionTokenProviderOptions> options, ISettingSupplier settingSupplier)
            : base(new AesProtectionProvider(settingSupplier.Supply()), options)
        {
            var settingsLifetime = settingSupplier.Supply().Encryption.PasswordResetLifetime;

            if (settingsLifetime.TotalSeconds > 1)
            {
                Options.TokenLifespan = settingsLifetime;
            }
        }
    }

    public class AesProtectionProvider : IDataProtectionProvider
    {
        private readonly SystemSettings _settings;

        public AesProtectionProvider(SystemSettings settings)
        {
            _settings = settings;

            if(string.IsNullOrEmpty(_settings.Encryption.AESPasswordResetKey))
                throw new ArgumentNullException("AESPasswordResetKey must be set");
        }

        public IDataProtector CreateProtector(string purpose)
        {
            return new AesDataProtector(purpose, _settings.Encryption.AESPasswordResetKey);
        }
    }

    public class AesDataProtector : IDataProtector
    {
        private readonly string _purpose;
        private readonly SymmetricSecurityKey _key;
        private readonly Encoding _encoding = Encoding.UTF8;

        public AesDataProtector(string purpose, string key)
        {
            _purpose = purpose;
            _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        }

        public byte[] Protect(byte[] userData)
        {
            return AESThenHMAC.SimpleEncryptWithPassword(userData, _encoding.GetString(_key.Key));
        }

        public byte[] Unprotect(byte[] protectedData)
        {
            return AESThenHMAC.SimpleDecryptWithPassword(protectedData, _encoding.GetString(_key.Key));
        }

        public IDataProtector CreateProtector(string purpose)
        {
            throw new NotSupportedException();
        }
    }

我在我的项目中使用的SettingsSupplier来提供我的设置

    public interface ISettingSupplier
    {
        SystemSettings Supply();
    }

    public class SettingSupplier : ISettingSupplier
    {
        private IConfiguration Configuration { get; }

        public SettingSupplier(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public SystemSettings Supply()
        {
            var settings = new SystemSettings();
            Configuration.Bind("SystemSettings", settings);

            return settings;
        }
    }

    public class SystemSettings
    {
        public EncryptionSettings Encryption { get; set; } = new EncryptionSettings();
    }

    public class EncryptionSettings
    {
        public string AESPasswordResetKey { get; set; }
        public TimeSpan PasswordResetLifetime { get; set; } = new TimeSpan(3, 0, 0, 0);
    }

最后在Startup中注册提供程序:

 services
     .AddIdentity<AppUser, AppRole>()
     .AddEntityFrameworkStores<AppDbContext>()
     .AddDefaultTokenProviders()
     .AddTokenProvider<AesDataProtectorTokenProvider<AppUser>>(TokenOptions.DefaultProvider);


 services.AddScoped(typeof(ISettingSupplier), typeof(SettingSupplier));

//AESThenHMAC.cs: See https://gist.github.com/jbtule/4336842#file-aesthenhmac-cs

9
string code = _userManager.GeneratePasswordResetToken(user.Id);

                code = HttpUtility.UrlEncode(code);

//发送REST电子邮件


不要解码代码

var result = await _userManager.ResetPasswordAsync(user.Id, model.Code, model.Password); 

你的注释“不要”解码代码对我无效。只有解码代码才能成功。 - Aaron Hudon
@AaronHudon 可能取决于您是通过 URL 字符串还是请求正文(POST)发送它。 - Alternatex
1
似乎取决于您使用的是WebAPI还是MVC控制器。 MVC控制器上的模型绑定器默认情况下对URL进行解码! - Choco

3
这是我所做的:对Token进行URL编码后解码(简而言之)
首先,我必须对生成的用户确认电子邮件令牌进行编码。(以上为标准建议)
    var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
    var encodedToken = HttpUtility.UrlEncode(token);

在您的控制器的“确认”操作中,我必须在验证令牌之前对其进行解码。
    var decodedCode = HttpUtility.UrlDecode(mViewModel.Token);
    var result = await userManager.ConfirmEmailAsync(user,decodedCode);

3

受到@cheny发布的解决方案#3的启发,我意识到如果您使用相同的UserManager实例,则生成的代码将被接受。但在实际情况下,在用户单击电子邮件链接后,验证代码会发生在第二个API调用中。 这意味着创建了UserManager的新实例,并且无法验证第一个调用的第一个实例生成的代码。使其工作的唯一方法是确保数据库用户表中有SecurityStamp列。 将使用UserManager的类注册为单例会在应用程序启动时引发异常,因为UserManager类会自动注册为带有Scoped生存期。


谢谢,我在用户表中填充了SecurityStamp列,问题得到了解决。 - Elyas Dolatabadi

3
以下解决方案对我在WebApi中有所帮助:
注册
var result = await _userManager.CreateAsync(user, model.Password);

if (result.Succeeded) {
EmailService emailService = new EmailService();
var url = _configuration["ServiceName"];
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedToken = HttpUtility.UrlEncode(token);

// .Net Core 2.1, Url.Action return null
// Url.Action("confirm", "account", new { userId = user.Id, code = token }, protocol: HttpContext.Request.Scheme);
var callbackUrl = _configuration["ServiceAddress"] + $"/account/confirm?userId={user.Id}&code={encodedToken}";
var message = emailService.GetRegisterMailTemplate(callbackUrl, url);

await emailService.SendEmailAsync( model.Email, $"please confirm your registration {url}", message );
}

确认

[Route("account/confirm")]
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> ConfirmEmail(string userId, string code) {
  if (userId == null || code == null) {
    return Content(JsonConvert.SerializeObject( new { result = "false", message = "data is incorrect" }), "application/json");
  }

  var user = await _userManager.FindByIdAsync(userId);
  if (user == null) {
    return Content(JsonConvert.SerializeObject(new { result = "false", message = "user not found" }), "application/json");
  }

  //var decodedCode = HttpUtility.UrlDecode(code);
  //var result = await _userManager.ConfirmEmailAsync(user, decodedCode);

  var result = await _userManager.ConfirmEmailAsync(user, code);

  if (result.Succeeded)
    return Content(JsonConvert.SerializeObject(new { result = "true", message = "ок", token = code }), "application/json");
  else
    return Content(JsonConvert.SerializeObject(new { result = "false", message = "confirm error" }), "application/json");
}

神奇的 var encodedToken = HttpUtility.UrlEncode(token); 做到了它的魔力。 - sairfan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接