しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET Core Identity に Windows Hello を使ったログイン機能を追加する

Windows Hello というか Web Authentication API を使って、簡単にサービスにログイン出来るようになって欲しいので、GitHub に ASP.NET Core Identity を使ったデモアプリケーションを公開しました。

動かしながら仕組みを理解した方が良いと思いました。デモなので複数端末で Windows Hello を使えないとか、関連付けを削除できないとかありますが、本質的な問題ではありません。

ビルドして実行後、まずは普通にユーザー登録をすると Windows Hello のセットアップが出来るようになっています。セットアップすなわち makeCredential を呼び出して公開鍵を登録します。

f:id:shiba-yan:20170131150243p:plain

セットアップ後は、ログイン画面から Windows Hello を選ぶと実行できます。

f:id:shiba-yan:20170131150203p:plain

現実的な実装だと、まずはユーザー登録してから 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 を使ったログインが行えるようになります。

公開鍵と署名があるので少し難しく感じますが、処理自体は大したことはないです。