How to create a password reset link?
Asked Answered
G

4

11

Which way would you suggest to create a secure password reset link in MVC and C#? I mean, I'll create a random token, right? How do I encode it before to sending to user? Is MD5 good enough? Do you know any other secure way?

Gayton answered 25/9, 2012 at 0:5 Comment(1)
Most cryptographic libraries have tools for generating a cryptographically random number. You don't need to hash, encode, or encrypt anything. You just need a random random number than the Random class can provide.Scarborough
C
26

I mean, I'll create a random token, right?

There are two approaches:

  • 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.
    • Another disadvantage (or an advantage) is that a user can only have at-most 1 pending password-reset.
  • 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).
    • You could store revocation information in the database (as well as preventing multiple pending password-resets) but this removes all of the advantages of the stateless nature of signed HMACs for authentication.

Approach 1: Cryptographically secure random password reset code

  • Use System.Security.Cryptography.RandomNumberGenerator which is a cryptographically-secure RNG.
    • Don't use System.Random, it isn't cryptographically secure.
    • Use it to generate random bytes and then convert those bytes to human-readable characters that will survive e-mail and being copied and pasted around (i.e. by using Base16 or Base64 encoding).
  • 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.

Like so:

  1. Extend your databases' Users table to include columns for the password-reset code:

    ALTER TABLE dbo.Users ADD
        PasswordResetCode  binary(12)   NULL,
        PasswordResetStart datetime2(7) NULL;
    
  2. Do something like this in your web application's code:

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

Approach 2: HMAC code

This approach requires no changes to your database nor to persist new state, but it does require you to understand how HMAC works.

Basically it's a short structured message (rather than being random unpredictable bytes) that contains enough information to allow the system to identify the user whose password should be reset, including an expiry timestamp - to prevent forgery this message is cryptographically-signed with a private-key known only to your application code: this prevents attackers from generating their own password reset codes (which obviously wouldn't be good!).

Here's how you can generate a HMAC code for password reset, as well as how to verify it:

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 );
    }
}

Used like so:


// 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.
    }
}
Caras answered 25/9, 2012 at 0:8 Comment(5)
Great answer. As a heads up, I needed to do this in .NET Core to create an instance: using (RandomNumberGenerator rng = RandomNumberGenerator.Create())Dashboard
Also, as a side note - the point to hashing it that if the db is compromised, they cannot use the random number straight out of the db. They also need access to the API, so it is more secure. Only clarifying because, again, this is a great answer.Dashboard
Sorry to necro this, with approach 2 you have a second variable : '_privateKey ' that is not instantiated. What is the purpose of the immutable array?Sherrylsherurd
@Sherrylsherurd I think it's clear from the context that the private-key used to sign the HMAC message is contained in _privateKey, which is an array of bytes (of suitable length for the chosen HMAC scheme). I didn't initialize it because I'm not going to share my private-keys on a public forum :)Caras
@Dai, thank you for the calrification. Occams Razor... not sure why i was over thinking itSherrylsherurd
D
4

Actually, I wouldn't do any of these.

I faced the same issue and I decided to send a reset token and to do this I used a JWT token.

On that token (which is encrypted) you can set an expiry. Simply create a reset token including the Customers email address as a claim and then set your expiry, store this in your database (in its encrypted form) and encode it and place on the link as URL parameter.

Then when you receive the request you can verify the token is valid. You can then unpack it look at the email address and then proceed to direct them to your secure password reset area for their account. (you can include other claims such as username etc).

To get the JWT implemnetation you can type Install-Package JWT

Damnify answered 11/4, 2016 at 15:20 Comment(2)
what will you do with debugging of this token? it is possible to extract all of the information in this kind of token:email, expiry date...Scarab
this is functionally almost the same as the second approach in @Caras answerInveracity
P
2

I do not think you need an encrypted string for this purpose. I think creating one string with Guid would be enough.

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

Save that in your db table against that particular user account. Create a link for the user which has this string and send it to them. When they click on it, it will take them to an action method and their you get the corresponding user record associated with this temp string we stored and show the form for user to update the password.

And if you have a doubt whether Guid's are unique, checkout this.

Pander answered 25/9, 2012 at 0:7 Comment(4)
Unique != random. GUIDs are not designed to be random. They are not designed to be unpredictable. They are simply designed to not repeat.Scarborough
I use this approach too, the Guid. If you are bothered with the uniqueness, you can check wether it already exists in the database. I also set a timelimit on it (so the link is only clickable for two hours) so that limits the chance on collisions even more.Amongst
@Scarborough I'm not sure how your comment impacts the answer? Are you saying this is a bad idea? If they're designed not to repeat then doesn't that make the answer valid? I don't see anything in the answer referencing the randomness of the GUID and as long as the GUID does in fact not repeat then does it not serve the purpose here?Transport
@Transport The question is not asking how to generate a value that won't collide, they're asking how to generate a value that won't collide and that cannot be computed by anyone other than the person who is given the value. A GUID does the former, but not the latter. It is not designed to be unpredictable by a determined malicious attacker.Scarborough
A
1

Better than using a random number is to salt then hash. Here is a snippet from a security guru:

@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);            
}

You can see more on his answer here: https://mcmap.net/q/126442/-hash-and-salt-passwords-in-c

Basically just create a password. Salt and hash it here, and then compare it when the user returns. The linked answer also contains a comparison method and a more in depth explanation of salt/hashing.

Antisepticize answered 25/9, 2012 at 0:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.