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

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 をうまく活用する選択肢もありだと思います。

Azure DevOps の Multi-Stage Pipelines で Variable groups を使ってパイプラインを構築する

先日の記事で、Azure DevOps の Multi-Stage Pipelines を使って Azure Web Apps にデプロイする内容を記載しました。
gooner.hateblo.jp
YAML をソースコードのリポジトリで管理する想定なので、環境毎に異なる情報やパスワードなどのシークレットな情報をリポジトリに含めたくありません。そこで、Azure DevOps の Variable groups を使って、パイプラインを書き換えてみました。

パイプラインの全体設計

先日の記事と同様に、パイプラインは、自動デプロイされる開発環境向け(Commit Stage)と承認デプロイされる本番環境向け(Production Stage)を作りました。
YAML を Build と Release に分けて、可変部をパラメータで切り替えできるテンプレートを作ることで再利用性を向上させます。

f:id:TonyTonyKun:20191220105519p:plain

Variable groups とは

Variable groups は、Azure DevOps 内に定義した Key-Value のデータをグループ化し、YAML パイプラインに渡すことができる機能です。
docs.microsoft.com
Azure DevOps の Library メニューから Variable groups を登録します。このユースケースでは、ブランチ毎にデプロイ先の Azure Web Apps が異なるので、Commit Stage と Production Stage でグループを分けて、webAppsName を登録します。
variable-group-commitvariable-group-production の2つの Variable groups を登録しました。

f:id:TonyTonyKun:20200127140616p:plain

今回は利用しませんが、パスワードなどのシークレットな情報は、Value の右横の鍵マークをクリックすると、値をマスクすることができ、登録した人以外には非公開にすることも可能です。また、Azure Key Vault で値を管理することもできます。

パイプライン

変更する YAML は、パイプライン起動用の azure-pipelines.yml です。

trigger:
- master

variables:
- group: variable-group-commit
- name: imageName
  value: 'windows-2019'
- name: buildConfiguration
  value: 'Release'
- name: projects
  value: '**/WebApplication.csproj'
- name: testProjects
  value: '**/WebApplication.Tests.csproj'
- name: azureSubscription
  value: 'AzureSponsorships'
- name: webAppsType
  value: 'webApp'
- name: environment
  value: 'Commit-Stage'

stages:
- stage: Build
  jobs:
  - template: pipelines/build-pipelines.yml
    parameters:
      imageName: $(imageName)
      buildConfiguration: $(buildConfiguration)
      projects: $(projects)
      testProjects: $(testProjects)
    
- stage: Release
  dependsOn:
  - Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
  jobs:
  - template: pipelines/release-pipelines.yml
    parameters:
      imageName: $(imageName)
      azureSubscription: $(azureSubscription)
      webAppsName: $(webAppsName)
      webAppsType: $(webAppsType)
      environment: $(environment)

variables の webAppsName を削除する代わりに、group: variable-group-commit を追加することで、Variable groups から値を受け取ることができます。
Variable groups と YAML に定義する variables の両方を使いたいので、既存の variables を name-value 形式で書き換えています。
同様の手順で、本番環境向けパイプラインの azure-pipelines-production.yml も変更すれば、完了です。

まとめ

YAML をソースコードのリポジトリで管理する場合、環境毎に異なる情報やパスワードなどのシークレットな情報をリポジトリに含めたくありません。
Azure DevOps の Variable groups を使うことで、YAML パイプラインに値を渡すことができるので、実プロジェクトに適用する際にはうまく活用したい機能です。
こちらから、パイプライン YAML の全体を確認できます。
github.com