LoginSignup
5
4

More than 5 years have passed since last update.

Microsoft Graph を使ってみよう : Azure AD v1 エンドポイントでの認証を Web アプリケーション (MVC) と ADAL で行う

Posted at

Web アプリケーションでの認証

ユーザーは以下の図のように、ブラウザ経由で ASP.NET MVC アプリケーションに接続します。Azure AD v2 エンドポイントを利用して自分のサイトをセキュアにできます。

以下、ASP.NET Web アプリへの "Microsoft でサインイン" の追加 より抜粋。
flow

しかし Web アプリケーションが Microsoft Graph に接続してユーザーに関連する情報を取得する場合、ユーザーのアクセストークンが必要となる一方、アクセストークンを要求するのは ASP.NET MVC アプリケーションとなります。そこで ADAL ではそのための手段として、OpenId Connect によるユーザー認証と認可コードの取得、およびサービス側が、認可コードを利用してアクセストークンを取得する仕組みを用意しています。

今回は以下のアプリケーションを作ります。

  • C# MVC Web アプリケーション
  • OpenId と認可コードフローでトークン取得
  • ADAL を利用

アプリケーションの登録

OAuth 2.0 を使うために、まず初めにアプリケーションを登録します。

1. Azure ポータル にアクセスして、ログイン。ここでは組織アカウントを使用。ログイン後、Azure Active Directory を選択。

image.png

2. 「アプリの登録」を選択し、「新しいアプリケーションの登録」をクリック。

image.png

3. 名前を指定し、「アプリケーションの種類」から「Web アプリ/API」を選択。任意の「リダイレクト URI」を指定し「作成」をクリック。

image.png

4. アプリが作成されたら「アプリション ID」を確認。その後「設定」をクリック。

image.png

5. 「必要なアクセス許可」をクリックし、「追加」をクリック。

image.png

6. 「API を選択します」をクリックして、「Microsoft Graph」をクリック。「選択」ボタンをクリック。

image.png

7. 「アクセス許可を選択します」をクリックし、必要な権限を追加。ここでは Read and write user and shared calendars を選択し、最後に「完了」をクリック。

image.png

8. キーメニューをクリックし、説明に名前を、期間を選択して「保存」。値が表示されるのでコピー。この値は画面遷移後はもう表示されない。

image.png

アプリケーションの開発

1. Visual Studio で ASP.NET Web アプリケーションプロジェクトを作成。

image.png

2. MVC を選択。認証は「個別のユーザーアカウント」を選択して「OK」をクリック。

image.png

3. NuGet の管理より「Microsoft.Owin.Security.OpenIdConnect」および「Microsoft.IdentityModel.Clients.ActiveDirectory (ADAL)」を追加。

image.png

4. 作成したプロジェクトを右クリックしてプロパティを表示。Web のセクションから「プロジェクトの URL」 を確認。

image.png

5. Azure ポータル に登録したアプリケーションの設定より、「応答 URL」に上記アドレスを追加。

image.png

6. ユーザーのログインとトークン管理のヘルパークラスを作成。プロジェクトに TokenStorage フォルダを作成し、SessionTokenCache.cs ファイルを追加。

image.png

7. 中身を以下のコードと書き換え。尚コードは GitHub にある aspnet-connect-rest-sample のもの少し変更。これまでの記事でも紹介した通り、ADAL が利用する TokenCache に利用される。

TokenCache.cs
/* 
*  Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. 
*  See LICENSE in the source repository root for complete license information. 
*/

using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Web;

namespace GraphWebAppDemo.TokenStorage
{

    // Store the user's token information.
    public class SessionTokenCache
    {
        private static ReaderWriterLockSlim SessionLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
        string UserId = string.Empty;
        string CacheId = string.Empty;
        HttpContextBase httpContext = null;

        TokenCache cache = new TokenCache();

        public SessionTokenCache(string userId, HttpContextBase httpcontext)
        {
            // not object, we want the SUB
            UserId = userId;
            CacheId = UserId + "_TokenCache";
            httpContext = httpcontext;
            Load();
        }

        public TokenCache GetMsalCacheInstance()
        {
            cache.BeforeAccess = BeforeAccessNotification;
            cache.AfterAccess = AfterAccessNotification;
            Load();
            return cache;
        }

        public void SaveUserStateValue(string state)
        {
            SessionLock.EnterWriteLock();
            httpContext.Session[CacheId + "_state"] = state;
            SessionLock.ExitWriteLock();
        }
        public string ReadUserStateValue()
        {
            string state = string.Empty;
            SessionLock.EnterReadLock();
            state = (string)httpContext.Session[CacheId + "_state"];
            SessionLock.ExitReadLock();
            return state;
        }
        public void Load()
        {
            SessionLock.EnterReadLock();
            cache.Deserialize((byte[])httpContext.Session[CacheId]);
            SessionLock.ExitReadLock();
        }

        public void Persist()
        {
            SessionLock.EnterWriteLock();

            // Optimistically set HasStateChanged to false. We need to do it early to avoid losing changes made by a concurrent thread.
            cache.HasStateChanged = false;

            // Reflect changes in the persistent store
            httpContext.Session[CacheId] = cache.Serialize();
            SessionLock.ExitWriteLock();
        }

        // Triggered right before MSAL needs to access the cache.
        // Reload the cache from the persistent store in case it changed since the last access.
        void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            Load();
        }

        // Triggered right after MSAL accessed the cache.
        void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            // if the access operation resulted in a cache update
            if (cache.HasStateChanged)
            {
                Persist();
            }
        }

    }
}

8. App_Start フォルダ内 Startup.Auth.cs を以下のコードに書き換えて、OpenId Connect を利用するように変更。

Startup.Auth.cs
using GraphWebAppDemo.TokenStorage;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
using System;
using System.Configuration;
using System.IdentityModel.Claims;
using System.Threading.Tasks;
using System.Web;

namespace GraphWebAppDemo
{
    public partial class Startup
    {
        private static string clientId = ConfigurationManager.AppSettings["ClientId"];
        private static string clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
        private static string redirectUri = ConfigurationManager.AppSettings["RedirectUri"];
        private static string resourceUri = "https://graph.microsoft.com/";
        private static string authority = "https://login.windows.net/common/";

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

            app.UseCookieAuthentication(new CookieAuthenticationOptions());
            app.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    ClientId = clientId,
                    Authority = $"https://login.microsoftonline.com/common/",
                    RedirectUri = redirectUri,
                    PostLogoutRedirectUri = redirectUri,
                    Scope = OpenIdConnectScope.OpenIdProfile,
                    ResponseType = OpenIdConnectResponseType.CodeIdToken,
                    TokenValidationParameters = new TokenValidationParameters()
                    {
                        ValidateIssuer = false
                    },
                    Notifications = new OpenIdConnectAuthenticationNotifications
                    {
                        AuthorizationCodeReceived = async (context) =>
                        {
                            var code = context.Code;

                            string upn = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Upn).Value;
                            TokenCache tokenCache = new SessionTokenCache(upn,
                              context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase)
                              .GetAdalCacheInstance();

                            AuthenticationContext authContext =
                                new AuthenticationContext(authority, tokenCache);

                            AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
                                code, new Uri(redirectUri), new ClientCredential(clientId, clientSecret), resourceUri);
                        },
                        AuthenticationFailed = (context) =>
                        {
                            context.HandleResponse();
                            context.Response.Redirect("/?errormessage=" + context.Exception.Message);
                            return Task.FromResult(0);
                        }
                    }
                }
            );
        }
    }
}

9. サインアウト時に OpenId Connect のキャッシュを消せるよう、AccountController.cs の LogOff メソッドを以下のコードで書き換え。using の追加を必要に応じて追加。

AccountController.cs
//
// POST: /Account/LogOff
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult LogOff()
{
    if (Request.IsAuthenticated)
    {
        HttpContext.GetOwinContext().Authentication.SignOut(OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);
    }

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

10. またログインでも OpenId Connect を利用するよう、Login メソッドを以下に書き換え。

AccountController.cs
//
// GET: /Account/Login
[AllowAnonymous]
public ActionResult Login(string returnUrl)
{
    if (!Request.IsAuthenticated)
    {
        // Signal OWIN to send an authorization request to Azure.
        HttpContext.GetOwinContext().Authentication.Challenge(
            new AuthenticationProperties { RedirectUri = "/" },
            OpenIdConnectAuthenticationDefaults.AuthenticationType);
    }
    ViewBag.ReturnUrl = returnUrl;
    return View();
}

11. AntiForgery の対応として、Global.asax.cs を以下のコードと差し替え。

Global.asax.cs
using System.IdentityModel.Claims;
using System.Web.Helpers;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;

namespace GraphWebAppDemo
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}

12. Graph を使うサンプルとして HomeController.cs を以下に書き換え。

HomeController.cs
using GraphWebAppDemo.TokenStorage;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Newtonsoft.Json.Linq;
using System.Configuration;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Mvc;

namespace GraphWebAppDemo.Controllers
{
    public class HomeController : Controller
    {
        private static string clientId = ConfigurationManager.AppSettings["ClientId"];
        private static string clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
        private static string graphUrl = "https://graph.microsoft.com";
        private static string tenant = "common";//"graphdemo01.onmicrosoft.com";

        public ActionResult Index()
        {
            return View();
        }

        [Authorize]
        public async Task<ActionResult> About()
        {
            string upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
            TokenCache tokenCache = new SessionTokenCache(upn, HttpContext).GetAdalCacheInstance();

            AuthenticationContext authContext =
                new AuthenticationContext($"https://login.windows.net/{tenant}/", tokenCache);

            // Get an access token.
            AuthenticationResult authResult = await authContext.AcquireTokenSilentAsync(
                graphUrl, 
                new ClientCredential(clientId,clientSecret),  
                new UserIdentifier(upn, UserIdentifierType.RequiredDisplayableId));

            using (HttpClient client = new HttpClient())
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

                var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
                var me = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
                ViewBag.Message = me.Value<string>("displayName");
            }

            return View();
        }

        public ActionResult Contact()
        {
            ViewBag.Message = "Your contact page.";

            return View();
        }
    }
}

13. 各種設定値を Web.config に追加します。

Web.config
<add key="ClientId" value="e3e6b8fe-36a2-4c9e-bc5c-52c1fe7b0504" />
<add key="ClientSecret" value="X8oGRlT5eyWz9g2e....." />
<add key="RedirectUri" value="http://localhost:53154/" />

アプリケーションのテスト

1. F5 キーを押下してアプリケーションを起動。「ログイン」をクリック。

image.png

2. サインインして、要求される権限を「承認」

image.png

3. 「詳細」をクリックして Microsoft Graph より情報が取れることを確認。

image.png

コードの詳細確認

ここまでのコードを確認してみます。

OpenId Connect と ADAL の連携
MVC アプリケーションには OpenId Connect を利用してサインインしていますが、ResponseType に CodeIdToken を指定しています。これにより必要なアクセストークンを取得するための認可コードが Azure AD より返されます。

app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        ClientId = clientId,
        Authority = $"https://login.microsoftonline.com/common/",
        RedirectUri = redirectUri,
        PostLogoutRedirectUri = redirectUri,
        Scope = OpenIdConnectScope.OpenIdProfile,
        ResponseType = OpenIdConnectResponseType.CodeIdToken,
        TokenValidationParameters = new TokenValidationParameters()
        {
            ValidateIssuer = false
        },
        Notifications = new OpenIdConnectAuthenticationNotifications
        {
...

認可トークン取得成功時のコールバック関数で、ADAL の AuthenticationContext を作成し、AcquireTokenByAuthorizationCodeAsync メソッドを実行して有効なアクセストークンが取得できるか検証するとともに、TokenCache キャッシュにユーザー情報をキャッシュしています。尚、一度でも認可トークンが発行された場合、期限が切れるか、サインアウトするまで、コールバックは発生しません。

Notifications = new OpenIdConnectAuthenticationNotifications
{
    AuthorizationCodeReceived = async (context) =>
    {
        var code = context.Code;

        string upn = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Upn).Value;
        TokenCache tokenCache = new SessionTokenCache(upn,
            context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase)
            .GetAdalCacheInstance();

        AuthenticationContext authContext =
            new AuthenticationContext(authority, tokenCache);

        AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
            code, new Uri(redirectUri), new ClientCredential(clientId, clientSecret), resourceUri);
    },
...

OpenId Connect へのログインとログアウト
Owin の Authentication.Challenge を呼び出してログインを実行しています。

HttpContext.GetOwinContext().Authentication.Challenge(
    new AuthenticationProperties { RedirectUri = "/" },
    OpenIdConnectAuthenticationDefaults.AuthenticationType)

またログアウト時も Owin の Authentication.SignOut を呼び出して OpenId Connect およびクッキー認証ともにサインアウトしています。

HttpContext.GetOwinContext().Authentication.SignOut(OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);

Microsoft Graph の呼び出し
既にトークンのキャッシュが終わっているため、AuthenticationContext の AcquireTokenSilentAsync メソッドを、ユーザーを指定して実行しています。上記の OpenId Connect と認可コードの処理が失敗している場合は、必然的にこちらのコードも失敗します。

string upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
TokenCache tokenCache = new SessionTokenCache(upn, HttpContext).GetAdalCacheInstance();

AuthenticationContext authContext =
    new AuthenticationContext($"https://login.windows.net/{tenant}/", tokenCache);

// Get an access token.
AuthenticationResult authResult = await authContext.AcquireTokenSilentAsync(
    graphUrl, 
    new ClientCredential(clientId,clientSecret),  
    new UserIdentifier(upn, UserIdentifierType.RequiredDisplayableId));

ログインユーザーの表示名を取得する

現在、サインインしたにも関わらず、アプリケーションではユーザーの名前が取得できていません。しかし OpenId Connect のプロファイルには名前が含まれるため、上書きしてみましょう。

1. Startup.Auth.cs の AuthorizationCodeReceived コールバックの最後に以下のコードを追加。

Startup.Auth.cs
System.Security.Claims.ClaimsIdentity claimsId = context.AuthenticationTicket.Identity;
claimsId.RemoveClaim(claimsId.FindFirst(ClaimTypes.Name));
claimsId.AddClaim(new System.Security.Claims.Claim(
    System.Security.Claims.ClaimTypes.Name, 
    context.AuthenticationTicket.Identity.FindFirst("name").Value, 
    System.Security.Claims.ClaimValueTypes.String));

2. アプリケーションを実行してログイン。名前が表示されることを確認。

image.png

考慮事項

今回はあくまで動作確認のための開発をしましたが、本番環境では以下の考慮が必要です。

  • キャッシュの有効期限が切れることも考えて、アクセストークン取得のエラーはハンドルする
  • Web サーバーの増減や再起動を考慮して、キャッシュの場所を検討する

まとめ

今回の開発を通して、Web アプリケーションを Azure AD v1 でセキュアにすると同時に、OpenId Connect で認証時に認可コードを取得し、認可コードで一度アクセストークンを取得することで、トークンキャッシュが生成される仕組みが見えたと思います。是非試してください。

 参考

Azure ポータル
Azure AD の認証シナリオ
GitHub:active-directory-webapp-webapi-multitenant-openidconnect-aspnetcore

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4