演習 - Microsoft 認証ライブラリを ASP.NET MVC Web アプリに組み込む

完了

この演習では、ひとつ前の演習のアプリケーションを拡張し、Azure AD を使用して認証をサポートします。 これは、Microsoft Graph API を呼び出すのに必要な OAuth アクセス トークンを取得するために必要です。 この手順では、OWIN ミドルウェアと Microsoft Authentication Library のライブラリをアプリケーションに統合します。

ソリューション エクスプローラーgraph-tutorial プロジェクトを右クリックし、[新しい項目の追加>]を選択します。...

[Web 構成ファイル] を選択し、ファイルの名前を PrivateSettings.config に指定し、[追加] を選択します。

すべてのコンテンツを次のコードに置き換えます。

<appSettings>
    <add key="ida:AppID" value="YOUR APP ID" />
    <add key="ida:AppSecret" value="YOUR APP PASSWORD" />
    <add key="ida:RedirectUri" value="https://localhost:PORT/" />
    <add key="ida:AppScopes" value="User.Read Calendars.Read" />
</appSettings>

YOUR_APP_ID_HERE を Azure AD 管理センターのアプリケーション ID に置き換え、YOUR_APP_PASSWORD_HERE を生成済みのクライアント シークレットに置き換えます。 クライアント シークレットにアンパサンド (&) が含まれている場合は、PrivateSettings.config&amp; に置き換えてください。 また、ida:RedirectUriPORT 値をアプリケーションの URL に合わせて変更してください。

重要

Git などのソース管理を使用している場合は、ソース管理から PrivateSettings.config ファイルを除外して、アプリ ID とパスワードが誤って漏洩しないようにすることをお勧めします。

Web.config を更新して、この新しいファイルを読み込みます。 <appSettings> (7 行目) を以下のように置き換えます

<appSettings file="PrivateSettings.config">

サインインの実装

まず OWIN ミドルウェアを初期化して、アプリに Azure AD 認証を使用します。

ソリューション エクスプローラーでApp_Start フォルダーを右クリックし、[クラスの追加]>を選択します。ファイルに Startup.Auth.cs という名前を付け、[追加] を選択します。 すべての内容を、次のコードで置き換えます。

using Microsoft.Identity.Client;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Notifications;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
using System.Configuration;
using System.Threading.Tasks;
using System.Web;

namespace graph_tutorial
{
    public partial class Startup
    {
        // Load configuration settings from PrivateSettings.config
        private static string appId = ConfigurationManager.AppSettings["ida:AppId"];
        private static string appSecret = ConfigurationManager.AppSettings["ida:AppSecret"];
        private static string redirectUri = ConfigurationManager.AppSettings["ida:RedirectUri"];
        private static string graphScopes = ConfigurationManager.AppSettings["ida:AppScopes"];

        public void ConfigureAuth(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

            app.UseCookieAuthentication(new CookieAuthenticationOptions());

            app.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    ClientId = appId,
                    Authority = "https://login.microsoftonline.com/common/v2.0",
                    Scope = $"openid email profile offline_access {graphScopes}",
                    RedirectUri = redirectUri,
                    PostLogoutRedirectUri = redirectUri,
                    TokenValidationParameters = new TokenValidationParameters
                    {
                        // For demo purposes only, see below
                        ValidateIssuer = false

                        // In a real multi-tenant app, you would add logic to determine whether the
                        // issuer was from an authorized tenant
                        //ValidateIssuer = true,
                        //IssuerValidator = (issuer, token, tvp) =>
                        //{
                        //  if (MyCustomTenantValidation(issuer))
                        //  {
                        //    return issuer;
                        //  }
                        //  else
                        //  {
                        //    throw new SecurityTokenInvalidIssuerException("Invalid issuer");
                        //  }
                        //}
                    },
                    Notifications = new OpenIdConnectAuthenticationNotifications
                    {
                        AuthenticationFailed = OnAuthenticationFailedAsync,
                        AuthorizationCodeReceived = OnAuthorizationCodeReceivedAsync
                    }
                }
            );
        }

        private static Task OnAuthenticationFailedAsync(AuthenticationFailedNotification<OpenIdConnectMessage,
            OpenIdConnectAuthenticationOptions> notification)
        {
            notification.HandleResponse();
            string redirect = $"/Home/Error?message={notification.Exception.Message}";
            if (notification.ProtocolMessage != null && !string.IsNullOrEmpty(notification.ProtocolMessage.ErrorDescription))
            {
                redirect += $"&debug={notification.ProtocolMessage.ErrorDescription}";
            }
            notification.Response.Redirect(redirect);
            return Task.FromResult(0);
        }

        private async Task OnAuthorizationCodeReceivedAsync(AuthorizationCodeReceivedNotification notification)
        {
            var idClient = ConfidentialClientApplicationBuilder.Create(appId)
                .WithRedirectUri(redirectUri)
                .WithClientSecret(appSecret)
                .Build();

            string message;
            string debug;

            try
            {
                string[] scopes = graphScopes.Split(' ');

                var result = await idClient.AcquireTokenByAuthorizationCode(
                    scopes, notification.Code).ExecuteAsync();

                message = "Access token retrieved.";
                debug = result.AccessToken;
            }
            catch (MsalException ex)
            {
                message = "AcquireTokenByAuthorizationCodeAsync threw an exception";
                debug = ex.Message;
            }

            var queryString = $"message={message}&debug={debug}";
            if (queryString.Length > 2048)
            {
                queryString = queryString.Substring(0, 2040) + "...";
            }

            notification.HandleResponse();
            notification.Response.Redirect($"/Home/Error?{queryString}");
        }
    }
}

注:

このコードは、PrivateSettings.config からの値を使用して OWIN ミドルウェアを構成し、OnAuthenticationFailedAsyncOnAuthorizationCodeReceivedAsync の 2 つのコールバック メソッドを定義します。 これらのコールバック メソッドは、サインイン プロセスが Azure から戻るときに呼び出されます。

ここで、Startup.cs ファイルを更新し、ConfigureAuth メソッドを呼び出します。 Startup.cs の内容全体を次のコードで置き換えます。

using Microsoft.Owin;
using Owin;

[assembly: OwinStartup(typeof(graph_tutorial.Startup))]

namespace graph_tutorial
{
    public partial class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            ConfigureAuth(app);
        }
    }
}

Error アクションを HomeController クラスに追加し、message クエリ パラメーターおよび debug クエリ パラメーターを Alert オブジェクトに変換します。 Controllers/HomeController.cs を開き、次の関数を追加します。

public ActionResult Error(string message, string debug)
{
    Flash(message, debug);
    return RedirectToAction("Index");
}

コントローラーを追加してサインインを処理します。 ソリューション エクスプローラーの Controllers フォルダーを右クリックし、[コントローラーの追加>]を選択します。[MVC 5 コントローラー ] - [空] を選択し、[追加] を選択します。 コントローラーの名前をAccountController に指定し、[ 追加 ] を選択します。 ファイルのすべての内容を次のコードで置き換えます。

using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using System.Security.Claims;
using System.Web;
using System.Web.Mvc;

namespace graph_tutorial.Controllers
{
    public class AccountController : Controller
    {
        public void SignIn()
        {
            if (!Request.IsAuthenticated)
            {
                // Signal OWIN to send an authorization request to Azure
                Request.GetOwinContext().Authentication.Challenge(
                    new AuthenticationProperties { RedirectUri = "/" },
                    OpenIdConnectAuthenticationDefaults.AuthenticationType);
            }
        }

        public ActionResult SignOut()
        {
            if (Request.IsAuthenticated)
            {
                Request.GetOwinContext().Authentication.SignOut(
                    CookieAuthenticationDefaults.AuthenticationType);
            }

            return RedirectToAction("Index", "Home");
        }
    }
}

これにより、SignIn アクションと SignOut アクションが定義されます。 SignIn アクションは、要求が既に認証されているかどうかを確認します。 認証されていなければ、OWIN ミドルウェアを呼び出してユーザーを認証します。 SignOut アクションは、サインアウトする OWIN ミドルウェアを呼び出します。

変更を保存してプロジェクトを開始します。 [サインイン] ボタンを選択すると、 https://login.microsoftonline.comにリダイレクトされます。 Microsoft アカウントでログインし、要求されたアクセス許可に同意します。 ブラウザーがアプリにリダイレクトし、トークンが表示されます。

ユーザーの詳細情報を取得する

ユーザーがログインすると、Microsoft Graph からそのユーザーの情報を入手できます。

ソリューション エクスプローラーModels フォルダーを右クリックし、[クラスの追加]>を選択します。CachedUser クラスに名前を付け、[追加] を選択しますCachedUser.cs の内容全体を次のコードに置き換えます。

namespace graph_tutorial.Models
{
    // Simple class to serialize user details
    public class CachedUser
    {
        public string DisplayName { get; set; }
        public string Email { get; set; }
        public string Avatar { get; set; }
    }
}

ソリューション エクスプローラーで graph-tutorial フォルダーを右クリックし、[新しいフォルダーの追加] >を選択します。 フォルダーに「ヘルパー」という名前を付けます。

この新しいフォルダーを右クリックし、[ クラスの追加] > を選択しますGraphHelper.cs ファイルに名前を付け、[追加] を選択 します。 このファイルの内容を次のコードで置き換えます。

using graph_tutorial.Models;
using Microsoft.Graph;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace graph_tutorial.Helpers
{
    public static class GraphHelper
    {
        public static async Task<CachedUser> GetUserDetailsAsync(string accessToken)
        {
            var graphClient = new GraphServiceClient(
                new DelegateAuthenticationProvider(
                    async (requestMessage) =>
                    {
                        requestMessage.Headers.Authorization =
                            new AuthenticationHeaderValue("Bearer", accessToken);
                    }));

            var user = await graphClient.Me.Request()
                .Select(u => new {
                    u.DisplayName,
                    u.Mail,
                    u.UserPrincipalName
                })
                .GetAsync();

            return new CachedUser
            {
                Avatar = string.Empty,
                DisplayName = user.DisplayName,
                Email = string.IsNullOrEmpty(user.Mail) ?
                    user.UserPrincipalName : user.Mail
            };
        }
    }
}

これにより、GetUserDetailsAsync 関数が実装されます。これは、Microsoft Graph SDK を使用して /me エンドポイントを呼び出して結果を返します。

App_Start/Startup.Auth.csOnAuthorizationCodeReceivedAsync メソッドを、この関数を呼び出すように更新します。 次の using ステートメントをファイルの一番上に追加します。

using graph_tutorial.Helpers;

OnAuthorizationCodeReceivedAsync の既存の try ブロックを、以下のコードで置き換えます。

try
{
    string[] scopes = graphScopes.Split(' ');

    var result = await idClient.AcquireTokenByAuthorizationCode(
        scopes, notification.Code).ExecuteAsync();

    var userDetails = await GraphHelper.GetUserDetailsAsync(result.AccessToken);

    message = "User info retrieved.";
    debug = $"User: {userDetails.DisplayName}, Email: {userDetails.Email}";
}

変更を保存してアプリを起動すると、サインイン後、アクセス トークンの代わりにユーザーの名前とメール アドレスが表示されます。

トークンの格納

トークンを取得できるようになったので、トークンをアプリに格納する手順を実装します。 これはサンプル アプリなので、セッションを使用してトークンを格納します。 実際のアプリでは、データベースのような、より信頼性の高い安全なストレージ ソリューションを使用します。 このセクションでは、次のことについて説明します。

  • トークン ストア クラスを実装して、MSAL トークン キャッシュとユーザーの詳細情報をシリアル化し、ユーザー セッションに格納します。
  • 認証コードを更新して、トークン ストア クラスを使用します。
  • ベース コントローラー クラスを更新して、格納されているユーザーの詳細情報をアプリケーション内のすべてのビューに公開します。

ソリューション エクスプローラーgraph-tutorial フォルダーを右クリックし、[新しいフォルダーの追加] >を選択します。 [TokenStorage]という名前を付けます。

この新しいフォルダーを右クリックし、[ クラスの追加] > を選択しますファイルに SessionTokenStore.cs という名前を付け、[追加] を選択 します。 このファイルの内容を次のコードで置き換えます。

using graph_tutorial.Models;
using Microsoft.Identity.Client;
using Newtonsoft.Json;
using System.Security.Claims;
using System.Threading;
using System.Web;

namespace graph_tutorial.TokenStorage
{
    public class SessionTokenStore
    {
        private static readonly ReaderWriterLockSlim sessionLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);

        private HttpContext httpContext = null;
        private string tokenCacheKey = string.Empty;
        private string userCacheKey = string.Empty;

        public SessionTokenStore(ITokenCache tokenCache, HttpContext context, ClaimsPrincipal user)
        {
            httpContext = context;

            if (tokenCache != null)
            {
                tokenCache.SetBeforeAccess(BeforeAccessNotification);
                tokenCache.SetAfterAccess(AfterAccessNotification);
            }

            var userId = GetUsersUniqueId(user);
            tokenCacheKey = $"{userId}_TokenCache";
            userCacheKey = $"{userId}_UserCache";
        }

        public bool HasData()
        {
            return (httpContext.Session[tokenCacheKey] != null &&
                ((byte[])httpContext.Session[tokenCacheKey]).Length > 0);
        }

        public void Clear()
        {
            sessionLock.EnterWriteLock();

            try
            {
                httpContext.Session.Remove(tokenCacheKey);
            }
            finally
            {
                sessionLock.ExitWriteLock();
            }
        }

        private void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            sessionLock.EnterReadLock();

            try
            {
                // Load the cache from the session
                args.TokenCache.DeserializeMsalV3((byte[])httpContext.Session[tokenCacheKey]);
            }
            finally
            {
                sessionLock.ExitReadLock();
            }
        }

        private void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            if (args.HasStateChanged)
            {
                sessionLock.EnterWriteLock();

                try
                {
                    // Store the serialized cache in the session
                    httpContext.Session[tokenCacheKey] = args.TokenCache.SerializeMsalV3();
                }
                finally
                {
                    sessionLock.ExitWriteLock();
                }
            }
        }

        public void SaveUserDetails(CachedUser user)
        {

            sessionLock.EnterWriteLock();
            httpContext.Session[userCacheKey] = JsonConvert.SerializeObject(user);
            sessionLock.ExitWriteLock();
        }

        public CachedUser GetUserDetails()
        {
            sessionLock.EnterReadLock();
            var cachedUser = JsonConvert.DeserializeObject<CachedUser>((string)httpContext.Session[userCacheKey]);
            sessionLock.ExitReadLock();
            return cachedUser;
        }

        public string GetUsersUniqueId(ClaimsPrincipal user)
        {
            // Combine the user's object ID with their tenant ID

            if (user != null)
            {
                var userObjectId = user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value ??
                    user.FindFirst("oid").Value;

                var userTenantId = user.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value ??
                    user.FindFirst("tid").Value;

                if (!string.IsNullOrEmpty(userObjectId) && !string.IsNullOrEmpty(userTenantId))
                {
                    return $"{userObjectId}.{userTenantId}";
                }
            }

            return null;
        }
    }
}

次の using ステートメントを App_Start/Startup.Auth.cs ファイルの先頭に追加します。

using graph_tutorial.TokenStorage;
using System.Security.Claims;

既存の OnAuthorizationCodeReceivedAsync 関数を、以下の関数で置き換えます。

private async Task OnAuthorizationCodeReceivedAsync(AuthorizationCodeReceivedNotification notification)
{
    notification.HandleCodeRedemption();

    var idClient = ConfidentialClientApplicationBuilder.Create(appId)
        .WithRedirectUri(redirectUri)
        .WithClientSecret(appSecret)
        .Build();

    var signedInUser = new ClaimsPrincipal(notification.AuthenticationTicket.Identity);
    var tokenStore = new SessionTokenStore(idClient.UserTokenCache, HttpContext.Current, signedInUser);

    try
    {
        string[] scopes = graphScopes.Split(' ');

        var result = await idClient.AcquireTokenByAuthorizationCode(
            scopes, notification.Code).ExecuteAsync();

        var userDetails = await GraphHelper.GetUserDetailsAsync(result.AccessToken);

        tokenStore.SaveUserDetails(userDetails);
        notification.HandleCodeRedemption(null, result.IdToken);
    }
    catch (MsalException ex)
    {
        string message = "AcquireTokenByAuthorizationCodeAsync threw an exception";
        notification.HandleResponse();
        notification.Response.Redirect($"/Home/Error?message={message}&debug={ex.Message}");
    }
    catch (Microsoft.Graph.ServiceException ex)
    {
        string message = "GetUserDetailsAsync threw an exception";
        notification.HandleResponse();
        notification.Response.Redirect($"/Home/Error?message={message}&debug={ex.Message}");
    }
}

注:

この OnAuthorizationCodeReceivedAsync の新しいバージョンでは、次のように変更されます。

  • このコードでは、ConfidentialClientApplication の既定のユーザー トークン キャッシュが SessionTokenStore クラスでラップされるようになります。 MSAL ライブラリでは、トークンを格納し、必要に応じて更新するロジックを処理します。
  • このコードでは、Microsoft Graph から取得したユーザーの詳細情報を SessionTokenStore オブジェクトに渡して、セッションに格納するようになります。
  • 成功すると、コードはリダイレクトされなくなり、返されるだけです。 これにより、OWIN ミドルウェアは認証プロセスを完了できます。

サインアウトする前に、SignOut アクションを更新して、トークン ストアをクリアします。Controllers/AccountController.cs の先頭に次の using ステートメントを追加します。

using graph_tutorial.TokenStorage;

既存の SignOut 関数を、以下の関数で置き換えます。

public ActionResult SignOut()
{
    if (Request.IsAuthenticated)
    {
        var tokenStore = new SessionTokenStore(null,
            System.Web.HttpContext.Current, ClaimsPrincipal.Current);

        tokenStore.Clear();

        Request.GetOwinContext().Authentication.SignOut(
            CookieAuthenticationDefaults.AuthenticationType);
    }

    return RedirectToAction("Index", "Home");
}

Controllers/BaseController.cs ファイルを開き、ファイルの先頭に次の using ステートメントを追加します。

using graph_tutorial.TokenStorage;
using System.Security.Claims;
using System.Web;
using Microsoft.Owin.Security.Cookies;

次の関数を追加します。

protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
    if (Request.IsAuthenticated)
    {
        // Get the user's token cache
        var tokenStore = new SessionTokenStore(null,
            System.Web.HttpContext.Current, ClaimsPrincipal.Current);

        if (tokenStore.HasData())
        {
            // Add the user to the view bag
            ViewBag.User = tokenStore.GetUserDetails();
        }
        else
        {
            // The session has lost data. This happens often
            // when debugging. Log out so the user can log back in
            Request.GetOwinContext().Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
            filterContext.Result = RedirectToAction("Index", "Home");
        }
    }

    base.OnActionExecuting(filterContext);
}

サーバーを起動し、サインイン プロセスを実行します。 ホーム ページに戻ると、UI が変更されサインインしていることが表示されます。

サインイン後のホーム ページのスクリーンショット

右上隅にあるユーザー アバターをクリックして、[サインアウト] リンクにアクセスします。 [サインアウト] をクリックすると、セッションがリセットされ、ホーム ページに戻ります。

[サインアウト] リンクのドロップダウン メニューのスクリーンショット

トークンの更新

この時点で、アプリケーションにはアクセス トークンが用意されています。これは API 呼び出しの Authorization ヘッダーで送信されます。 これは、アプリが Microsoft Graph にユーザーの代わりにアクセスできるようにするトークンです。

ただし、このトークンは一時的なものです。 トークンは発行されてから 1 時間後に有効期限が切れます。 ここで、更新トークンが役に立ちます。 更新トークンを使用すると、ユーザーが再度サインインしなくても、アプリは新しいアクセス トークンを要求できます。

アプリは MSAL ライブラリを使用して TokenCache オブジェクトをシリアル化するため、トークン更新ロジックを実装する必要はありません。 ConfidentialClientApplication.AcquireTokenSilentAsync メソッドが、すべてのロジックを実行します。 まずキャッシュされたトークンをチェックし、期限切れでない場合はトークンを返します。 有効期限が切れている場合は、キャッシュされた更新トークンを使用して新しいトークンを取得します。 このメソッドは、後の演習で使用します。

要約

この演習では、ひとつ前の演習からアプリケーションを拡張し、Azure AD を使用して認証をサポートしました。 これは、Microsoft Graph API を呼び出すのに必要な OAuth アクセス トークンを取得するために必要です。 その手順では、OWIN ミドルウェアと Microsoft Authentication Library のライブラリをアプリケーションに統合しました。

自分の知識をテストする

1.

OAuth 2.0 認証コード付与フローで使用する Azure AD アプリを登録する際、作成、構成、収集に必要な要素は何でしょう。

2.

静的および動的な同意の違いは何ですか?