October 2017

Volume 32 Number 10

Cutting Edge - ASP.NET Core でのポリシーベースの承認

Dino Esposito | October 2017

Dino Esposito特定のリソースにアクセスすること、特定の操作を実行すること、または特定のリソースに対して特定の操作を実行することが現在のユーザーに許可されているかどうかは、ソフトウェア アプリケーションの承認レイヤーで確認されます。ASP.NET Core では、承認レイヤーをセットアップする方法が 2 通りあります。役割を使用する方法と、ポリシーを使用する方法です。前者の方法、つまり役割ベースの承認は、前バージョンの ASP.NET プラットフォームから引き継がれたものです。それに対し、ポリシーベースの承認は ASP.NET Core で初めて導入されたものです。

Authorize 属性

役割は、初期の ASP.NET アプリケーションから使用されています。技術的には、役割とはプレーンな文字列です。ただし、役割の値は、セキュリティ レイヤーではメタ情報として処理されます (IPrincipal オブジェクトで存在がチェックされます)。また、アプリケーションでは、特定の認証済みユーザーに一連のアクセス許可をマップするために使用します。ASP.NET では、ログインしたユーザーは IPrincipal オブジェクトによって識別されます。ASP.NET Core でのその実際のクラスは ClaimsPrincipal になります。このクラスは ID のコレクションを公開します。各 ID は IIdentity オブジェクト (具体的には ClaimsIdentity オブジェクト) で表されます。つまり、ログインしたユーザーには、要求 (claim) のリストが用意されます。要求とは基本的には、ユーザーの状態に関するステートメントです。ユーザー名と役割が、ASP.NET Core アプリケーションのユーザーに対する 2 つの一般的な要求です。ただし、役割の有無は、使用する ID ストアによって変わります。たとえば、ソーシャル認証を使用する場合、役割は存在しません。

承認は、認証の 1 段階先の行為です。認証は、ユーザーの ID を検出するのが目的です。それに対し、承認はユーザーがアプリケーション エンドポイントを呼び出すための要件を定義します。通常、ユーザーの役割はデータベースに格納され、ユーザーの資格情報を検証するときに取得します。この時点で、役割情報がなんらかの方法でユーザー アカウントに結び付けられます。IIdentity インターフェイスには IsInRole メソッドがあり、これを実装する必要があります。ClaimsIdentity クラスでは、認証プロセスによって用意される要求のコレクションで、役割要求を利用できるかどうかをチェックすることにより、このようなしくみを実現します。いずれの場合でも、ユーザーがセキュリティ保護されたコントローラー メソッドの呼び出しを試みる際には、そのユーザーの役割をチェックできる必要があります。チェックできなければ、ユーザーは、セキュリティ保護されたメソッドの呼び出しを拒否されます。

Authorize 属性は、コントローラーまたはその一部のメソッドを保護する宣言型の方法です。

[Authorize]
public class CustomerController : Controller
{
  ...
}

引数を使用せずに指定した場合、この属性はユーザーの認証状態のみをチェックします。ただし、この属性では Roles などの追加属性がサポートされます。Roles プロパティは、一覧に記述された役割のユーザーに、アクセス権が付与されることを示します。複数の役割を要求するには、複数回 Authorize 属性を適用したり、独自のフィルターを記述します。

[Authorize(Roles="admin, system"]
public class BackofficeController : Controller
{
  ...
}

必要に応じて、Authorize 属性は、ActiveAuthenticationSchemes プロパティを使用して 1 つ以上の認証方式を受け取ることもできます。

[Authorize(Roles="admin, system", ActiveAuthenticationSchemes="Cookie"]
public class BackofficeController : Controller
{
  ...
}

ActiveAuthenticationSchemes プロパティは、コンマ区切りの文字列で、現在のコンテキストで承認レイヤーから信頼される認証ミドルウェア コンポーネントの一覧を取得します。つまり、BackofficeController クラスへのアクセスは、ユーザーが Cookie 方式経由で認証済みで、なおかつ一覧に記述されたいずれかの役割を持つ場合のみ許可されることが示されています。前述のように、ActiveAuthenticationSchemes プロパティに渡す文字列値は、アプリケーションの起動時に登録された認証ミドルウェアと一致する必要があります。

ASP.NET 2.0 では、認証ミドルウェアが、ハンドラーを複数持つサービスに置き換わることに注意してください。その結果、認証方式はハンドラーを選択するラベルになります。ASP.NET Core における認証の詳細については、2017 年 9 月号のコラム「Cutting Edge - ASP.NET Core における Cookie、クレーム、および認証」(msdn.com/magazine/mt842501、英語) を参照することをお勧めします。

承認フィルター

Authorize 属性により提供される情報は、システム提供の承認フィルターで使用されます。承認フィルターは、要求された操作をユーザーが実行できるかどうかをチェックします。そのため、このフィルターは他の ASP.NET Core フィルターよりも先に実行されます。ユーザーが承認されない場合、承認フィルターはパイプラインを実行せずに要求をキャンセルします。

カスタム承認フィルターを作成できますが、たいていの場合、この作業は必要はありません。実際には、既定のフィルターによって利用されている既存の承認レイヤーを構成することをお勧めします。

役割、アクセス許可、却下

役割は、ユーザーが実行できること、できないこと基づいて、アプリケーションのユーザーをグループ分けする簡単な方法です。ですが、役割にはあまり高い表現力はありません。少なくとも、大半の最新アプリケーションのニーズを満たせるほどの表現力は備わっていません。例として、比較的シンプルな承認アーキテクチャを考えてみましょう。このアーキテクチャは、Web サイトの標準ユーザーと、バック オフィス ソフトウェアにアクセスしてコンテンツを更新する権限を持つパワー ユーザーに対応するものとします。役割ベースの承認レイヤーは、User と Admin という、2 つの役割を中心に構築でき、各グループがアクセスできるコントローラーとメソッドを定義します。

特定の役割内でユーザーができること、できないことを示す却下に関する微妙な差異について、問題が発生しました。たとえば、バック オフィス システムにアクセスできるユーザーがいます。しかし、その中には、顧客データの編集のみ承認されているユーザーもいれば、コンテンツを操作することのみ承認されているユーザーもいます。また、顧客データの編集とコンテンツの操作の両方を承認されているユーザーも存在します (図 1 参照)。

役割の階層

図 1 役割の階層

役割は基本的にはフラットな概念です。図 1 に示すようなシンプルな階層はどうすればフラットになるでしょうか。User、Admin、CustomerAdmin、および ContentsAdmin という 4 つの異なる役割を作成することはできるでしょう。しかし、却下の数が増加するとすぐに、必要な役割も大幅に増えることになります。旧バージョンとの互換性が優先事項の 1 つになっている単純なシナリオや事例は例外として、このような単純な演習でさえ、承認を処理するうえで、役割が最も効果的な方法ではない可能性が示されています。あらゆることに、異なる要件があります。そこで考えられたのが、ポリシーベースの承認です。

ポリシーとは

ASP.NET Core では、承認とアプリケーション ロジックを分離するために、ポリシーベースの承認フレームワークが設計されています。簡単に言うと、ポリシーは、要件のコレクションとして考案されたエンティティです。要件自体は、現在のユーザーが満たす必要のある条件です。

ユーザーが認証済みであるというのが最も単純なポリシーです。ユーザーが特定の役割に関連付けられているポリシーもよく使われます。ユーザーに特定の要求がある、または特定の値を含む特定の要求があるというポリシーも使われます。わかりやすい言葉で説明すると、要件とは、当該メソッドへのアクセスを試みるユーザー ID に関するアサーションです。次のコードを使用してポリシー オブジェクトを作成します。

var policy = new AuthorizationPolicyBuilder()
  .AddAuthenticationSchemes("Cookie, Bearer")
  .RequireAuthenticatedUser()
  .RequireRole("Admin")
  .RequireClaim("editor", "contents")  .RequireClaim("level", "senior")
  .Build();

AuthorizationPolicyBuilder オブジェクトは、さまざまな拡張メソッドを使用して要件を収集した後に、ポリシー インスタンスを構築します。ご覧のとおり、要件は認証状態に基づいて機能し、認証方式、役割、および要求の組み合わせにより、認証 Cookie またはベアラー トークンが読み取られます。

定義済みの拡張メソッドの中に役立つ要件を定義するものがない場合は、いつでも独自のアサーションを使用して新しい要件を定義できます。次のようになります。

var policy = new AuthorizationPolicyBuilder()
  .AddAuthenticationSchemes("Cookie, Bearer")
  .RequireAuthenticatedUser()
  .RequireRole("Admin")
  .RequireAssertion(ctx =>
  {
    return ctx.User.HasClaim("editor", "contents") ||
           ctx.User.HasClaim("level", "senior");
  })
  .Build();

RequireAssertion メソッドは、HttpContext オブジェクトを受け取ってブール値を返すラムダを受け取ります。したがって、RequireAssertion は単なる条件ステートメントです。RequireRole を複数回連結する場合は、すべての役割がユーザーから渡される必要があることに注意してください。そうでなく、OR 条件で表現することを考えている場合も、アサーションを利用できます。実際、この例のポリシーでは、コンテンツの編集者か、シニア ユーザーが許可されます。

ポリシーの登録

ポリシーを定義するだけでは不十分です。承認ミドルウェアを使用してポリシーを登録することも必要です。そのためには、次のように、Startup クラスの ConfigureServices メソッドで承認ミドルウェアをサービスとして追加します。

services.AddAuthorization(options=>
{
  options.AddPolicy("ContentsEditor", policy =>
  {
    policy.AddAuthenticationSchemes("Cookie, Bearer");
    policy.RequireAuthenticatedUser();
    policy.RequireRole("Admin");
    policy.RequireClaim("editor", "contents");
  });
}

このミドルウェアに追加される各ポリシーには名前があります。この名前は、コントローラー クラスの Authorize 属性内でポリシーを参照するために使用されます。

[Authorize(Policy = "ContentsEditor")]
public IActionResult Save(Article article)
{
  // ...
}

Authorize 属性では、宣言によりポリシーを設定できます。ただし、ポリシーは、アクション メソッドを使って直接プログラムから呼び出すこともできます (図 2 参照)。

図 2 プログラムによるポリシーのチェック

public class AdminController : Controller
{
  private IAuthorizationService _authorization;
  public AdminController(IAuthorizationService authorizationService)
  {
    _authorization = authorizationService;
  }

  public async Task<IActionResult> Save(Article article)
  {    var allowed = await _authorization.AuthorizeAsync(      User, "ContentsEditor"));
    if (!allowed)
      return new ForbiddenResult();
    
    // Proceed with the method implementation 
    ...
  }
}

プログラムによるアクセス許可のチェックが失敗した場合は、ForbiddenResult オブジェクトを返すことができます。もう 1 つのオプションは、ChallengeResult を返すことです。ASP.NET Core1.x では、チャレンジが返されると、401 状態コードを返すか、ユーザーをログイン ページにリダイレクトするか、構成に応じてそのいずれかを承認ミドルウェアに指示します。ただし、ASP.NET Core 2.0 ではリダイレクトは行われません。ASP.NET Core1.x でも、ユーザーが既にログインしている場合は、チャレンジが発生すると最終的に ForbiddenResult が返されます。結局、最善のアプローチは、アクセス許可のチェックに失敗したら ForbiddenResult を返すことです。

Razor ビューの内部でプログラムからポリシーをチェックすることもできます。これを次のコードに示します。

@{ 
  var authorized = await Authorization.AuthorizeAsync(
    User, "ContentsEditor"))}
@if (!authorized)
{
  <div class="alert alert-error">
    You’re not authorized to access this page.
  </div>
}

ただし、このコードが動作するには、次のように、まず承認サービスに依存関係を挿入する必要があります。

@inject IAuthorizationService Authorization

ビューで承認サービスを使用すると、現在のコンテキストを考慮した場合に、現在のユーザーが操作できる範囲内にあってはならない UI 要素を非表示にできます。ただし、ビューから単純にオプションを非表示にするだけでは不十分であることを忘れないでください。必ず、コントローラーでもポリシーを強制する必要があります。

カスタム要件

組み込みの要件でも、基本的には、要求と認証がカバーされ、アサーションをベースにカスタマイズを実現する汎用メカニズムが提供されます。ただし、カスタム要件を作成することもできます。ポリシーの要件は 2 つの要素から構成されます。データのみを保持する要件クラスと、ユーザーに対するデータの検証を行う承認ハンドラーです。カスタム要件を使用すると、特別なポリシーを幅広く表現できるようになります。たとえば、ユーザーに 3 年以上の経験が必要だとする要件を追加することで、ContentsEditor ポリシーを拡張するとしましょう。これは次のようになります。

public class ExperienceRequirement : IAuthorizationRequirement
{
  public int Years { get; private set; }

  public ExperienceRequirement(int minimumYears)
  {
    Years = minimumYears;
  }
}

要件には承認ハンドラーが 1 つ以上必要です。ハンドラーは AuthorizationHandler<T> 型になります。このとき、"T" は要件の型を表します。図 3 に、ExperienceRequirement 型のサンプル ハンドラーを示しています。

図 3 サンプルの承認ハンドラー

public class ExperienceHandler : 
             AuthorizationHandler<ExperienceRequirement>
{
  protected override Task HandleRequirementAsync( 
    AuthorizationHandlerContext context, 
    ExperienceRequirement requirement)
  {
    // Save User object to access claims
    var user = context.User;    if (!user.HasClaim(c => c.Type == "EditorSince"))      return Task.CompletedTask;

    var since = user.FindFirst("EditorSince").Value.ToInt();
    if (since >= requirement.Years)
      context.Succeed(requirement);

    return Task.CompletedTask;
  }
}

このサンプルの承認ハンドラーは、ユーザーに関連付けられた要求を読み取り、EditorSince カスタム要求を確認します。見つからない場合、ハンドラーは成功を含めずに返します。成功が返されるのは、カスタム要求が存在していて、指定した年数以上の整数値が格納されている場合のみです。

カスタム要求は、Users テーブルの列など、なんらかの方法でユーザーにリンクされ、なおかつ認証 Cookie に保存される 1 つの情報であると想定されます。ただし、一度ユーザーへの参照を保持したら、いつでも、要求からユーザー名を見つけて、データベースまたは外部サービスに対してクエリを実行し、経験年数を取得してその情報をハンドラーで使用することができます (EditorSince 値に DateTime を保持し、ユーザーが Editor として開始してから特定の年数が経過してるかどうかを計算すれば、この例がもう少し現実的なものになるでしょう)。

承認ハンドラーはメソッド Succeed を呼び出し、現在の要件を渡して、要件が正常に検証されたことを通知します。要件に合格しなかった場合でも、承認ハンドラーでは何も実行する必要がなく、単純に戻ることができます。ただし、同じ要件を扱う他のハンドラーで成功しているかどうかに関係なく、承認ハンドラーで要件の失敗を判断する必要がある場合は、承認コンテキスト オブジェクトでメソッド Fail を呼び出します。

以下に、カスタム要件をポリシーに追加する方法を示します (これはカスタム要件であるため、拡張メソッドがないことに注意してください。それどころか、ポリシー オブジェクトの要件のコレクションを処理しなければなりません)。

services.AddAuthorization(options =>
{
  options.AddPolicy("AtLeast3Years",
    policy => policy
      .Requirements
      .Add(new ExperienceRequirement(3)));
});

また、依存関係の挿入 (DI) システムを使用して、IAuthorizationHandler 型のスコープ内に新しいハンドラーを登録する必要があります。

services.AddSingleton<IAuthorizationHandler, ExperienceHandler>();

前述のように、1 つの要件で複数のハンドラーを使用することができます。承認レイヤーの同じ要件に対し、DI システムを使用して複数のハンドラーが登録されている場合は、1 つ以上成功すれば十分です。

現在の HTTP コンテキストへのアクセス

承認ハンドラーの実装では、次のように、要件のプロパティまたはルート データを検査することが必要になる場合があります。

if (context.Resource is AuthorizationFilterContext mvc)
{
  var url = mvc.HttpContext.Request.GetDisplayUrl();  ...
}

ASP.NET Core の AuthorizationHandlerContext オブジェクトは、フィルター コンテキスト オブジェクトに設定された Resource プロパティを公開します。コンテキスト オブジェクトは、関連するフレームワークによって異なります。たとえば、MVC と SignalR はそれぞれの固有のオブジェクトを送信します。キャストするかどうかは、アクセスする必要のある対象に応じて異なります。たとえば、ユーザー情報はいつでも使用できるため、そのためにキャストする必要はありません。しかし、ルーティング情報など、MVC 固有の詳細が必要な場合は、キャストしなければなりません。

まとめ

ASP.NET Core の承認には 2 つの選択肢があります。1 つ目は、従来の役割ベースの承認です。これは、従来の ASP.NET MVC と同じように機能し、まだ、ややフラットで、洗練された承認ロジックを表現するのに理想的ではないという構造的な制約があります。ポリシーベースの認証は、より表現力の高い充実したモデルを提供する新しいアプローチです。その理由は、ポリシーが、HTTP コンテキストや外部のソースから挿入できる他の情報に基づいたカスタム ロジックと要求をベースにした、要件のコレクションであるためです。これらの要件はそれぞれ 1 つ以上のハンドラーに関連付けられ、要件の実際の評価はハンドラーが担当します。


Dino Esposito は、『Microsoft .NET: Architecting Applications for the Enterprise』(Microsoft Press、2014 年) および『Modern Web Applications』(Microsoft Press、2016 年) の著者です。JetBrains の .NET および Android プラットフォームのテクニカル エバンジェリストでもあります。世界各国で開催される業界のイベントで頻繁に講演しており、software2cents@wordpress.com (英語) や Twitter (@despos、英語) でソフトウェアに関するビジョンを紹介しています。

この記事のレビューに協力してくれた技術スタッフの Barry Dorrans (マイクロソフト) および Steve Smith に心より感謝いたします。


この記事について MSDN マガジン フォーラムで議論する