How to create a password reset link?

asp.net-mvc, c#, forgot-password, password-recovery, security

Solution

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:

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;

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

Problem

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?

Original source

Related problems