如何实现密码重置?

83
我正在开发一个ASP.NET应用程序,想知道如何实现“密码重置”功能,具体来说,我有以下问题:
1. 生成一个难以破解的唯一ID的好方法是什么? 2. 是否应该附加定时器?如果是,应该设置多长时间? 3. 我应该记录IP地址吗?这真的很重要吗? 4. 在“密码重置”屏幕下,我应该询问哪些信息?只需要电子邮件地址吗?或者可能需要电子邮件地址加上一些他们“知道”的信息吗?(最喜欢的队伍,小狗的名字等) 5. 还有其他需要注意的事项吗? NB: 其他问题完全忽略了技术实现。事实上,被接受的答案也忽略了许多细节。我希望这个问题和后续的回答能够深入探讨这些细节,通过更加具体的提问方式,回答内容不那么空洞,而是更加详尽。

编辑:回答还应该涉及如何在SQL Server中建模和处理此类表格,或提供任何ASP.NET MVC链接的答案。


ASP.NET MVC 使用默认的 ASP.NET 认证提供程序,因此您找到的任何代码示例都应该与您的目的相关。 - paulwhit
7个回答

68

编辑于2012/05/22:作为对这个流行答案的跟进,我不再在此过程中使用GUID。像其他流行答案一样,我现在使用自己的哈希算法来生成要发送到URL中的密钥。这样做的优点是更短。使用System.Security.Cryptography生成它们,我通常也会使用一个SALT。

首先,不要立即重置用户的密码。

当用户请求重置密码时,请不要立即重置用户的密码。这是一种安全漏洞,因为有人可能会猜测电子邮件地址(例如公司中的电子邮件地址),并随意重置密码。如今的最佳实践通常包括向用户的电子邮件地址发送“确认”链接,以确认他们想要重置密码。这个链接是你想要发送唯一密钥链接的地方。我用类似以下链接发送我的: example.com/User/PasswordReset/xjdk2ms92

是的,在链接上设置超时时间,并将密钥和超时时间存储在后端(如果您正在使用其中之一,请同时使用SALT)。三天的超时时间是正常值,并确保在用户请求重置时在 web 端通知用户 3 天的时间。

使用唯一的哈希键

我的先前答案说要使用 GUID。我现在编辑此内容,建议每个人都使用随机生成的哈希,例如使用。并确保从哈希中消除任何“真实单词”。我记得有一个特别的早上6点的电话,一个女士收到了她的“假定是随机”的哈希密钥中的某个“c”字眼。糟糕!

整个过程

  • 用户点击“重置”密码。
  • 要求用户输入电子邮件地址。
  • 用户输入电子邮件地址并点击发送。不要确认或否认电子邮件,因为这也是不好的做法。简单地说,“如果验证了电子邮件,则我们已发送重置密码请求。”或类似的神秘语。
  • RNGCryptoServiceProvider 创建哈希值,将其作为单独的实体存储在 ut_UserPasswordRequests 表中,并链接回用户。这样一来,您就可以跟踪旧的请求并通知用户较早的链接已过期。
  • 发送链接到电子邮件。

用户收到链接,例如 http://example.com/User/PasswordReset/xjdk2ms92,然后点击它。

如果链接得到验证,您将要求设置新密码。简单明了,用户可以设置自己的密码。或者,在此处设置自己的加密密码,并在此处通知他们他们的新密码(并通过电子邮件发送给他们)。


1
我在想,如果实际用户密码已经被哈希处理过了,为什么还要生成一个新的哈希密钥呢?直接给用户发送一封包含重置密码链接的电子邮件并传递哈希密码不是更好吗?哈希密码无法被还原,当用户点击链接时,服务器将接收到哈希密码,与实际存储的密码进行比较,然后允许用户更改密码。 - Daniel
另一个很好的事情是,您不需要设置超时时间,一旦用户更改了密码,旧链接将自动失效,因为存储在数据库中的哈希密码已更改。 - Daniel
1
@Daniel,那是个非常糟糕的想法。我认为你需要谷歌一下“暴力攻击”的术语。此外,您希望它过期的原因是,以防某人的电子邮件在一年后被入侵(而他们从未重置它),黑客获得更改密码的权利。 - eduncan911
@educan911。我知道暴力攻击,但是为了获得哈希密钥的访问权限,恶意人必须要能够访问电子邮件,如果他已经有了这个权限,就没有必要还原哈希密码了。此外,为了使其几乎不可能,您可以对哈希密码进行再次哈希,或者更好的方法是使用其他方式对密码进行哈希。我并不反对您,我只是在尝试进行头脑风暴。 - Daniel

66
这里有很多好的答案,我不会重复所有内容... 除了一个问题,几乎每个答案都重复了,即使它是错误的:
“GUID(全局唯一标识符)是(实际上)唯一的,而且统计上不可能被猜测到。”
这是不正确的,GUID是非常薄弱的标识符,不应该用于允许访问用户帐户。如果您检查其结构,最多只能获得128位,而这在今天并不算太多。其中前半部分是典型的不变量(对于生成系统),剩下的一半是时间相关的(或类似的东西)。总的来说,这是一种非常弱的、容易被暴力破解的机制。
所以不要使用它!
相反,只需使用加密的强随机数生成器(System.Security.Cryptography.RNGCryptoServiceProvider),并获取至少256位的原始熵。
其他的,就像其他答案提供的那样。

6
完全同意,据我所知,GUID并不是设计成具有强密码学安全性和无法猜测的。 - Jan Soltis
5
说得好。据我所知,MSDN明确指出GUID不应用于安全领域。 - dr. evil
2
自2000年以来,Windows一直在使用版本4 UUID:.NET 4 GUIDs是如何生成的?- Stack Overflow。它们有122个随机位,我认为符合NIST的建议。根据CryptGenRandom - Wikipedia的说法,存在一种非常严重的本地攻击漏洞,但在2008年之前,Vista和XP已经修复了这个漏洞。那么你认为当前使用GUID存在什么问题呢? - nealmcb
4
“Old New Thing”博客描述的是已弃用的版本1 UUID,并引用了一份互联网草案(你永远不应该这样做),该草案在1998年就已过期,早于博客发布10年。将来对它们持怀疑态度是可以理解的。我们早已经历了这些斗争,似乎已经赢得了大部分胜利。我仍然同意使用干净的API调用加密随机源要好得多,但并不要对版本4的GUID / UUID太苛刻。 - nealmcb
1
就此而言,这并没有回答“如何重置密码”的问题。你只是涌出了关于GUID的伟大观点。 - Rex Whitten
显示剩余7条评论

8
首先,我们需要知道您已经了解的关于用户的信息。显然,您有一个用户名和旧密码。您还知道什么?您是否拥有电子邮件地址?您是否拥有有关用户最喜欢的花卉的数据?
假设您有用户名、密码和有效的电子邮件地址,您需要向用户表(假设它是数据库表)添加两个字段:一个名为new_passwd_expire的日期和一个名为new_passwd_id的字符串。
假设您知道用户的电子邮件地址,当有人请求重置密码时,您需要按照以下方式更新用户表:
new_passwd_expire = now() + some number of days
new_passwd_id = some random string of characters (see below)

接下来,您需要向该地址的用户发送电子邮件:
亲爱的某某,
有人请求重置<username>在<your website name>的用户帐户密码。如果您确实请求此密码重置,请单击以下链接:
http://example.com/yourscript.lang?update=&lt;new_password_id
如果该链接不起作用,您可以转到http://example.com/yourscript.lang,并在表格中输入以下内容:<new_password_id>
如果您没有请求密码重置,则可以忽略此电子邮件。
谢谢,yada yada
现在,编写yourscript.lang:此脚本需要一个表格。如果URL上传递了var update,则该表单仅要求用户的用户名和电子邮件地址。如果未传递更新,则它会要求用户名、电子邮件地址和发送到电子邮件中的id代码。您还要求输入新密码(当然要输入两次)。
为了验证用户的新密码,您需要验证用户名、电子邮件地址和id代码是否匹配,请求是否过期以及两个新密码是否匹配。如果成功,您将更改用户的密码为新密码并从用户表中清除密码重置字段。还要确保注销用户/清除任何与登录相关的cookie并将用户重定向到登录页面。
基本上,new_passwd_id字段是仅在密码重置页面上有效的密码。
一个潜在的改进:您可以从电子邮件中删除<username>。"有人请求重置此电子邮件地址上的帐户密码..."这样,如果电子邮件被拦截,用户名就是用户所知道的内容。我一开始没有这样做,因为如果有人攻击该帐户,他们已经知道用户名了。这种增加的模糊性可以在某些情况下防止机会攻击。
至于您的问题:
生成随机字符串:它不需要非常随机。任何GUID生成器或甚至是md5(concat(salt,current_timestamp()))都足够了,其中salt是用户记录上的某些东西,例如账户创建时的时间戳。它必须是用户看不到的内容。
计时器:是的,您需要这样做只是为了保持数据库的正常状态。最多只需要一周,但至少要2天,因为您永远不知道电子邮件延迟可能持续多长时间。
IP地址:由于电子邮件可能会延迟数天,因此IP地址仅用于日志记录,而不用于验证。如果要记录它,请这样做,否则您不需要它。
重置屏幕:请参见上文。

一个潜在的攻击者是否能够使用当前日期戳的MD5值进入系统? - George Stocker
我强烈建议不要通过电子邮件发送密码。大多数用户会将这些电子邮件保留未删除,这是一种安全漏洞 - 他们中的一些人会喜欢每次从他们“最喜欢”的电子邮件中复制粘贴密码。如果用户公司邮件服务器的证书过期并且流量被嗅探,那该怎么办呢?为了最小化这种可能的漏洞,可以采取以下措施:(1)设置特定密码的短期过期时间 - 1小时,(2)强制用户在下次登录时更新密码。 - Ognyan Dimitrov
Ognyan,电子邮件中发送的密码只能使用一次。他们必须在登录后更改密码,而电子邮件不包含用户登录名。因此,他们不能每次都复制粘贴它。不删除电子邮件并不是安全问题,因为它只是一串毫无意义的字母/数字,在重置密码后攻击者将得不到任何东西。 - jmucchiello

3

对于大多数普通应用来说,发送到记录的电子邮件地址的GUID可能已经足够了 - 加上超时更好。

毕竟,如果用户的电子邮箱被入侵(即黑客拥有电子邮件地址的登录/密码),你无能为力。


2

1)生成唯一标识符,您可以使用安全哈希算法。 2)附加计时器?您是指重置密码链接的到期时间吗? 是的,您可以设置有效期 3)您可以要求提供除电子邮件地址之外的其他信息进行验证... 比如出生日期或一些安全问题 4)您还可以生成随机字符,并要求在请求时输入,以确保密码请求不是由间谍软件或类似物自动化的。


2
你可以向用户发送带有链接的电子邮件。该链接将包含一些难以猜测的字符串(例如GUID)。在服务器端,您也会存储与您向用户发送的相同字符串。现在,当用户按下链接时,您可以在数据库中查找具有相同秘密字符串的条目并重置其密码。

更多细节会很有帮助。 - George Stocker

0

我认为微软关于ASP.NET身份验证的指南是一个很好的起点。

https://learn.microsoft.com/en-us/aspnet/identity/overview/features-api/account-confirmation-and-password-recovery-with-aspnet-identity

我用于ASP.NET身份验证的代码:

Web.Config:

<add key="AllowedHosts" value="example.com,2.example" />

AccountController.cs:

[Route("RequestResetPasswordToken/{email}/")]
[HttpGet]
[AllowAnonymous]
public async Task<IHttpActionResult> GetResetPasswordToken([FromUri]string email)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    var user = await UserManager.FindByEmailAsync(email);
    if (user == null)
    {
        Logger.Warn("Password reset token requested for non existing email");
        // Don't reveal that the user does not exist
        return NoContent();
    }

    //Prevent Host Header Attack -> Password Reset Poisoning.
    //If the IIS has a binding to accept connections on 80/443 the host parameter can be changed.
    //See https://security.stackexchange.com/a/170759/67046
    if (!ConfigurationManager.AppSettings["AllowedHosts"].Split(',').Contains(Request.RequestUri.Host)) {
            Logger.Warn($"Non allowed host detected for password reset {Request.RequestUri.Scheme}://{Request.Headers.Host}");
            return BadRequest();
    }

    Logger.Info("Creating password reset token for user id {0}", user.Id);

    var host = $"{Request.RequestUri.Scheme}://{Request.Headers.Host}";
    var token = await UserManager.GeneratePasswordResetTokenAsync(user.Id);
    var callbackUrl = $"{host}/resetPassword/{HttpContext.Current.Server.UrlEncode(user.Email)}/{HttpContext.Current.Server.UrlEncode(token)}";

    var subject = "Client - Password reset.";
    var body = "<html><body>" +
               "<h2>Password reset</h2>" +
               $"<p>Hi {user.FullName}, <a href=\"{callbackUrl}\"> please click this link to reset your password </a></p>" +
               "</body></html>";

    var message = new IdentityMessage
    {
        Body = body,
        Destination = user.Email,
        Subject = subject
    };

    await UserManager.EmailService.SendAsync(message);

    return NoContent();
}

[HttpPost]
[Route("ResetPassword/")]
[AllowAnonymous]
public async Task<IHttpActionResult> ResetPasswordAsync(ResetPasswordRequestModel model)
{
    if (!ModelState.IsValid)
        return NoContent();

    var user = await UserManager.FindByEmailAsync(model.Email);
    if (user == null)
    {
        Logger.Warn("Reset password request for non existing email");
        return NoContent();
    }

    if (!await UserManager.UserTokenProvider.ValidateAsync("ResetPassword", model.Token, UserManager, user))
    {
        Logger.Warn("Reset password requested with wrong token");
        return NoContent();
    }

    var result = await UserManager.ResetPasswordAsync(user.Id, model.Token, model.NewPassword);

    if (result.Succeeded)
    {
        Logger.Info("Creating password reset token for user id {0}", user.Id);

        const string subject = "Client - Password reset success.";
        var body = "<html><body>" +
                   "<h1>Your password for Client was reset</h1>" +
                   $"<p>Hi {user.FullName}!</p>" +
                   "<p>Your password for Client was reset. Please inform us if you did not request this change.</p>" +
                   "</body></html>";

        var message = new IdentityMessage
        {
            Body = body,
            Destination = user.Email,
            Subject = subject
        };

        await UserManager.EmailService.SendAsync(message);
    }

    return NoContent();
}

public class ResetPasswordRequestModel
{
    [Required]
    [Display(Name = "Token")]
    public string Token { get; set; }

    [Required]
    [Display(Name = "Email")]
    public string Email { get; set; }

    [Required]
    [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 10)]
    [DataType(DataType.Password)]
    [Display(Name = "New password")]
    public string NewPassword { get; set; }

    [DataType(DataType.Password)]
    [Display(Name = "Confirm new password")]
    [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; }
}

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