您建议用哪种方式在MVC
和C#
中创建安全密码重置链接?我的意思是,我将创建一个随机令牌,对吧?在发送给用户之前,我如何对其进行编码?MD5是否足够好?你知道其他安全的方法吗?
我的意思是,我会创建一个随机令牌,对吧?
有两种方法:
- 使用加密安全的随机字节序列,这些字节被保存到数据库中(也可以进行哈希处理),并通过电子邮件发送给用户。
- 这种方法的缺点是需要扩展数据库设计(模式),以便有一列来存储这些数据。您还应该存储生成字节的UTC日期+时间,以便密码重置代码过期
- 另一个缺点(或优点)是用户最多只能有一个待处理的密码重置
- 使用私钥对HMAC消息进行签名,该消息包含重置用户密码所需的最少细节,并且该消息还可以包括到期日期+时间。
- 这种方法避免了在数据库中存储任何内容,但也意味着你不能撤销任何有效生成的密码重置代码,这就是为什么使用短的到期时间(我估计大约5分钟)很重要
- 可以将吊销信息存储在数据库中(以及防止多次挂起的密码重置),但这消除了用于身份验证的签名HMAC的无状态特性的所有优点
方法1:加密安全的随机密码重置代码
- 使用
System.Security.Cryptography.RandomNumberGenerator
,这是一种加密安全的RNG。- 不要使用
System.Random
,它在加密方面不安全 - 使用它生成随机字节,然后将这些字节转换为人类可读的字符,这些字符将在电子邮件中幸存下来,并被复制和粘贴(即使用Base16或Base64编码)
- 不要使用
- 然后存储这些相同的随机字节(或者它们的散列,尽管这对安全性没有那么大帮助)。
- 只需在电子邮件中包含Base16或Base64字符串即可
- 您可以在电子邮件中有一个可点击的链接,其中包括查询字符串中的密码重置代码,然而,这样做违反了HTTP关于
GET
请求应该能够实现的功能的指导原则(由于单击链接始终是GET
请求,但GET
请求不应导致持久数据中的状态更改,因此只有POST
、PUT
和PATCH
请求才能做到这一点——这需要用户手动复制代码并提交POST
web表单——这不是最佳的用户体验。- 实际上,更好的方法是让该链接打开一个查询字符串中有密码重置代码的页面,然后该页面仍然有一个
<form method="POST">
,但它是提交用户的新密码,而不是为他们预先生成一个新密码-这样就不会违反HTTP的指导原则,因为在使用新密码的最终POST
之前不会更改状态
- 实际上,更好的方法是让该链接打开一个查询字符串中有密码重置代码的页面,然后该页面仍然有一个
如此:
-
扩展数据库的
Users
表以包括密码重置代码的列:ALTER TABLE dbo.Users ADD PasswordResetCode binary(12) NULL, PasswordResetStart datetime2(7) NULL;
-
在您的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. } }
方法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.
}
}
实际上,我不会做任何这些。
我遇到了同样的问题,我决定发送一个重置令牌,为此我使用了JWT令牌。
在该令牌(已加密)上,您可以设置过期时间。只需创建一个重置令牌,包括客户电子邮件地址作为声明,然后设置您的到期日,将其存储在数据库中(以加密形式),并对其进行编码,然后将其作为URL参数放置在链接上。
然后,当您收到请求时,您可以验证令牌是否有效。然后,你可以打开它,查看电子邮件地址,然后将他们引导到他们帐户的安全密码重置区域。(您可以包括用户名等其他声明)。
要实现JWT,您可以键入Install-Package JWT
我认为您不需要加密字符串。我认为创建一个具有Guid的字符串就足够了。
string thatString=Guid.NewGuid("n").ToString();
将其保存在数据库表中,并与特定的用户帐户相对应。为具有此字符串的用户创建一个链接并将其发送给他们。当他们点击它时,它会把他们带到一个操作方法和他们的方法。你会得到与我们存储的这个临时字符串相关联的相应用户记录,并显示用户更新密码的表单。
如果您怀疑Guid是否是唯一的,请检查此项。
比使用随机数更好的方法是先加盐,然后散列。以下是一位安全专家的片段:
@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
基本上只是创建一个密码。在这里对其进行Salt和hash处理,然后在用户返回时进行比较。链接的答案还包含一个比较方法和对salt/hashing的更深入的解释。