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:
- 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).
- Don't use
- 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 aGET
request, butGET
requests should not cause state-changes in persisted data, onlyPOST
,PUT
, andPATCH
requests should do that - which necessitates having the user manually copy the code and submit aPOST
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 finalPOST
with the new password.
- 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
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.
}
}
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
–
Dashboard _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 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
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.
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.
© 2022 - 2024 — McMap. All rights reserved.
Random
class can provide. – Scarborough