Windows Hello というか Web Authentication API を使って、簡単にサービスにログイン出来るようになって欲しいので、GitHub に ASP.NET Core Identity を使ったデモアプリケーションを公開しました。
動かしながら仕組みを理解した方が良いと思いました。デモなので複数端末で Windows Hello を使えないとか、関連付けを削除できないとかありますが、本質的な問題ではありません。
ビルドして実行後、まずは普通にユーザー登録をすると Windows Hello のセットアップが出来るようになっています。セットアップすなわち makeCredential を呼び出して公開鍵を登録します。
セットアップ後は、ログイン画面から Windows Hello を選ぶと実行できます。
現実的な実装だと、まずはユーザー登録してから Windows Hello のセットアップという流れになりました。最初から Hello で登録もやろうと思えば出来ますが、別端末からのログインが厳しい気がします。
デモアプリケーションの要点だけは簡単に説明しておきたいと思います。
使用する Web Authentication API (FIDO 2.0) に関しては前回まとめておいたので、流れを頭に入れておくと理解の助けになるかと思います。
ApplicationUser に公開鍵を追加
Web Authentication API は公開鍵を使って署名を検証するので、ユーザーに公開鍵を持たせる必要があります。なので ApplicationUser に適当にカラムを追加して保存できるようにしておきます。
// Add profile data for application users by adding properties to the ApplicationUser class public class ApplicationUser : IdentityUser { public string PublicKey { get; set; } }
GitHub に公開しているソースにはマイグレーション追加済みですが、Entity Framework Core でもこれまでと同じように Add-Migration と Update-Database で DB への反映が行えます。
Add-Migration AddPublicKey Update-Database
Update-Database に関してはエラー画面から実行できるようになっているので結構便利です。
公開鍵の登録
makeCredential で鍵を作成
makeCredential に displayName や id を渡して新しく鍵を作成します。cryptoParameters は ScopedCred と RSASSA-PKCS1-v1_5 を指定しておけば問題ないです。
<script> $(function () { var accountInfo = { rpDisplayName: 'WebAuthnDemo', displayName: '@UserManager.GetUserName(User)', id: '@UserManager.GetUserId(User)' }; var cryptoParameters = [ { type: 'ScopedCred', algorithm: 'RSASSA-PKCS1-v1_5' } ]; $("#make").on("click", function () { navigator.authentication.makeCredential(accountInfo, cryptoParameters) .then(function (result) { $("#publickey").val(JSON.stringify(result.publicKey)); $("form").submit(); }); }); }) </script>
作成された公開鍵は JSON にしてサーバー側に submit します。
DB に公開鍵を保存
submit された公開鍵は ApplicationUser に保存しておきます。処理としては UserManager.UpdateAsync を呼び出すだけなので、こっちは非常に簡単なコードで済みます。
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> AddPublicKey(AddPublicKeyViewModel model) { if (!ModelState.IsValid) { return View(model); } var user = await GetCurrentUserAsync(); if (user == null) { return View("Error"); } user.PublicKey = model.PublicKey; await _userManager.UpdateAsync(user); return RedirectToAction(nameof(Index), "Manage"); }
これで登録の処理は完了なので、ログインの処理に移ります。ログインは少し複雑です。
ログイン処理
getAssertion で署名を作成
実際にユーザーがログインを行う場合には getAssertion に Challenge を渡して署名を作成してもらいます。Challenge はランダム文字列でセッションに保存するようにしました。
<script> $(function() { $("#windowshello").on("click", function () { var options = { timeoutSeconds: 60 }; navigator.authentication.getAssertion("@ViewData["Challenge"]", options) .then(function (result) { $("#userId").val(result.credential.id); $("#signature").val(result.signature); $("#authenticatorData").val(result.authenticatorData); $("#clientData").val(result.clientData); $("#windowshello-form").submit(); }); }); }) </script>
エラー処理はさぼっていますが、署名の生成後はサーバーに送信して検証してもらいます。
JWK から公開鍵を取得
公開鍵は Json Web Key として渡されているので、適当に JSON をパースしてパラメータを RSAParameters に詰め替えておきます。Base64 されているので、デコードしてバイト配列にしておきます。
var jwk = JObject.Parse(_publicKey); var rsaParameters = new RSAParameters { Modulus = FromBase64Url((string)jwk["n"]), Exponent = FromBase64Url((string)jwk["e"]) };
最近は Json Web *** という仕様が多くて、把握が地味に大変ですね。
Challenge を確認
getAssertion の結果として返ってくる clientData は中身が JSON なので、それをまたパースして使用した Challenge と同じものかチェックしておきます。
var clientDataBytes = FromBase64Url(clientData); var clientJson = JObject.Parse(Encoding.ASCII.GetString(clientDataBytes)); if ((string)clientJson["challenge"] != _challenge) { return false; }
Challenge が一致しない場合はログインエラーとして処理しておけばいいです。
署名を検証してログイン
アルゴリズム決め打ちになってしまっていますが、デフォルトでは RS256 (RSA + SHA-256) が使われているので、その通りに署名の検証を実装しておきます。clientData はハッシュアルゴリズムが選択可能になってますが、今は SHA-256 しかないので、ここも決め打ちです。
var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(clientDataBytes); var data = authenticatorDataBytes.Concat(hash).ToArray(); var rsa = RSA.Create(); rsa.ImportParameters(rsaParameters); rsa.VerifyData(data, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
makeCredential に RSASSA-PKCS1-v1_5 を渡しているので、パディングは PKCS1 を指定しています。実装が進んでくると、RSA 以外にも ECDSA とかの対応が必要になるかもしれません。
ユーザーのログイン自体は SignInManager.SignInAsync を呼び出すだけです。これで Windows Hello を使ったログインが行えるようになります。
公開鍵と署名があるので少し難しく感じますが、処理自体は大したことはないです。