Как создать ссылку для сброса пароля?

Каким способом вы бы посоветовали создать ссылку для безопасного сброса пароля в MVC и C#? Я имею в виду, что я создам случайный токен, верно? Как мне его закодировать перед отправкой пользователю? Достаточно ли MD5? Вы знаете какой-нибудь другой безопасный способ?


person agent47    schedule 25.09.2012    source источник
comment
В большинстве криптографических библиотек есть инструменты для генерации криптографически случайного числа. Вам не нужно ничего хешировать, кодировать или шифровать. Вам просто нужно случайное случайное число, которое не может предоставить класс Random.   -  person Servy    schedule 25.09.2012


Ответы (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).
  • 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 a GET request, but GET requests should not cause state-changes in persisted data, only POST, PUT, and PATCH requests should do that - which necessitates having the user manually copy the code and submit a POST 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 final POST with the new password.

Вот так:

  1. Расширьте Users таблицу ваших баз данных, включив в нее столбцы для кода сброса пароля:

    ALTER TABLE dbo.Users ADD
        PasswordResetCode  binary(12)   NULL,
        PasswordResetStart datetime2(7) NULL;
    
  2. Сделайте что-то вроде этого в коде вашего веб-приложения:

    [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.
    }
}
person Dai    schedule 25.09.2012
comment
Отличный ответ. Напомним, что для создания экземпляра мне нужно было сделать это в .NET Core: using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) - person VSO; 21.10.2020
comment
Кроме того, в качестве побочного примечания - суть хеширования в том, что если база данных скомпрометирована, они не могут использовать случайное число прямо из базы данных. Им также нужен доступ к API, поэтому он является более безопасным. Только уточнение, потому что, опять же, это отличный ответ. - person VSO; 21.10.2020
comment
Извините за некро, но с подходом 2 у вас есть вторая переменная: '_privateKey', которая не создается. Какова цель неизменяемого массива? - person Vince; 27.01.2021
comment
@Vince Я думаю, из контекста ясно, что закрытый ключ, используемый для подписи сообщения HMAC, содержится в _privateKey, который представляет собой массив байтов (подходящей длины для выбранной схемы HMAC). Я не инициализировал его, потому что не собираюсь делиться своими закрытыми ключами на публичном форуме :) - person Dai; 27.01.2021
comment
@ Дай, спасибо за прозвище. Бритва Оккамса ... не знаю, почему я над этим подумал - person Vince; 27.01.2021

На самом деле, я бы не стал делать ничего из этого.

Я столкнулся с той же проблемой, и я решил отправить токен сброса, и для этого я использовал токен JWT.

Для этого токена (который зашифрован) вы можете установить срок действия. Просто создайте токен сброса, включая адрес электронной почты Клиента в качестве утверждения, а затем установите срок действия, сохраните его в своей базе данных (в зашифрованном виде), закодируйте его и поместите в ссылку в качестве параметра URL.

Затем, когда вы получите запрос, вы можете подтвердить, что токен действителен. Затем вы можете распаковать его, посмотрев на адрес электронной почты, а затем направить их в безопасную область для сброса пароля для их учетной записи. (вы можете включить другие утверждения, такие как имя пользователя и т. д.).

Чтобы получить реализацию JWT, вы можете ввести Install-Package JWT

person krystan honour    schedule 11.04.2016
comment
что будешь делать с отладкой этого токена? в этом типе токена можно извлечь всю информацию: адрес электронной почты, срок действия ... - person a-man; 02.02.2017
comment
это функционально почти то же самое, что и второй подход в ответе @Dai - person BlackICE; 16.06.2021

Я не думаю, что для этой цели вам понадобится зашифрованная строка. Я думаю, что достаточно создать одну строку с помощью Guid.

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

Сохраните это в своей таблице базы данных для этой конкретной учетной записи пользователя. Создайте ссылку для пользователя, у которого есть эта строка, и отправьте ее им. Когда они нажимают на нее, они переходят к методу действия, и вы получаете соответствующую запись пользователя, связанную с этой временной строкой, которую мы сохранили, и показываете форму для пользователя для обновления пароля.

А если у вас есть сомнения в уникальности Guid, проверьте this.

person Shyju    schedule 25.09.2012
comment
Уникальный! = Случайный. GUID не предназначены для случайный. Они не должны быть непредсказуемыми. Они просто созданы, чтобы не повторяться. - person Servy; 25.09.2012
comment
Я тоже использую этот подход, Guid. Если вас беспокоит уникальность, вы можете проверить, существует ли она в базе данных. Я также установил для него временной лимит (чтобы ссылка была доступна только в течение двух часов), чтобы еще больше ограничить вероятность столкновений. - person Michel; 25.09.2012
comment
@Servy Я не уверен, как ваш комментарий повлияет на ответ? Вы говорите, что это плохая идея? Если они созданы, чтобы не повторяться, разве это не делает ответ верным? Я не вижу в ответе ничего, относящегося к случайности GUID, и если GUID на самом деле не повторяется, разве он не служит цели здесь? - person Jacques; 09.07.2018
comment
@Jacques Вопрос не в том, как сгенерировать значение, которое не будет конфликтовать, они спрашивают, как сгенерировать значение, которое не будет конфликтовать и которое не может быть вычислено кем-либо, кроме человека, которому дан значение. GUID выполняет первое, но не второе. Он не предназначен для непредсказуемости решительным злоумышленником. - person Servy; 10.07.2018

Лучше, чем использовать случайное число, - это солить, а затем хешировать. Вот отрывок от гуру безопасности:

@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

В основном просто создайте пароль. Посолите и перемешайте здесь, а затем сравните, когда пользователь вернется. Связанный ответ также содержит метод сравнения и более подробное объяснение соли / хеширования.

person Travis J    schedule 25.09.2012