ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

ASP.NET Core アプリケーションを Azure Active Directory v2.0 で認証する

Azure Active Directory(Azure AD)の v2.0 は Microsoft ID プラットフォームと呼ばれていて、OpenID Connect 準拠のエンドポイントが提供されていたり、アクセス許可を一度にまとめて確認するでのはなく、必要に応じてアクセス許可を確認(増分および動的な同意)できたりします。
ただし、Azure AD v1.0 のすべての機能が v2.0 で提供されているわけではないので、注意が必要です。
docs.microsoft.com
今回は、ASP.NET Core で作った Web アプリケーションと Web API に Azure AD v2.0 認証を組み込み、フロントエンドの Web アプリケーションからバックエンドの Web API を呼び出してみます。
基本的にはこちらのサンプル通りですが、必要最低限のコードで試してみました。
github.com

事前準備

Visual Studio 2019 のプロジェクトテンプレートを使って、ASP.NET Core 3.1 で Web アプリケーション(MVC)と Web API のプロジェクトを作成しておきます。
テンプレートで認証を設定すると、Azure AD v1.0 向けの Active Directory Authentication Library(ADAL)が使われるので、今回は指定しません。
Azure AD v2.0 向けの Microsoft Authentication Library(MSAL)を手動で組み込みます。現時点のバージョンは 0.3.1-preview となっており、プレビュー版です。
docs.microsoft.com

Azure AD にアプリケーションを登録する

Azure Portal で Azure AD のアプリケーションを2つ登録します。

  • フロントエンドの Web アプリケーション
  • バックエンドの Web API
必須項目 設定値
Redirect URIs https://localhost:xxxxx/signin-oidc(Web アプリと Web API の URL)
Implicit grant ID tokens にチェックをいれる
Client secrets 自動生成(後から確認できないのでメモしておく)

バックエンドの Web API のみ、Expose an API メニューから Application ID URIScopes を登録します。
f:id:TonyTonyKun:20200920165840p:plain

フロントエンドの Web アプリケーションを作る

Web アプリケーションのプロジェクトに対して、MSAL の NuGet パッケージをインストールします。

PM>Install-Package Microsoft.Identity.Web.UI -Version 0.3.1-preview

Azure AD のアプリケーションに登録した情報をもとに、appsettings.json に AzureAd のセクションを追加します。

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[テナントのドメインを入力します。e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Directory (Tenant) ID を入力します。]",
    "ClientId": "[Application (Client) ID を入力します。]",
    "CallbackPath": "/signin-oidc",
    "ClientSecret": "[Client secrets を入力します。]"
  },
}

Startup クラスにおいて、MSAL の初期化処理を実装します。

public void ConfigureServices(IServiceCollection services)
{
    // appsettings.json の AzureAd セクションから構成を読み取り、認証サービスを構成します。
    services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAd")
            .EnableTokenAcquisitionToCallDownstreamApi()
            .AddInMemoryTokenCaches();

    services.AddHttpClient();

    services.AddControllersWithViews()
                .AddMicrosoftIdentityUI();    // アカウント管理ページを追加
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // (ここまでのコードは省略)

    app.UseRouting();
    app.UseAuthentication();    // パイプラインに認証ミドルウェアを追加
    app.UseAuthorization();

    // (これ以降のコードは省略)
}

HomeController クラスには、Authorize 属性を指定します。

[Authorize]
public class HomeController : Controller
{
}

ログインしたユーザー名とサインアウトをヘッダーに追加しておきます。Views/Sharedディレクトリに _LoginPartial.cshtml を追加し、_Layout.cshtml に組み込みます。
asp-area には、MicrosoftIdentity を指定します。

@using System.Security.Principal

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
  <li class="nav-item">
      <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
  </li>
  <li class="nav-item">
      <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignOut">Sign out</a>
  </li>
}
else
{
  <li class="nav-item">
      <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in</a>
  </li>
}
</ul>

以上で、フロントエンドの Web アプリケーションは実装完了です。Visual Studio からデバッグ実行して、動作確認してみます。

f:id:TonyTonyKun:20200920173849p:plain

バックエンドの Web API を作る

Web API のプロジェクトに対して、MSAL の NuGet パッケージをインストールします。

PM>Install-Package Microsoft.Identity.Web -Version 0.3.1-preview

Azure AD のアプリケーションに登録した情報をもとに、appsettings.json に AzureAd のセクションを追加します。
Web アプリケーションと同様ですが、API なので CallbackPath は必要ありません。

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[テナントのドメインを入力します。e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Directory (Tenant) ID を入力します。]",
    "ClientId": "[Application (Client) ID を入力します。]",
    "ClientSecret": "[Client secrets を入力します。]"
  },
}

Startup クラスにおいて、MSAL の初期化処理を実装します。

public void ConfigureServices(IServiceCollection services)
{
    // appsettings.json の AzureAd セクションから構成を読み取り、認証サービスを構成します。
    services.AddMicrosoftIdentityWebApiAuthentication(Configuration, "AzureAd");

    services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // (ここまでのコードは省略)

    app.UseRouting();
    app.UseAuthentication();    // パイプラインに認証ミドルウェアを追加
    app.UseAuthorization();

    // (これ以降のコードは省略)
}

WeatherForecastController クラスには、Authorize 属性を指定します。

[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
}

以上で、バックエンドの Web API は実装完了です。Visual Studio からデバッグ実行して、Postman で動作確認してみます。

f:id:TonyTonyKun:20200920174759p:plain

Postmanを使ったアクセストークンの取得は、こちらの記事を参照してください。
gooner.hateblo.jp

フロントエンドからバックエンドの Web API を呼び出す

Azure AD のアプリケーションを変更する

フロントエンドの Web アプリケーションに対して、バックエンドの Web API を呼び出せるアクセス権を追加します。

f:id:TonyTonyKun:20200921115951p:plain

API のアクセス権は、add a permission をクリックして選択できます。忘れずに、Grant admin consent for 既定のディレクトリも設定しておきます。

フロントエンドの Web アプリケーションを変更する

Web アプリケーションのプロジェクトに対して、appsettings.json に CallApi のセクションを追加します。

{
  "CallApi": {
    "Scope": "[Web API の Scope を入力します。e.g. api://<GUID>/user_impersonation]",
    "BaseAddress": "[Web API の URL を入力します。e.g. https://localhost:xxxxx]"
  }
}

Startup クラスにおいて、MSAL の初期化処理にバックエンドの Web API の Scope を追加します。EnableTokenAcquisitionToCallDownstreamApi メソッドの引数に Scope を渡します。

public void ConfigureServices(IServiceCollection services)
{
    // バックエンドの Web API の Scope を追加
    services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
            .EnableTokenAcquisitionToCallDownstreamApi(new string [] { Configuration["CallApi:Scope"], })
            .AddInMemoryTokenCaches();
}

バックエンドの Web API を呼び出すアクセストークンを取得したいので、HomeController クラスのコンストラクタで ITokenAcquisition のインスタンスを DI コンテナから受け取ります。

[Authorize]
public class HomeController : Controller
{
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IHttpClientFactory _clientFactory;
    private readonly string _webApiScope = string.Empty;
    private readonly string _webApiBaseAddress = string.Empty;

    public HomeController(ITokenAcquisition tokenAcquisition, IConfiguration configuration, IHttpClientFactory clientFactory)
    {
        _tokenAcquisition = tokenAcquisition;
        _clientFactory = clientFactory;
        _webApiScope = configuration["CallApi:Scope"];
        _webApiBaseAddress = configuration["CallApi:BaseAddress"];
    }
}

Privacy ページがクリックされた際に、バックエンドの Web API を呼び出します。アクションメソッドには、AuthorizeForScopes 属性でバックエンドの Web API の Scope を付与します。

[AuthorizeForScopes(ScopeKeySection = "CallApi:Scope")]
public async Task<IActionResult> Privacy()
{
    // アクセストークンを取得する
    var scopes = new string[] { _webApiScope };
    var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);

    // バックエンドの Web API を呼び出し
    var client = _clientFactory.CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    var response = await client.GetAsync($"{_webApiBaseAddress}/weatherforecast");
    if (response.StatusCode == HttpStatusCode.OK)
    {
        ViewData["ApiResult"] = await response.Content.ReadAsStringAsync();
    }
    else
    {
        ViewData["ApiResult"] = $"Invalid status code in the HttpResponseMessage: {response.StatusCode}.";
    }
    return View();
}

実行結果

Azure AD で認証されたフロントエンドの Web アプリケーションから、バックエンドの API を呼び出せることを確認します。

f:id:TonyTonyKun:20200921123219p:plain

まとめ

ASP.NET Core で作った Web アプリケーションと Web API に Azure AD v2.0 認証を組み込み、フロントエンドの Web アプリケーションからバックエンドの Web API を呼び出してみました。
認証ライブラリの MSAL はまだプレビュー版ですし、もうすぐ .NET 5 がリリースされるタイミングなので、継続してキャッチアップしていきたいと思います。
今回のソースコードは、こちらで公開しています。
github.com

Postman で Azure Active Directory 認証された API のアクセストークンを取得する

Postman を使って、Azure Active Directory 認証された API のアクセストークンを取得する方法のメモです。

Azure Active Directory にアプリケーションを登録する

Azure Portal で Postman 用の Azure AD アプリケーションを登録します。

必須項目 設定値
Redirect URIs https://www.getpostman.com/oauth2/callback
Client secrets 自動生成(後から確認できないのでメモしておく)
API permissions Postman からアクセスしたい API

API Permission は、add a permission をクリックして選択できます。忘れずに、Grant admin consent for 既定のディレクトリも設定しておきます。
右側に表示される API Permission Name は、Postman 側の設定で必要なのでメモしておきます。

f:id:TonyTonyKun:20200912162207p:plain

アプリケーションを登録できたら、Postman 側の設定で必要な情報をメモします。
まずは、Application (client) ID です。

f:id:TonyTonyKun:20200912155813p:plain

次に、Endpoints をクリックして表示される2つのエンドポイントです。

  • OAuth 2.0 authorization endpoint (v2)
  • OAuth 2.0 token endpoint (v2)

f:id:TonyTonyKun:20200912160008p:plain

Postman でアクセストークンを取得する

Postman では、Authorization タブから OAuth 2.0 を選択して、Get New Access Token をクリックします。

f:id:TonyTonyKun:20200912160750p:plain

上記の画面で、それぞれの項目を設定して、Request Token ボタンを押します。

項目 設定値
Token Names 任意の名前
Auth URL OAuth 2.0 authorization endpoint (v2)
Access Token URL OAuth 2.0 token endpoint (v2)
Client ID Application (client) ID
Client Secret Client Secrets
Scope API Permission Name
Grant Type Authorization Code

取得したアクセストークンを Authorization ヘッダーにセットすれば、Azure Active Directory 認証された API にアクセスできます。

f:id:TonyTonyKun:20200912161537p:plain

Azure App Service の Easy Auth を有効にした Web Apps から Functions にアクセスする

Azure App Service では、組み込みの認証機能が提供されています。この機能は Easy Auth と呼ばれていて、アプリケーション側に最低限のコードを実装するだけで、Azure AD などのさまざまなプロバイダーを使って保護できます。
docs.microsoft.com
今回は、Web Apps と Functions で Easy Auth を有効にして、Azure AD で認証されたフロントエンドの Web アプリケーションからバックエンドの API を呼び出します。

  • Web Apps で ClaimsPrincipal を取得する
  • Functions のアクセストークンを取得するように構成する

この2つの構成がポイントになるので、まとめておきます。

Azure App Service の Easy Auth を有効にする

Azure ポータルを使って、Web Apps と Functions の Easy Auth を有効にします。
認証プロバイダーに Azure AD を選択して設定する際に Express モードを選択すると、Azure AD のアプリケーションも同時に登録できます。

f:id:TonyTonyKun:20200912111521p:plain

バックエンドの API をデプロイする

Functions には、Visual Studio 2019 のプロジェクトテンプレートで作成した Http Trigger の Functions をデプロイします。
Azure AD 認証するので、Functions の AuthorizationLevel は Anonymous に変更しておきます。

[FunctionName("Function1")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
    ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");

    string name = req.Query["name"];
    string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    dynamic data = JsonConvert.DeserializeObject(requestBody);
    name = name ?? data?.name;

    // ClaimsPrincipal のユーザー名を取得
    var identityName = req.HttpContext.User.Identity.Name;

    var responseMessage = string.IsNullOrEmpty(name)
        ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
        : $"Hello, {name}. IdentityName is {identityName}.";
    return new OkObjectResult(responseMessage);
}

Functions の場合、ClaimsPrincipal を自動的に HttpContext.User に格納してくれますので、ユーザー名などを取得できます。

フロントエンドの Web アプリケーションをデプロイする

Web Apps には、Visual Studio 2019 のプロジェクトテンプレートで作成した ASP.NET Core 3.1 の MVC をデプロイします。
Easy Auth が設定してくれる HTTP ヘッダーの X-MS-TOKEN-AAD-ACCESS-TOKEN にアクセストークンが格納されています。
docs.microsoft.com
このアクセストークンを使って、バックエンドの API を呼び出すコードを実装しておきます。

public async Task<IActionResult> Privacy()
{
    var client = _clientFactory.CreateClient();
    if (HttpContext.Request.Headers.ContainsKey("X-MS-TOKEN-AAD-ACCESS-TOKEN"))
    {
        var accessToken = HttpContext.Request.Headers["X-MS-TOKEN-AAD-ACCESS-TOKEN"];
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    }
    var response = await client.GetAsync($"https://gooner-api.azurewebsites.net/api/Function1?name=gooner");
    if (response.StatusCode == HttpStatusCode.OK)
    {
        ViewData["ApiResult"] = await response.Content.ReadAsStringAsync();
    }
    else
    {
        ViewData["ApiResult"] = $"Invalid status code in the HttpResponseMessage: {response.StatusCode}.";
    }
    return View();
}

ログインしたユーザー名とサインアウトをヘッダーに追加しておきます。Views/Sharedディレクトリに _LoginPartial.cshtml を追加し、_Layout.cshtml に組み込みます。
Easy Auth では、サインアウトのエンドポイントに /.auth/logout を使います。

@using System.Security.Principal

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
    <li class="nav-item">
        <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" href="/.auth/logout">Sign out</a>
    </li>
}
</ul>

Web Apps の場合、ClaimsPrincipal が自動的に HttpContext.User に格納されません。従来の ASP.NET の場合は、ClaimsPrincipal が自動的にバインドされていました。
ASP.NET Core の場合、アプリケーション側での対応が必要となります。

Web Apps で ClaimsPrincipal を取得する

前述の通り、ASP.NET Core では ClaimsPrincipal を取得するために、アプリケーション側での対応が必要となります。
docs.microsoft.com
HTTP ヘッダー(X-MS-CLIENT-PRINCIPAL-NAMEなど)を解析するか、/.auth/me エンドポイントから取得するか、どちらかの方法になります。
これらの機能を実装する ASP.NET Core 向けの認証ミドルウェアは公式には提供されていませんが、一時的な回避方法として MaximeRouiller.Azure.AppService.EasyAuth が提供されているので、今回はこちらを使います。
github.com

認証ミドルウェアの NuGet パッケージをインストールします。

PM>Install-Package MaximeRouiller.Azure.AppService.EasyAuth -Version 1.0.0-ci141

Startup クラスで、ミドルウェアパイプラインに Easy Auth の ClaimsPrincipal を取得する認証ミドルウェアを追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpClient();

    // Easy Auth の ClaimsPrincipal を取得する認証ミドルウェア
    services.AddAuthentication().AddEasyAuthAuthentication((configure) => {});

    services.AddControllersWithViews();
}

Functions のアクセストークンを取得するように構成する

HTTP ヘッダーの X-MS-TOKEN-AAD-ACCESS-TOKEN にアクセストークンが格納されていますが、このままでは Functions の認証が通りません。
docs.microsoft.com
フロントエンドの Web アプリケーションを認証する際に、バックエンドの API を呼び出すトークンも含めるように構成する必要があります。
Azure ポータルから構成できないので、Azure Resource Explorer を使います。
Azure Resource Explorer から Web Apps の authsettings で、additionalLoginParams を構成します。

"additionalLoginParams": [
  "response_type=code id_token",
  "resource=<Azure AD に登録された Functions の Application (client) ID>"
],

実行結果

Azure AD で認証されたフロントエンドの Web アプリケーションから、バックエンドの API を呼び出せることを確認します。

f:id:TonyTonyKun:20200920113903p:plain

Web Apps と Functions で ClaimsPrincipal を取得できました。

まとめ

Web Apps と Functions で Easy Auth を有効にして、Azure AD で認証されたフロントエンドの Web アプリケーションからバックエンドの API を呼び出しました。
アプリケーション側で認証機能を実装せずとも、要件を満たすのであれば、Easy Auth をうまく活用する選択肢もありだと思います。