演習 - Microsoft 認証ライブラリを ASP.NET MVC Web アプリに組み込む
この演習では、前の演習のアプリケーションを拡張して、Microsoft Entra ID による認証をサポートします。 これは、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>
を Microsoft Entra 管理センターのアプリケーション ID に置き換 YOUR_APP_ID_HERE
え、を生成したクライアント シークレットに置き換えます YOUR_APP_PASSWORD_HERE
。 クライアント シークレットにアンパサンド (&
) が含まれている場合は、PrivateSettings.config
で &
に置き換えてください。 また、ida:RedirectUri
の PORT
値をアプリケーションの URL に合わせて変更してください。
重要
Git などのソース管理を使用している場合は、ソース管理から PrivateSettings.config ファイルを除外して、アプリ ID とパスワードが誤って漏洩しないようにすることをお勧めします。
Web.config を更新して、この新しいファイルを読み込みます。
<appSettings>
(7 行目) を以下のように置き換えます
<appSettings file="PrivateSettings.config">
サインインの実装
まず、OWIN ミドルウェアを初期化して、アプリに Microsoft Entra 認証を使用します。
ソリューション エクスプローラーで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 ミドルウェアを構成し、OnAuthenticationFailedAsync
と OnAuthorizationCodeReceivedAsync
の 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.cs の OnAuthorizationCodeReceivedAsync
メソッドを、この関数を呼び出すように更新します。 次の 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
メソッドが、すべてのロジックを実行します。 まずキャッシュされたトークンをチェックし、期限切れでない場合はトークンを返します。 有効期限が切れている場合は、キャッシュされた更新トークンを使用して新しいトークンを取得します。 このメソッドは、後の演習で使用します。
要約
この演習では、前の演習からアプリケーションを拡張し、Microsoft Entra ID による認証をサポートしました。 これは、Microsoft Graph API を呼び出すのに必要な OAuth アクセス トークンを取得するために必要です。 その手順では、OWIN ミドルウェアと Microsoft Authentication Library のライブラリをアプリケーションに統合しました。