読者です 読者をやめる 読者になる 読者になる

しばやん雑記

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

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 を使ったログインが行えるようになります。

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

Windows Hello を Web で使うための Web Authentication API (FIDO 2.0) について調べた

所有している MacBook Pro と Surface Pro 4 は両方とも顔認証を使った Windows Hello を使うようにしているので、そろそろ Web サービス側も Windows Hello で便利になって欲しいです。

Build 2016 で発表されてから、特に話題に上ることもなくなってしまいましたが、実装してみたいので Web Authentication API (FIDO 2.0) について調べました。

私が調べた範囲では Windows Hello に対応した Web サービスを見たことがないので、今のうちに一通りの実装を試しておいて、どこかで組み込むチャンスが来るのを狙うという寸法です。

W3C による標準化

元々提案された FIDO 2.0 と Web Authentication API の現在のドラフトが公開されてます。

FIDO 2.0: Web API for accessing FIDO 2.0 credentials
Web Authentication: An API for accessing Scoped Credentials

概要をさっくりと掴むには ZDNet の翻訳記事が分かりやすかったと思います。

今でもまだワーキングドラフトのままなので、勧告候補になるまでまだ時間はかかりそうです。

ブラウザ側の対応

Edge は対応済みで、Chrome と Firefox は開発中らしい。Safari は期待するだけ無駄な気がする。

とはいえ、まだ Web Authentication API 自体の仕様が固まってないので、特に Edge に関しては polyfill を使って対応するのが、今のところはベスト。

技術的な説明

Edge に実装されている msCredential が Web Authentication API とは大きく異なっているので、ちゃんと polyfill を使って後で苦労しないようにしましょう。

マイクロソフトの松崎さんが貴重な日本語解説を書いてくれています。

英語ですが Microsoft Edge のドキュメントには Web Authentication API についての説明があります。

Web Authentication API 自体は結構シンプルなので、後は対応したデバイスがあれば実装が出来そうです。現時点では Edge と Windows Hello 対応デバイスが必須です。

Web Authn API での認証の流れ

Web Authentication API では基本的には公開鍵暗号を使って認証を行います。したがって、まずサービス側に公開鍵を登録するという作業が必要です。

登録時には鍵ペアを作成し、ログイン時には署名を生成することになります。それぞれ makeCredential と getAssertion メソッドが直接対応しています。登録時には makeCredential を使って鍵ペアを作成し、公開鍵をサービス側に渡します。

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

サービス側では makeCredential に id を渡すか、実行結果に含まれている id を使ってユーザーの識別を行います。基本はこの id と公開鍵を使ってユーザーを認証します。

ログイン時にはサービスから Challenge として文字列を受け取り、getAssertion に渡して署名を作成します。

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

サービス側はその署名を受け取り、Challenge と公開鍵を使って検証を行います。検証が通れば対応する秘密鍵を持っていることが確定するので、実際にログイン処理を行って完了です。大雑把ですがこんな感じです。

検索して見つかったサンプルコードは、実際に組み込んで使えるようなレベルではなかったので、ASP.NET Identity に Windows Hello でのログイン機能を実際に追加して検証しました。コードの整理が完了したら、どのように実装したかをまた書こうと思います。

CircleCI と Docker を使って App Service on Linux へのデプロイを自動化する

App Service on Linux での Docker 対応は Azure Container Services などに比べて、お手軽なので非常に便利なんですが、Elastic Beanstalk のように Dockerfile を含むリポジトリから自動的にビルドをしてくれないので、必然的に何らかの方法で Docker Image を作成する必要があります。

Windows Server Containers な PaaS がリリースされても同じような流れが必要になると思うので、簡単に必要な流れを確認しておくことにしました。単純に Docker を使った CI/CD に興味があったとも言います。

  1. ASP.NET Core アプリケーションをプリコンパイル
  2. プリコンパイル結果を利用して Docker Image を作成
  3. Docker Hub にプッシュ
  4. App Service on Linux にプッシュした Docker Image を使うように設定

Linux なので CircleCI を使います。この一連の流れを CircleCI で実行する circle.yml を書きます。

必要なファイルや情報をあらかじめ試してから circle.yml を書いてビルドすることにします。

ASP.NET Core のプリコンパイルと Docker Image の作成

Docker を使って ASP.NET Core アプリケーションを実行する方法として、ソースコードをイメージに追加して実行時にコンパイルする方法と、プリコンパイルした結果をイメージに追加する方法の二つがあります。

どう考えてもプリコンパイルした方が起動パフォーマンスとサイズが小さくなるので、プリコンパイルした結果を使って Docker Image を作成することにします。手順は以下の記事を参考にしました。

要するに dotnet publish を実行するとビルド出来るので、その出力を使うというだけです。

用意した Dockerfile は少し変更してあります。主にベースイメージを microsoft/aspnetcore にした点です。

FROM microsoft/aspnetcore:1.1.0

WORKDIR /app
COPY . .

EXPOSE 80

ENTRYPOINT  ["dotnet", "AspNetCoreDocker.dll"]

App Service on Linux ではデフォルトで 80 番を見に行くので EXPOSE しておきます。

この Dockerfile を使ってビルドすると、ローカルでも ASP.NET Core アプリケーションは動作します。

App Service on Linux の Docker Image を変更

App Service on Linux の Custom Docker Image は AppSettings で保持しているシンプルな実装なので、基本的にはここの値を変更すると新しいコンテナが起動するようになっています。

新しい Azure CLI 2.0 には変更用のコマンドが用意されているので、これを使うのがシンプルです。

Azure CLI を使うためのサービスプリンシパルも 2.0 から簡単に作れるようになっているので便利です。

CI では az login を実行後、az appservice web config container update を実行すれば変更できます。

CircleCI で一連の処理を実行

ドキュメントに Docker を使って Docker Hub にプッシュしたり、Elastic Beanstalk にデプロイする方法が書かれているので、殆どそのまま参考にして App Service on Linux 向けにします。

追加要素としては ASP.NET Core アプリケーションをビルドするために .NET Core をインストールしたり、Azure CLI 2.0 をインストールするぐらいです。

以前に書いたことがありますが、ビルド時間の短縮のために環境変数も設定しておきます。

一通りの処理を書いた circle.yml は以下のようになりました。少し長めです。認証情報は環境変数ですが、Web App 名やリソースグループ名は環境変数にするまでもないと思ったので直接書いてあります。

machine:
  services:
    - docker
  environment:
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
    NUGET_XMLDOC_MODE: skip

dependencies:
  pre:
    - pip install azure-cli
    - sudo sh -c 'echo "deb [arch=amd64] https://apt-mo.trafficmanager.net/repos/dotnet-release/ trusty main" > /etc/apt/sources.list.d/dotnetdev.list'
    - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 417A0893
    - sudo apt-get update; sudo apt-get install dotnet-dev-1.0.0-preview2.1-003177
  override:
    - dotnet restore

compile:
  pre:
    - rm -rf $(pwd)/publish/app
  override:
    - dotnet publish src/AspNetCoreDocker/project.json -c Release -o $(pwd)/publish/app
    - docker build --rm=false -t shibayan/aspnetcore-demo:$CIRCLE_SHA1 $(pwd)/publish/app

test:
  override:
    - docker run -d -p 8080:80 shibayan/aspnetcore-demo:$CIRCLE_SHA1; sleep 10
    - curl --retry 10 --retry-delay 5 -v http://127.0.0.1:8080

deployment:
  appservice:
    branch: master
    commands:
      - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
      - docker push shibayan/aspnetcore-demo:$CIRCLE_SHA1
      - az login --service-principal -u "$AZURE_USER" -p $AZURE_PASS --tenant $AZURE_TENANT
      - az appservice web config container update -c "shibayan/aspnetcore-demo:$CIRCLE_SHA1" -n aspnetcore-docker -g LinuxAppServiceRG

CircleCI の設定から環境変数を一通り追加しておきます。Docker の扱いだけ心配ですが、実際に使う場合は Azure Container Registry を使ってプライベートな運用をすると良いです。

Azure Container Registry を使う場合、docker login の部分とイメージ名、そして container update に認証情報を渡す部分を変えるだけで済みます。

気を取り直して、実際に CircleCI の設定から環境変数を追加しておきました。

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

ちょっと追加するのが面倒ですが、Git に含めてしまうのはありえないので仕方ないです。

ビルド結果を確認

準備がこれで出来たので、CircleCI でビルドさせてその結果を確認しておきます。ちゃんと Azure CLI の呼び出しまで成功していることが簡単に確認できました。CircleCI は見やすくて良いですね。

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

ちゃんと Docker Hub にはコミットハッシュでタグが切られています。

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

Web App にアクセスすると、ASP.NET Core アプリケーションが動作しているのがわかります。

本当にイメージが切り替わったかはログを確認するしかないので、この辺り GA までにもっとわかりやすくなって欲しいです。ログは /home/LogFiles/docker に書き出されています。

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

ポータルから Docker の設定も確認しました。ちゃんと CircleCI で作成したイメージが使われていました。

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

CircleCI は最初から Docker が入っているので、簡単にビルドして Docker Hub にプッシュまで行うことが出来てかなり楽でした。環境構築に時間がかかるのが少しネックです。

Windows Server Containers でも基本的には同じ流れになると思うのと、Azure Container Services の Windows 対応は今のところ Swarm のみ使えるらしい*1ので、流れを知るいい勉強になりました。