ASP.NET_Core

ASP.NET Core 2.0 Authentication

ASP.NET Core 2.0 Authentication

ASP.NET Core は認証周りもフレームワークがサポートしていてかなり便利なのですが、サンプルで提示されている以外のことをやろうとするとドキュメント不足に陥りました。調べた結果をまとめます。

前提知識

  • ASP.NET Core MVC/Web API のチュートリアルくらいは分かる

本題

認証ハンドラーの登録

ASP.NET Core 2.0 では認証のための実装はサービスとしてすでに用意されています。そちらの基本部分の登録は AddAuthentication メソッドで行います。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication();
    }

    // ...
}

ただし、登録されているのはフレームワークで、例えば Cookie にトークンを埋め込むなどの実装は認証ハンドラーを登録する必要があります。

注) 認証ハンドラー が正式な用語かはわかりません。ただ、これらは IAuthenticationHandler インターフェイスを実装しているため、ここでは認証ハンドラーと呼びます。

この認証ハンドラーは標準でいくつか用意されています。以下はそれを抜き出した表です。

実装名 目的
CookieAuthenticationHandler Cookie でトークンを管理する
JwtBearerHandler HTTP Authorization ヘッダーの Bearer でトークンを管理する
OAuthHandler OAuth ログインを管理する
OpenIdConnectHandler Open ID Connect ログインを管理する
MicrosoftAccountHandler Microsoft Account ログインを管理する

大雑把に分類すると、トークンをブラウザーとやり取りする CookieJwtBearer、外部認証からトークンを受け取るための OAuthOpenIdConnectMicrosoftAccount の二つに別れます。(厳密には JwtBearer はトークンのブラウザーへ発行ができません)

比較的分かりやすい JwtBearerHandler を使って説明します。

AddAuthentication メソッドの戻り値は AuthenticationBuilder オブジェクトとなっていて、ここで認証処理用のハンドラーを登録します。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication()
            .AddJwtBearer();
    }

    // ...
}

登録した認証ハンドラーは HttpContext オブジェクトから呼び出せるようになります。認証されていない状態 (HTTP Code 401) を返す ChallengeAsync メソッドを呼び出します。(認証ハンドラーとして JwtBearer を明示的に指定しています)

internal sealed class Startup
{
    // ...

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.Run(context => context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme));
    }
}

実行して、ウェブアプリにアクセスすると、HTTP/1.1 401 Unauthorized が返るようになります。

$ curl -v http://localhost:5000
* Rebuilt URL to: http://localhost:5000/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< Date: Thu, 18 Jan 2018 03:45:24 GMT
< Server: Kestrel
< Content-Length: 0
< WWW-Authenticate: Bearer
< 
* Connection #0 to host localhost left intact

せっかくですので、CookieAuthenticationHandler を使ってみます。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication()
            .AddJwtBearer()
            .AddCookie(); // CookieAuthenticationHandler も登録
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // CookieAuthenticationHandler.ChallengeAsync の呼び出し
        app.Run(context => context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme));
    }
}

同じように実行してアクセスすると、HTTP/1.1 302 Found が返ります。

$ curl -v http://localhost:5000
* Rebuilt URL to: http://localhost:5000/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 302 Found
< Date: Thu, 18 Jan 2018 03:57:19 GMT
< Server: Kestrel
< Content-Length: 0
< Location: http://localhost:5000/Account/Login?ReturnUrl=%2F
< 
* Connection #0 to host localhost left intact

Location ヘッダーでログインページにジャンプするようになっています。

ChallengeAsync 以外にも認証ハンドラーは以下のメソッドが実装されています。

メソッド名 動作
AuthenticateAsync HTTP リクエストヘッダー内のトークンから認証情報を取り出します
ChallengeAsync 認証が必要なことを示すレスポンスを返します
ForbidAsync 権限が足りないことを示すレスポンスを返します

デフォルトスキームの登録

HttpContext.ChallengeAsync メソッドを呼び出すたびにいちいちスキーム名を指定していたら大変面倒です。そこで、デフォルトスキームというものを登録できるようになっています。

以下のように、AddAuthentication メソッドでデフォルトスキームを登録します。

internal sealed class Startup
{
    // ReSharper disable once UnusedMember.Global
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
                {
                    // ChallengeAsync 呼び出し時に JWT を使うように
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                })
            .AddJwtBearer()
            .AddCookie();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // デフォルトの Challenge スキームを呼び出します
        app.Run(context => context.ChallengeAsync());
    }
}

AuthenticateAsyncForbidAsync にもデフォルトスキームが用意されています。あまり意味はないと思いますが、ChallengeAsync はクッキーを ForbidAsync には JWT Bearer トークンをと使い分けることもできます。

options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme;

全て共通のデフォルトスキームにすることもできます。以下の一行だけで、すべてのメソッドのデフォルトスキーマを設定できます。

options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;

SignInAsync

少し脱線して、SignInAsync メソッドを説明します。メソッドの名前からは分かりにくいですが、認証情報をブラウザーへ返す役割があります。

ただ、すべての認証ハンドラーがこれを実装しているかというとそうではなく、標準では CookieAuthenticationHadleer のみのようです。認証ハンドラーが IAuthenticationSignInHandler インターフェイスを実装しているかどうかで区別がつきます。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
            {
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddCookie();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.Run(context =>
        {
            // 認証情報を作る
            var identity = new ClaimsIdentity();
            identity.AddClaim(new Claim(ClaimTypes.Name, "taro"));
            var principal = new ClaimsPrincipal(identity);

            // 認証情報を登録する
            return context.SignInAsync(principal);
        });
    }
}

実行してアクセスすると、以下のように Cookie にトークンが埋め込まれていることがわかります。

> curl -v http://localhost:5000
* Rebuilt URL to: http://localhost:5000/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Thu, 18 Jan 2018 04:27:58 GMT
< Server: Kestrel
< Content-Length: 0
< Cache-Control: no-cache
< Pragma: no-cache
< Expires: -1
< Set-Cookie: .AspNetCore.Cookies=CfDJ8H5PtWI7BWJGl2cIGtbQbFI_QkDCQLPWmWmvo84r_KgLlLHWSpmP9iaVQww667ZXF-jKAdjBCP_1roRbXqwgyZFuzJkQ8uI6uza41HynfM8dp8nA8iGirT9e3jOHpYLymnG25i_1UHO_hHzjlPy8K8ZAil8RMOfQT34sSguZivvOGDZiBvrb2x2SJvDC2exqc0zCCoIzwPESSDxRPqci2nRMwQY_rysjpn_w4pbwjGLuG7Z02f_s9h2JcpxLXwFl87-7DAKzqJYvZPSJG4qXKnlSPdZfAoH9Tgu3QmtN4zif; path=/; samesite=lax; httponly
< 
* Connection #0 to host localhost left intact

AuthenticateAsync

先ほど説明を端折っていた AuthenticateAsync メソッドは、主に HTTP リクエストヘッダー内にあるトークンから認証情報を取り出す役割があります。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddCookie();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.Run(async context =>
        {
            // 認証情報の結果を取り出す
            var result = await context.AuthenticateAsync();

            // 認証チケットから認証情報を取り出す
            var principal = result.Ticket.Principal;

            // 名前を出力
            await context.Response.WriteAsync(principal.FindFirst(ClaimTypes.Name).Value);
        });
    }
}

また実行してアクセスします。もちろん、先ほどの Cookie に埋め込まれたトークンを合わせて渡します。

$ curl -v -b '.AspNetCore.Cookies=CfDJ8H5PtWI7BWJGl2cIGtbQbFI_QkDCQLPWmWmvo84r_KgLlLHWSpmP9iaVQww667ZXF-jKAdjBCP_1roRbXqwgyZFuzJkQ8uI6uza41HynfM8dp8nA8iGirT9e3jOHpYLymnG25i_1UHO_hHzjlPy8K8ZAil8RMOfQT34sSguZivvOGDZiBvrb2x2SJvDC2exqc0zCCoIzwPESSDxRPqci2nRMwQY_rysjpn_w4pbwjGLuG7Z02f_s9h2JcpxLXwFl87-7DAKzqJYvZPSJG4qXKnlSPdZfAoH9Tgu3QmtN4zif' http://localhost:5000
* Rebuilt URL to: http://localhost:5000/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
> Cookie: .AspNetCore.Cookies=CfDJ8H5PtWI7BWJGl2cIGtbQbFI_QkDCQLPWmWmvo84r_KgLlLHWSpmP9iaVQww667ZXF-jKAdjBCP_1roRbXqwgyZFuzJkQ8uI6uza41HynfM8dp8nA8iGirT9e3jOHpYLymnG25i_1UHO_hHzjlPy8K8ZAil8RMOfQT34sSguZivvOGDZiBvrb2x2SJvDC2exqc0zCCoIzwPESSDxRPqci2nRMwQY_rysjpn_w4pbwjGLuG7Z02f_s9h2JcpxLXwFl87-7DAKzqJYvZPSJG4qXKnlSPdZfAoH9Tgu3QmtN4zif
> 
< HTTP/1.1 200 OK
< Date: Thu, 18 Jan 2018 04:39:14 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
taro

無事、名前が表示されました!

UseAuthentication

先ほど AuthenticateAsync メソッドで認証情報を取り出せるようになりました。これを手軽にしてくれるのが AuthenticationMiddleare です。このミドルウェアは AuthenticateAsync メソッドから得た認証情報を HttpContext.User に登録してくれます。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddCookie();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // トークンを読み取って認証情報を設定するようにする
        app.UseAuthentication();

        app.Run(async context =>
        {
            // HttpContext.User プロパティから認証情報を取り出す
            var principal = context.User;

            // 名前を出力
            await context.Response.WriteAsync(principal.FindFirst(ClaimTypes.Name).Value);
        });
    }
}

同じように、実行して Cookie トークン付きでアクセスします。

$ curl -v -b '.AspNetCore.CookCfDJ8H5PtWI7BWJGl2cIGtbQbFI_QkDCQLPWmWmvo84r_KgLlLHWSpmP9iaVQww667ZXF-jKAdjBCP_1roRbXqwgyZFuzJkQ8uI6uza41HynfM8dp8nA8iGirT9e3jOHpYLymnG25i_1UHO_hHzjlPy8K8ZAil8RMOfQT34sSguZivvOGDZiBvrb2x2SJvDC2exqc0zCCoIzwPESSDxRPqci2nRMwQY_rysjpn_w4pbwjGLuG7Z02f_s9h2JcpxLXwFl87-7DAKzqJYvZPSJG4qXKnlSPdZfAoH9Tgu3QmtN4zif' http://localhost:5000
* Rebuilt URL to: http://localhost:5000/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
> Cookie: .AspNetCore.Cookies=CfDJ8H5PtWI7BWJGl2cIGtbQbFI_QkDCQLPWmWmvo84r_KgLlLHWSpmP9iaVQww667ZXF-jKAdjBCP_1roRbXqwgyZFuzJkQ8uI6uza41HynfM8dp8nA8iGirT9e3jOHpYLymnG25i_1UHO_hHzjlPy8K8ZAil8RMOfQT34sSguZivvOGDZiBvrb2x2SJvDC2exqc0zCCoIzwPESSDxRPqci2nRMwQY_rysjpn_w4pbwjGLuG7Z02f_s9h2JcpxLXwFl87-7DAKzqJYvZPSJG4qXKnlSPdZfAoH9Tgu3QmtN4zif
> 
< HTTP/1.1 200 OK
< Date: Thu, 18 Jan 2018 04:48:34 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
taro

同じように名前が表示されました!

一般的なユースケースでは、ログインユーザーの取得はこの方法のほうが使われると思います。AuthenticateAsync メソッドを使うことは稀ではないかと。

HandleRequestAsync

認証ハンドラーで少し特殊なのがこの HandleRequestAsync メソッドです。SignInAsync と同様に認証ハンドラーの基本機能ではなく、IAuthenticationRequestHandler インターフェイスを実装する必要があります。JwtBearerHandlerCookieAuthenticationHandler では実装されておらず、MicrosoftAccountHandler を始めとする外部認証の認証ハンドラーで実装されています。

また、他のメソッドと異なり、HttpContext.HandleReqyestAync メソッドが用意されておらず、デフォルトスキーマもありません。直接使うことなく、AuthenticationMiddleware からリクエストのたびにすべての認証ハンドラーの分が呼び出されます。

このメソッドの目的は、外部認証から認証コードをを受け取るためにあります。

Microsoft Account を始めとする外部認証は、認証の完了後、ブラウザーにウェブアプリに制御を戻すようリダイレクトを指示します。このリダイレクト URL に認証コードが埋め込まれています。

Location: http://localhost:5000/signin-microsoft?code=<authorization_code>&...

MicrosoftAccountHandler.HandleRequestAsync メソッドは、リクエストが http://localhost:5000/signin-microsoft の時にだけ反応するようになっています。URL パラメーターから認証コードを取得し、(かなり端折りますけど) 認証情報を作成します。この認証情報を HttpContext.SignInAsync メソッドを呼び出して登録します。

ところで、MicrosoftAccountHandlerSignInAsync メソッドを提供していませんので、その部分は CookieAuthenticationHandler を利用する必要があります。また、AuthenticateAsync も自分自身では処理をせず、デフォルトの SignIn スキームに登録されている認証ハンドラーに処理を委譲します。

つまり、Microsoft Account で認証をする場合は、SignIn スキームに CookieAuthenticationHandler を登録する必要があります。

internal sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
            {
                // デフォルトを Microsoft Account に
                options.DefaultScheme = MicrosoftAccountDefaults.AuthenticationScheme;
                // SignInAsync/SingOutAsync だけは Cookie を使う
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddMicrosoftAccount(options =>
            {
                options.ClientId = "....";
                options.ClientSecret = "...."
            })
            .AddCookie();
    }

    // ...
}
  • ChallengeAsyncMicrosoftAccountHandler が処理し、外部認証ページへリダイレクト
  • AuthenticateAsync はMicrosoftAccountHandlerが呼び出されるが、処理自体はCookieAuthenticationHandler` に委譲される
  • SignInAsyncCookieAuthenticationHandler が処理し、Cookie にトークンを埋め込む
  • 外部認証からの認証情報の取得は MicrosoftAccountHandler が担当し、CookieAuthenticationHandlerSignInAsync を呼び出して、Cookie にトークンを埋め込んでいる

終わりに

ASP.NET Core Authentication は ASP.NET MVC (Web API) や ASP.NET Identity とは無関係に使えるんですね。特に、ASP.NET identity を使っているサンプルと使っていないサンプルで混乱していたので、調べてみてかなりすっきりしました。