关于C#:如何实现密码重置?

How to Implement Password Resets?

我正在ASP.NET中开发一个应用程序,特别想知道如果我想自己滚动的话,如何实现Password Reset函数。

具体来说,我有以下问题:

  • 生成难以破解的唯一ID的好方法是什么?
  • 它是否应该有一个计时器?如果是,应该多长时间?
  • 我应该记录IP地址吗?这甚至重要吗?
  • 在"密码重置"屏幕下,我应该询问哪些信息?只是电子邮件地址?或者电子邮件地址加上一些他们"知道"的信息?(最喜欢的队、小狗的名字等)

我还有什么需要注意的吗?

NB: Other questions have glossed over technical implementation entirely. Indeed the accepted answer glosses over the gory details. I hope that this question and subsequent answers will go into the gory details, and I hope by phrasing this question much more narrowly that the answers are less 'fluff' and more 'gore'.

编辑:还将讨论如何在SQL Server中建模和处理这样一个表,或任何到答案的ASP.NET MVC链接的答案都将受到赞赏。


编辑2012/05/22:作为这个流行答案的后续行动,我不再在这个过程中使用guid。和另一个流行的答案一样,我现在使用我自己的哈希算法来生成发送到URL的密钥。这也有缩短的优点。查看system.security.cryptography以生成它们,我通常也使用salt。

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

首先,不要在用户请求时立即重置其密码。这是一个安全漏洞,因为有人可能会猜测电子邮件地址(即您在公司的电子邮件地址),并一时兴起重置密码。这些天的最佳实践通常包括发送到用户电子邮件地址的"确认"链接,确认他们想要重置它。此链接是要发送唯一键链接的位置。我用一个链接发送我的:domain.com/User/PasswordReset/xjdk2ms92

是的,在链接上设置一个超时,并在后端存储密钥和超时(如果您使用的是salt)。3天的超时是正常的,当用户请求重置时,请确保在Web级别通知用户3天。

使用唯一的哈希键

我以前的回答是使用一个guid。我现在编辑这篇文章是为了建议每个人使用随机生成的哈希,例如使用RNGCryptoServiceProvider。并且,确保从哈希表中删除任何"实词"。我记得有一个特别的早上6点的电话,一个女人在她的"假设是随机的"哈希键中收到了一个特定的"c"字。呸!

整个过程

  • 用户点击"重置"密码。
  • 要求用户提供电子邮件。
  • 用户输入电子邮件并单击发送。不要确认或拒绝电子邮件,因为这也是不好的做法。简单地说,"我们已经发送了一个密码重置请求,如果电子邮件被验证了。"或者类似的秘密。
  • RNGCryptoServiceProvider创建一个散列,将它作为一个单独的实体存储在ut_UserPasswordRequests表中,并链接回用户。这样您就可以跟踪旧的请求并通知用户旧的链接已经过期。
  • 将链接发送到电子邮件。

用户获取链接,如http://domain.com/User/PasswordReset/xjdk2ms92,然后单击它。

如果链接已验证,则需要新密码。简单,用户可以设置自己的密码。或者,在这里设置您自己的密码,并在这里告知他们新密码(并通过电子邮件发送给他们)。


这里有很多很好的答案,我不想再重复一遍…

除了一个问题外,几乎每个答案都会重复这个问题,尽管它是错误的:

Guids are (realistically) unique and statistically impossible to guess.

这不是真的,guid是非常弱的标识符,不应该用于允许访问用户的帐户。如果检查结构,最多可以得到128位…这在现在被认为是不多的。其中前半部分是典型的不变量(对于生成系统),剩下的一半是时间依赖的(或类似的)。总之,这是一个非常脆弱和容易被野蛮改造的机制。

所以不要用那个!

相反,只需使用一个加密的强随机数生成器(System.Security.Cryptography.RNGCryptoServiceProvider)就可以得到至少256位的原始熵。

其余的,正如其他许多答案所提供的那样。


首先,我们需要知道您对用户已经了解了什么。显然,您有一个用户名和一个旧密码。你还知道什么?你有电子邮件地址吗?你有关于用户最喜欢的花的数据吗?

假设您有用户名、密码和工作电子邮件地址,您需要向用户表中添加两个字段(假设它是数据库表):名为new-passwd-expire的日期和字符串new-passwd-id。

假设您拥有用户的电子邮件地址,当有人请求密码重置时,您将更新用户表,如下所示:

1
2
new_passwd_expire = now() + some number of days
new_passwd_id = some random string of characters (see below)

接下来,您将向该地址的用户发送电子邮件:

Dear so-and-so

Someone has requested a new password for user account at . If you did request this password reset, follow this link:

http://example.com/yourscript.lang?update=

If that link does not work you can go to http://example.com/yourscript.lang and enter the following into the form:

If you did not request a password reset, you may ignore this email.

Thanks, yada yada

现在,对yourscript.lang进行编码:这个脚本需要一个表单。如果在URL上传递了var更新,那么表单只要求输入用户的用户名和电子邮件地址。如果未通过更新,则要求输入用户名、电子邮件地址和电子邮件中发送的ID代码。您还需要一个新密码(当然是两次)。

要验证用户的新密码,请验证用户名、电子邮件地址和ID代码是否都匹配、请求是否未过期以及两个新密码是否匹配。如果成功,您可以将用户密码更改为新密码,并从用户表中清除密码重置字段。还要确保注销用户/清除任何与登录相关的cookie,并将用户重定向到登录页面。

基本上,新的密码字段是一个只在密码重置页上工作的密码。

一个潜在的改进:您可以从电子邮件中删除。有人请求重置此电子邮件地址的帐户的密码…."因此,只有用户知道用户名是否被截获。我没有这样开始,因为如果有人攻击这个帐户,他们已经知道用户名了。这增加了默默无闻阻止了人们中间的机会攻击,以防有人恶意拦截电子邮件。

关于你的问题:

生成随机字符串:它不需要是非常随机的。任何guid生成器甚至md5(concat(salt,current_timestamp())都是足够的,其中salt是用户记录上的内容,如timestamp帐户已创建。一定是用户看不见的东西。

定时器:是的,你需要这个只是为了保持数据库的正常运行。不超过一个星期确实是必要的,但至少2天,因为你永远不知道电子邮件延迟可能会持续多久。

IP地址:由于电子邮件可能会延迟几天,因此IP地址只对日志记录有用,而不用于验证。如果你想记录它,就这样做,否则你不需要它。

重置屏幕:见上文。

希望能覆盖它。祝你好运。


发送到记录的电子邮件地址的guid对于大多数运行的工厂应用程序来说可能已经足够了——超时甚至更好。

毕竟,如果用户邮箱被泄露(即黑客拥有电子邮件地址的登录名/密码),那么您就没有什么可以做的了。


1)为了生成唯一的ID,可以使用安全哈希算法。2)计时器连接?您的意思是重置pwd链接过期吗?是的,你可以有一套有效期3)您可以要求除emailid之外的其他信息进行验证。比如出生日期或者一些安全问题4)您还可以生成随机字符并要求输入该字符以及请求…以确保密码请求不会被一些间谍软件或类似的东西自动执行。


您可以通过链接向用户发送电子邮件。此链接将包含一些难以猜测的字符串(如guid)。在服务器端,您还将存储与发送给用户的字符串相同的字符串。现在,当用户按下链接时,您可以在数据库条目中找到具有相同秘密字符串的链接,并重置其密码。


我认为微软的ASP.NET标识指南是一个好的开始。

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

我用于ASP.NET标识的代码:

Web.CONFIG:

1
 

会计总监:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
[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>" +
              "Password reset" +
               $"<p>
Hi {user.FullName},  please click this link to reset your password
</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>" +
                  "Your password for Client was reset" +
                   $"<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; }
}