How to create a password reset link?

agent47 picture agent47 · Sep 25, 2012 · Viewed 13.6k times · Source

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?

Answer

Dai picture Dai · Sep 25, 2012

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 );
    
            String base64 = Convert.ToBase64String( bytes );
            bytesBase64Url = base64.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 universes ultimate cup of coffee... uh... I mean, you can reset your password at {1}
    Black!
    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 };
private static readonly TimeSpan _passwordResetExpiry = TimeSpan.FromMinutes( 5 );
private static readonly ImmutableArray<Byte> _privateKey = 
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.GetInt32( message, 1 );
    Int64 createdUtcBinary = BitConverter.GetInt64( message, 1 + sizeof(Int32) );
    
    DateTime createdUtc = DateTime.FromBinary( createdUtcBinary );
    if( createdUtc.Add( _passwordResetExpiry ) < DateTime.UtcNow ) return false;
    
    const Int32 _messageLength = 1 + sizeof(Int32) + sizeof(Int64);

    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 ResetPassword( 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." );
    }
    
    // (Proceed to reset the user's password)
}