と で安全なパスワード リセット リンクMVC
を作成するには、どの方法をお勧めしますC#
か? つまり、ランダムなトークンを作成しますよね?ユーザーに送信する前にエンコードするにはどうすればよいですか? MD5で十分ですか?他の安全な方法を知っていますか?
4 に答える
つまり、ランダムなトークンを作成しますよね?
2つのアプローチがあります:
- 暗号的に安全なランダムな一連のバイトを使用します。これらのバイトはデータベースに保存され(オプションでハッシュ化されます)、電子メールでユーザーに送信されます。
- このアプローチの欠点は、データベース設計(スキーマ)を拡張して、このデータを格納する列を作成する必要があることです。パスワードリセットコードを期限切れにするために、バイトが生成されたUTC日付と時刻も保存する必要があります。
- もう1つの欠点(または利点)は、ユーザーが保留中のパスワードリセットを最大で1つしか持てないことです。
- 秘密鍵を使用して、ユーザーのパスワードをリセットするために必要な最小限の詳細を含むHMACメッセージに署名します。このメッセージには、有効期限と時刻を含めることもできます。
- このアプローチでは、データベースに何かを保存する必要がなくなりますが、有効に生成されたパスワードリセットコードを取り消すこともできないため、有効期限を短くすることが重要です(約5分だと思います)。
- 失効情報をデータベースに保存することもできますが(複数の保留中のパスワードリセットを防ぐこともできます)、これにより、認証用の署名付きHMACのステートレスな性質の利点がすべて失われます。
アプローチ1:暗号的に安全なランダムパスワードリセットコード
System.Security.Cryptography.RandomNumberGenerator
暗号的に安全なRNGであるものを 使用します。- 使用しないでください
System.Random
。暗号的に安全ではありません。 - これを使用してランダムなバイトを生成し、それらのバイトを人間が読み取れる文字に変換します。この文字は、電子メールで存続し、コピーおよび貼り付けされます(つまり、Base16またはBase64エンコーディングを使用します)。
- 使用しないでください
- 次に、それらの同じランダムバイト(またはそれらのハッシュ)を格納しますが、これはセキュリティにはそれほど役立ちません。
- そして、そのBase16またはBase64文字列を電子メールに含めるだけです。
- クエリ文字列にパスワードリセットコードを含む単一のクリック可能なリンクをメールに含めることができますが、そうすると、リクエストの機能に関するHTTPのガイドラインに違反します
GET
(リンクをクリックすることは常にGET
リクエストですが、GET
リクエストによって状態が発生することはありません) -永続化されたデータの変更、、、およびリクエストのみPOST
がPUT
それPATCH
を行う必要があります-ユーザーが手動でコードをコピーしてPOST
Webフォームを送信する必要があります-これは最高のユーザーエクスペリエンスではありません。- 実際、より良いアプローチは、クエリ文字列にパスワードリセットコードが含まれるページをそのリンクで開くことです。そのページにはまだがあります
<form method="POST">
が、ユーザーの新しいパスワードを事前に生成するのではなく、ユーザーの新しいパスワードを送信することです。したがって、違反することはありません。POST
新しいパスワードを使用した最終版まで状態変化は行われないため、HTTPのガイドライン。
- 実際、より良いアプローチは、クエリ文字列にパスワードリセットコードが含まれるページをそのリンクで開くことです。そのページにはまだがあります
そのようです:
データベースのテーブルを拡張して
Users
、パスワードリセットコードの列を含めます。ALTER TABLE dbo.Users ADD PasswordResetCode binary(12) NULL, PasswordResetStart datetime2(7) NULL;
Webアプリケーションのコードで次のようなことを行います。
[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. } }
アプローチ2:HMACコード
このアプローチでは、データベースを変更したり、新しい状態を維持したりする必要はありませんが、HMACがどのように機能するかを理解する必要があります。
基本的に、これは(ランダムな予測不可能なバイトではなく)短い構造化メッセージであり、有効期限のタイムスタンプなど、パスワードをリセットする必要があるユーザーをシステムが識別できるようにするための十分な情報が含まれています。アプリケーションコードだけが知っているキー:これにより、攻撃者が独自のパスワードリセットコードを生成するのを防ぐことができます(これは明らかに良くありません!)。
パスワードリセット用のHMACコードを生成する方法と、それを確認する方法は次のとおりです。
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 );
}
}
そのように使用されます:
// 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.
}
}
実際、私はこれらのどれもしません。
私は同じ問題に直面し、リセット トークンを送信することにしました。これを行うには、JWT トークンを使用しました。
そのトークン (暗号化されている) では、有効期限を設定できます。顧客の電子メール アドレスをクレームとして含むリセット トークンを作成し、有効期限を設定し、これをデータベースに (暗号化された形式で) 保存してエンコードし、URL パラメーターとしてリンクに配置するだけです。
次に、リクエストを受信すると、トークンが有効であることを確認できます。次に、メールアドレスを確認して解凍し、アカウントの安全なパスワードリセットエリアに誘導します. (ユーザー名などの他のクレームを含めることができます)。
JWT実装を取得するには、次のように入力できますInstall-Package JWT
この目的のために暗号化された文字列は必要ないと思います。Guidで1つの文字列を作成するだけで十分だと思います。
string thatString=Guid.NewGuid("n").ToString();
その特定のユーザーアカウントに対してdbテーブルに保存します。この文字列を持つユーザーのリンクを作成して送信します。ユーザーがそれをクリックすると、アクションメソッドに移動し、保存したこの一時文字列に関連付けられた対応するユーザーレコードを取得し、ユーザーがパスワードを更新するためのフォームを表示します。
また、Guidが一意であるかどうか疑問がある場合は、これを確認してください。
乱数を使用するよりも、ソルトしてからハッシュする方がよいでしょう。セキュリティの第一人者からの抜粋は次のとおりです。
@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);
}
あなたはここで彼の答えについてもっと見ることができます:https ://stackoverflow.com/a/2138588/1026459
基本的にはパスワードを作成するだけです。ここでソルトしてハッシュし、ユーザーが戻ってきたときに比較します。リンクされた回答には、比較方法とソルト/ハッシュのより詳細な説明も含まれています。