如何创建密码重置链接?

Posted

技术标签:

【中文标题】如何创建密码重置链接?【英文标题】:How to create a password reset link? 【发布时间】:2012-09-16 11:27:22 【问题描述】:

您建议以哪种方式在MVCC# 中创建安全 密码重置链接?我的意思是,我会创建一个随机令牌,对吗?在发送给用户之前如何对其进行编码? MD5 够用吗?你知道其他安全的方法吗?

【问题讨论】:

大多数密码库都有生成密码随机数的工具。您不需要散列、编码或加密任何东西。您只需要一个Random 类无法提供的随机数。 【参考方案1】:

我的意思是,我会创建一个随机令牌,对吧?

有两种方法:

使用加密安全的随机字节序列,这些字节被保存到数据库(也可以选择散列)并通过电子邮件发送给用户。 这种方法的缺点是您需要扩展您的数据库设计(模式),以便有一个列来存储这些数据。您还应该存储生成字节的 UTC 日期+时间,以使密码重置代码过期。 另一个缺点(或优点)是用户最多只能有 1 个待定密码重置。 使用私钥签署a HMAC 消息,其中包含重置用户密码所需的最少详细信息,并且此消息还可以包含到期日期+时间。 这种方法避免了需要在数据库中存储任何内容,但这也意味着您无法撤销任何有效生成的密码重置代码,这就是为什么使用较短的到期时间(我认为大约 5 分钟)很重要的原因。 您可以将撤销信息存储在数据库中(以及防止多次挂起的密码重置),但这会消除签名 HMAC 的无状态性质的所有优点用于身份验证。

方法 1:加密安全的随机密码重置代码

使用System.Security.Cryptography.RandomNumberGenerator,这是一个加密安全的RNG。 不要使用System.Random,它在密码学上不安全。 使用它来生成随机字节,然后将这些字节转换为人类可读的字符,以便在电子邮件中保留并被复制和粘贴(即使用 Base16 或 Base64 编码)。 然后存储这些相同的随机字节(或它们的散列,尽管这对安全性没有太大帮助)。 只需在电子邮件中包含该 Base16 或 Base64 字符串即可。 您可以在电子邮件中包含一个可单击的链接,其中包含查询字符串中的密码重置代码,但是这样做违反了 HTTP 关于 GET 请求应该能够做什么的准则(如单击链接始终是 GET 请求,但 GET 请求不应导致持久数据的状态更改,只有 POSTPUTPATCH 请求应该这样做 - 这需要用户手动复制代码并提交POST web-form - 这不是最好的用户体验。 实际上,更好的方法是让该链接在查询字符串中打开一个带有密码重置代码的页面,然后该页面仍然有一个<form method="POST">,但它是提交用户的新密码,而不是预先生成新密码对他们来说 - 因此不会违反 HTTP 的指导方针,因为在使用新密码的最终 POST 之前不会更改状态。

像这样:

    扩展您的数据库的Users 表以包含密码重置代码的列:

    ALTER TABLE dbo.Users ADD
        PasswordResetCode  binary(12)   NULL,
        PasswordResetStart datetime2(7) NULL;
    

    在您的网络应用程序代码中执行类似的操作:

    [HttpGet]
    [HttpHead]
    public IActionResult GetPasswordResetForm()
    
        // Return a <form> allowing the user to confirm they want to reset their password, which POSTs to the action below.
    
    
    static readonly TimeSpan _passwordResetExpiry = TimeSpan.FromMinutes( 5 );
    
    [HttpPost]
    public IActionResult SendPasswordResetCode()
    
        // 1. Get a cryptographically secure random number:
        // using System.Security.Cryptography;
    
        Byte[] bytes;
        String bytesBase64Url; // NOTE: This is Base64Url-encoded, not Base64-encoded, so it is safe to use this in a URL, but be sure to convert it to Base64 first when decoding it.
        using( RandomNumberGenerator rng = new RandomNumberGenerator() ) 
    
            bytes = new Byte[12]; // Use a multiple of 3 (e.g. 3, 6, 12) to prevent output with trailing padding '=' characters in Base64).
            rng.GetBytes( bytes );
    
            // The `.Replace()` methods convert the Base64 string returned from `ToBase64String` to Base64Url.
            bytesBase64Url = Convert.ToBase64String( bytes ).Replace( '+', '-' ).Replace( '/', '_' );
        
    
        // 2. Update the user's database row:
        using( SqlConnection c = new SqlConnection( CONNECTION_STRING ) )
        using( SqlCommand cmd = c.CreateCommand() )
        
            cmd.CommandText = "UPDATE dbo.Users SET PasswordResetCode = @code, PasswordResetStart = SYSUTCDATETIME() WHERE UserId = @userId";
    
            SqlParameter pCode = cmd.Parameters.Add( cmd.CreateParameter() );
            pCode.ParameterName = "@code";
            pCode.SqlDbType     = SqlDbType.Binary;
            pCode.Value         = bytes;
    
            SqlParameter pUserId = cmd.Parameters.Add( cmd.CreateParameter() );
            pCode.ParameterName = "@userId";
            pCode.SqlDbType     = SqlDbType.Int;
            pCode.Value         = userId;
    
            cmd.ExecuteNonQuery();
        
    
        // 3. Send the email:
        
            const String fmt = @"Greetings 0,
    I am Ziltoid... the omniscient.
    I have come from far across the omniverse.
    You shall fetch me your universe's ultimate cup of coffee... uh... I mean, you can reset your password at 1
    You have 2:N0 Earth minutes,
    Make it perfect!";
    
            // e.g. "https://example.com/ResetPassword/123/ABCDEF"
            String link = "https://example.com/" + this.Url.Action(
                controller: nameof(PasswordResetController),
                action: nameof(this.ResetPassword),
                params: new  userId = userId, codeBase64 = bytesBase64Url 
            );
    
            String body = String.Format( CultureInfo.InvariantCulture, fmt, userName, link, _passwordResetExpiry.TotalMinutes );
    
            this.emailService.SendEmail( user.Email, subject: "Password reset link", body );
        
    
    
    
    [HttpGet( "/PasswordReset/ResetPassword/userId/codeBase64Url" )]
    public IActionResult ResetPassword( Int32 userId, String codeBase64Url )
    
        // Lookup the user and see if they have a password reset pending that also matches the code:
    
        String codeBase64 = codeBase64Url.Replace( '-', '+' ).Replace( '_', '/' );
        Byte[] providedCode = Convert.FromBase64String( codeBase64 );
        if( providedCode.Length != 12 ) return this.BadRequest( "Invalid code." );
    
        using( SqlConnection c = new SqlConnection( CONNECTION_STRING ) )
        using( SqlCommand cmd = c.CreateCommand() )
        
            cmd.CommandText = "SELECT UserId, PasswordResetCode, PasswordResetStart FROM dbo.Users SET WHERE UserId = @userId";
    
            SqlParameter pUserId = cmd.Parameters.Add( cmd.CreateParameter() );
            pCode.ParameterName = "@userId";
            pCode.SqlDbType     = SqlDbType.Int;
            pCode.Value         = userId;
    
            using( SqlDataReader rdr = cmd.ExecuteReader() )
            
                if( !rdr.Read() )
                
                    // UserId doesn't exist in the database.
                    return this.NotFound( "The UserId is invalid." );
                
    
                if( rdr.IsDBNull( 1 ) || rdr.IsDBNull( 2 ) )
                
                    return this.Conflict( "There is no pending password reset." );
                 
    
                Byte[]    expectedCode = rdr.GetBytes( 1 );
                DateTime? start        = rdr.GetDateTime( 2 );
    
                if( !Enumerable.SequenceEqual( providedCode, expectedCode ) )
                
                    return this.BadRequest( "Incorrect code." );
                
    
                // Now return a new form (with the same password reset code) which allows the user to POST their new desired password to the `SetNewPassword` action` below.
            
        
    
        [HttpPost( "/PasswordReset/ResetPassword/userId/codeBase64" )]
        public IActionResult SetNewPassword( Int32 userId, String codeBase64, [FromForm] String newPassword, [FromForm] String confirmNewPassword )
        
            // 1. Use the same code as above to verify `userId` and `codeBase64`, and that `PasswordResetStart` was less than 5 minutes (or `_passwordResetExpiry`) ago.
            // 2. Validate that `newPassword` and `confirmNewPassword` are the same.
            // 3. Reset `dbo.Users.Password` by hashing `newPassword`, and clear `PasswordResetCode` and `PasswordResetStart`
            // 4. Send the user a confirmatory e-mail informing them that their password was reset, consider including the current request's IP address and user-agent info in that e-mail message as well.
            // 5. And then perform a HTTP 303 redirect to the login page - or issue a new session token cookie and redirect them to the home-page.
        
    
    

方法 2:HMAC 代码

这种方法不需要更改您的数据库,也不需要保留新状态,但它确实需要您了解 HMAC 的工作原理。

基本上,它是一个简短的结构化消息(而不是随机不可预测的字节),其中包含足够的信息以允许系统识别应重置其密码的用户,包括到期时间戳 - 为防止伪造,此消息使用加密签名只有您的应用程序代码知道的私钥:这可以防止攻击者生成自己的密码重置代码(这显然不好!)。

以下是如何生成用于密码重置的 HMAC 代码以及如何验证它:

private static readonly Byte[] _privateKey = new Byte[]  0xDE, 0xAD, 0xBE, 0xEF ; // NOTE: You should use a private-key that's a LOT longer than just 4 bytes.
private static readonly TimeSpan _passwordResetExpiry = TimeSpan.FromMinutes( 5 );
private const Byte _version = 1; // Increment this whenever the structure of the message changes.

public static String CreatePasswordResetHmacCode( Int32 userId )

    Byte[] message = Enumerable.Empty<Byte>()
        .Append( _version )
        .Concat( BitConverter.GetBytes( userId ) )
        .Concat( BitConverter.GetBytes( DateTime.UtcNow.ToBinary() ) )
        .ToArray();

    using( HMACSHA256 hmacSha256 = new HMACSHA256( key: _privateKey ) )
    
        Byte[] hash = hmacSha256.ComputeHash( buffer: message, offset: 0, count: message.Length );

        Byte[] outputMessage = message.Concat( hash ).ToArray();
        String outputCodeB64 = Convert.ToBase64( outputMessage );
        String outputCode    = outputCodeB64.Replace( '+', '-' ).Replace( '/', '_' );
        return outputCode;
    


public static Boolean VerifyPasswordResetHmacCode( String codeBase64Url, out Int32 userId )

    String base64 = codeBase64Url.Replace( '-', '+' ).Replace( '_', '/' );
    Byte[] message = Convert.FromBase64String( base64 );
    
    Byte version = message[0];
    if( version < _version ) return false;
    
    userId = BitConverter.ToInt32( message, startIndex: 1 ); // Reads bytes message[1,2,3,4]
    Int64 createdUtcBinary = BitConverter.ToInt64( message, startIndex: 1 + sizeof(Int32) ); // Reads bytes message[5,6,7,8,9,10,11,12]
    
    DateTime createdUtc = DateTime.FromBinary( createdUtcBinary );
    if( createdUtc.Add( _passwordResetExpiry ) < DateTime.UtcNow ) return false;
    
    const Int32 _messageLength = 1 + sizeof(Int32) + sizeof(Int64); // 1 + 4 + 8 == 13

    using( HMACSHA256 hmacSha256 = new HMACSHA256( key: _privateKey ) )
    
        Byte[] hash = hmacSha256.ComputeHash( message, offset: 0, count: _messageLength );
        
        Byte[] messageHash = message.Skip( _messageLength ).ToArray();
        return Enumerable.SequenceEquals( hash, messageHash );
    


这样使用:


// Note there is no `UserId` URL parameter anymore because it's embedded in `code`:

[HttpGet( "/PasswordReset/ResetPassword/codeBase64Url" )]
public IActionResult ConfirmResetPassword( String codeBase64Url )

    if( !VerifyPasswordResetHmacCode( codeBase64Url, out Int32 userId ) )
    
        // Message is invalid, such as the HMAC hash being incorrect, or the code has expired.
        return this.BadRequest( "Invalid, tampered, or expired code used." );
    
    else
    
        // Return a web-page with a <form> to POST the code.
        // Render the `codeBase64Url` to an <input type="hidden" /> to avoid the user inadvertently altering it.
        // Do not reset the user's password in a GET request because GET requests must be "safe". If you send a password-reset link by SMS text message or even by email, then software bot (like link-preview generators) may follow the link and inadvertently reset the user's password!
    



[HttpPost( "/PasswordReset/ResetPassword" )]
public IActionResult ConfirmResetPassword( [FromForm] ConfirmResetPasswordForm model )

    if( !VerifyPasswordResetHmacCode( model.CodeBase64Url, out Int32 userId ) )
    
        return this.BadRequest( "Invalid, tampered, or expired code used." );
    
    else
    
        // Reset the user's password here.
    

【讨论】:

很好的答案。作为提醒,我需要在 .NET Core 中执行此操作来创建实例:using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) 另外,作为旁注 - 散列它的一点是,如果数据库受到损害,他们不能直接使用数据库中的随机数。他们还需要访问 API,因此它更安全。只是澄清一下,因为这是一个很好的答案。 很抱歉,通过方法 2 你有第二个变量:'_privateKey',它没有被实例化。不可变数组的目的是什么? @Vince 我认为从上下文中可以清楚地看出,用于签署 HMAC 消息的私钥包含在 _privateKey 中,这是一个字节数组(长度适合所选的 HMAC 方案)。我没有初始化它,因为我不会在公共论坛上分享我的私钥:) @Dai,谢谢你的纠正。 Occams Razor...不知道为什么我想太多了【参考方案2】:

实际上,我不会做这些。

我遇到了同样的问题,我决定发送一个重置令牌,为此我使用了 JWT 令牌。

在该令牌(已加密)上,您可以设置到期时间。只需创建一个包含客户电子邮件地址的重置令牌作为声明,然后设置您的到期时间,将其存储在您的数据库中(以加密形式)并对其进行编码并作为 URL 参数放置在链接上。

然后,当您收到请求时,您可以验证令牌是否有效。然后,您可以打开它查看电子邮件地址,然后继续将他们引导到他们帐户的安全密码重置区域。 (您可以包含其他声明,例如用户名等)。

要获得 JWT 实现,您可以输入 Install-Package JWT

【讨论】:

您将如何调试此令牌?可以提取这种令牌中的所有信息:电子邮件,到期日期... 这在功能上与@Dai 回答中的第二种方法几乎相同【参考方案3】:

我认为您不需要为此目的使用加密字符串。我认为用 Guid 创建一个字符串就足够了。

string thatString=Guid.NewGuid("n").ToString();

针对该特定用户帐户将其保存在您的数据库表中。为具有此字符串的用户创建一个链接并将其发送给他们。当他们点击它时,它会将他们带到一个操作方法,他们会获得与我们存储的这个临时字符串相关联的相应用户记录,并显示用户更新密码的表单。

如果您对 Guid 是否独一无二有疑问,请查看 this。

【讨论】:

独特!=随机。 GUIDs are not designed to be random. 它们并非设计为不可预测的。它们只是为了不重复而设计的。 我也使用这种方法,Guid。如果您对唯一性感到困扰,您可以检查它是否已经存在于数据库中。我还对其设置了时间限制(因此链接只能点击两个小时),从而进一步限制了碰撞的机会。 @Servy 我不确定您的评论如何影响答案?你说这是个坏主意吗?如果它们被设计为不重复,那么这不是使答案有效吗?我在答案中看不到任何引用 GUID 随机性的内容,只要 GUID 实际上不重复,那么它在这里就没有用吗? @Jacques 问题不是问如何生成不会发生冲突的值,而是要问如何生成不会发生冲突的值 并且任何人都无法计算除了被赋予价值的人。 GUID 执行前者,但不执行后者。它的设计目的不是让坚定的恶意攻击者无法预测。【参考方案4】:

比使用随机数更好的是先加盐然后散列。这是来自安全专家的 sn-p:

@using System.Security.Cryptography;
static byte[] GenerateSaltedHash(byte[] plainText, byte[] salt)

 HashAlgorithm algorithm = new SHA256Managed();

 byte[] plainTextWithSaltBytes = 
  new byte[plainText.Length + salt.Length];

 for (int i = 0; i < plainText.Length; i++)
 
  plainTextWithSaltBytes[i] = plainText[i];
 
 for (int i = 0; i < salt.Length; i++)
  
  plainTextWithSaltBytes[plainText.Length + i] = salt[i];
 

 return algorithm.ComputeHash(plainTextWithSaltBytes);            

你可以在这里看到更多关于他的回答:https://***.com/a/2138588/1026459

基本上只需创建一个密码。在这里加盐和哈希,然后在用户返回时进行比较。链接的答案还包含比较方法和更深入的盐/散列解释。

【讨论】:

以上是关于如何创建密码重置链接?的主要内容,如果未能解决你的问题,请参考以下文章

如何在 Laravel 5 中更改默认重置密码链接

通过邮件找回密码

强化修改密码重置链接控制器

Wordpress REST API - 发送重置密码链接

如何使用 laravel 5 中的队列通过电子邮件发送密码重置链接

MVC 密码重置邮件 NO SIMPLEMEMBERSHIP