10

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

4

4 回答 4

23

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

有两种方法:

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

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

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

像这样:

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

    ALTER TABLE dbo.Users ADD
        PasswordResetCode  binary(12)   NULL,
        PasswordResetStart datetime2(7) NULL;
    
  2. 在您的 Web 应用程序代码中执行以下操作:

    [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.
        }
    }
    

方法二: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.
    }
}
于 2012-09-25T00:08:32.107 回答
4

事实上,我不会做这些。

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

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

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

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

于 2016-04-11T15:20:36.570 回答
2

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

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

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

如果您怀疑 Guid 是否独一无二,请查看

于 2012-09-25T00:07:27.557 回答
1

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

@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 ://stackoverflow.com/a/2138588/1026459

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

于 2012-09-25T00:16:04.610 回答