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?
I mean, I'll create a random token, right?
There are two approaches:
System.Security.Cryptography.RandomNumberGenerator
which is a cryptographically-secure RNG.
System.Random
, it isn't cryptographically secure.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.
<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 );
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.
}
}
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)
}