Каким способом вы бы посоветовали создать ссылку для безопасного сброса пароля в MVC
и C#
? Я имею в виду, что я создам случайный токен, верно? Как мне его закодировать перед отправкой пользователю? Достаточно ли MD5? Вы знаете какой-нибудь другой безопасный способ?
Как создать ссылку для сброса пароля?
Ответы (4)
Я имею в виду, что я создам случайный токен, верно?
Есть два подхода:
- Using a cryptographically secure random series of bytes, which are saved to the database (optionally hashed too) and also sent to the user by e-mail.
- The disadvantage to this approach is you need to extend your database design (schema) to have a column to store this data. You should also store the UTC date+time the bytes were generated in order to have the password reset code expire.
- Другой недостаток (или преимущество) заключается в том, что пользователь может иметь не более 1 ожидающего сброса пароля.
- Using a private key to sign a HMAC message containing minimal details needed to reset the user's password, and this message can include an expiry date+time as well.
- This approach avoids needing to store anything in your database, but it also means you cannot revoke any validly-generated password-reset code, which is why it's important to use a short expiry time (about 5 minutes, I reckon).
- Вы можете хранить информацию об отзыве в базе данных (а также предотвращать множественные ожидающие сбросы паролей), но это устраняет все преимущества не сохраняющего состояния характера подписанных HMAC для аутентификации.
Подход 1: криптографически безопасный случайный код сброса пароля
- Use
System.Security.Cryptography.RandomNumberGenerator
which is a cryptographically-secure RNG.- Don't use
System.Random
, it isn't cryptographically secure. - Используйте его для генерации случайных байтов, а затем преобразования этих байтов в удобочитаемые символы, которые сохранятся в электронной почте и будут скопированы и вставлены (например, с использованием кодировки Base16 или Base64).
- Don't use
- Then store those same random bytes (or a hash of them, though this doesn't aid security all that much).
- And just include that Base16 or Base64 string in the email.
- You could have a single clickable link in the email which includes the password reset code in the querystring, however doing so violates HTTP's guidelines on what a
GET
request should be capable of (as clicking a link is always aGET
request, butGET
requests should not cause state-changes in persisted data, onlyPOST
,PUT
, andPATCH
requests should do that - which necessitates having the user manually copy the code and submit aPOST
web-form - which isn't the best user-experience.- Actually, a better approach is to have that link open a page with the password reset code in the querystring, and then that page still has a
<form method="POST">
but it's to submit the user's new password, instead of pregenerating a new password for them - thus not violating HTTP's guidelines as no change-of-state is made until the finalPOST
with the new password.
- Actually, a better approach is to have that link open a page with the password reset code in the querystring, and then that page still has a
Вот так:
Расширьте
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.
По сути, это короткое структурированное сообщение (а не случайные непредсказуемые байты), которое содержит достаточно информации, чтобы позволить системе идентифицировать пользователя, пароль которого должен быть сброшен, включая временную метку истечения срока действия - для предотвращения подделки это сообщение криптографически подписано с закрытым кодом. key, известный только коду вашего приложения: это предотвращает создание злоумышленниками собственных кодов сброса пароля (что, очевидно, было бы нехорошо!).
Вот как вы можете сгенерировать код 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.
}
}
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
- person VSO; 21.10.2020
_privateKey
, который представляет собой массив байтов (подходящей длины для выбранной схемы HMAC). Я не инициализировал его, потому что не собираюсь делиться своими закрытыми ключами на публичном форуме :)
- person Dai; 27.01.2021
На самом деле, я бы не стал делать ничего из этого.
Я столкнулся с той же проблемой, и я решил отправить токен сброса, и для этого я использовал токен JWT.
Для этого токена (который зашифрован) вы можете установить срок действия. Просто создайте токен сброса, включая адрес электронной почты Клиента в качестве утверждения, а затем установите срок действия, сохраните его в своей базе данных (в зашифрованном виде), закодируйте его и поместите в ссылку в качестве параметра URL.
Затем, когда вы получите запрос, вы можете подтвердить, что токен действителен. Затем вы можете распаковать его, посмотрев на адрес электронной почты, а затем направить их в безопасную область для сброса пароля для их учетной записи. (вы можете включить другие утверждения, такие как имя пользователя и т. д.).
Чтобы получить реализацию JWT, вы можете ввести Install-Package JWT
Я не думаю, что для этой цели вам понадобится зашифрованная строка. Я думаю, что достаточно создать одну строку с помощью Guid.
string thatString=Guid.NewGuid("n").ToString();
Сохраните это в своей таблице базы данных для этой конкретной учетной записи пользователя. Создайте ссылку для пользователя, у которого есть эта строка, и отправьте ее им. Когда они нажимают на нее, они переходят к методу действия, и вы получаете соответствующую запись пользователя, связанную с этой временной строкой, которую мы сохранили, и показываете форму для пользователя для обновления пароля.
А если у вас есть сомнения в уникальности Guid, проверьте this.
Лучше, чем использовать случайное число, - это солить, а затем хешировать. Вот отрывок от гуру безопасности:
@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
В основном просто создайте пароль. Посолите и перемешайте здесь, а затем сравните, когда пользователь вернется. Связанный ответ также содержит метод сравнения и более подробное объяснение соли / хеширования.
Random
. - person Servy   schedule 25.09.2012