我的意思是,我会创建一个随机令牌,对吧?
有两种方法:
- 使用加密安全的随机字节序列,这些字节被保存到数据库(也可以选择散列)并通过电子邮件发送给用户。
- 这种方法的缺点是您需要扩展您的数据库设计(模式),以便有一个列来存储这些数据。您还应该存储生成字节的 UTC 日期+时间,以使密码重置代码过期。
- 另一个缺点(或优点)是用户最多只能有 1 个待定密码重置。
- 使用私钥签署a 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 - 这不是最好的用户体验。
- 实际上,更好的方法是让该链接在查询字符串中打开一个带有密码重置代码的页面,然后该页面仍然有一个
<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.
}
}