Web アプリケーションでの認証
ユーザーは以下の図のように、ブラウザ経由で ASP.NET MVC アプリケーションに接続します。Azure AD v2 エンドポイントを利用して自分のサイトをセキュアにできます。
以下、ASP.NET Web アプリへの "Microsoft でサインイン" の追加 より抜粋。
しかし Web アプリケーションが Microsoft Graph に接続してユーザーに関連する情報を取得する場合、ユーザーのアクセストークンが必要となる一方、アクセストークンを要求するのは ASP.NET MVC アプリケーションとなります。そこで ADAL ではそのための手段として、OpenId Connect によるユーザー認証と認可コードの取得、およびサービス側が、認可コードを利用してアクセストークンを取得する仕組みを用意しています。
今回は以下のアプリケーションを作ります。
- C# MVC Web アプリケーション
- OpenId と認可コードフローでトークン取得
- ADAL を利用
アプリケーションの登録
OAuth 2.0 を使うために、まず初めにアプリケーションを登録します。
1. Azure ポータル にアクセスして、ログイン。ここでは組織アカウントを使用。ログイン後、Azure Active Directory を選択。
2. 「アプリの登録」を選択し、「新しいアプリケーションの登録」をクリック。
3. 名前を指定し、「アプリケーションの種類」から「Web アプリ/API」を選択。任意の「リダイレクト URI」を指定し「作成」をクリック。
4. アプリが作成されたら「アプリション ID」を確認。その後「設定」をクリック。
5. 「必要なアクセス許可」をクリックし、「追加」をクリック。
6. 「API を選択します」をクリックして、「Microsoft Graph」をクリック。「選択」ボタンをクリック。
7. 「アクセス許可を選択します」をクリックし、必要な権限を追加。ここでは Read and write user and shared calendars を選択し、最後に「完了」をクリック。
8. キーメニューをクリックし、説明に名前を、期間を選択して「保存」。値が表示されるのでコピー。この値は画面遷移後はもう表示されない。
アプリケーションの開発
1. Visual Studio で ASP.NET Web アプリケーションプロジェクトを作成。
2. MVC を選択。認証は「個別のユーザーアカウント」を選択して「OK」をクリック。
3. NuGet の管理より「Microsoft.Owin.Security.OpenIdConnect」および「Microsoft.IdentityModel.Clients.ActiveDirectory (ADAL)」を追加。
4. 作成したプロジェクトを右クリックしてプロパティを表示。Web のセクションから「プロジェクトの URL」 を確認。
5. Azure ポータル に登録したアプリケーションの設定より、「応答 URL」に上記アドレスを追加。
6. ユーザーのログインとトークン管理のヘルパークラスを作成。プロジェクトに TokenStorage フォルダを作成し、SessionTokenCache.cs ファイルを追加。
7. 中身を以下のコードと書き換え。尚コードは GitHub にある aspnet-connect-rest-sample のもの少し変更。これまでの記事でも紹介した通り、ADAL が利用する TokenCache に利用される。
/*
* 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 を利用するように変更。
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 の追加を必要に応じて追加。
//
// 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 メソッドを以下に書き換え。
//
// 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 を以下のコードと差し替え。
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 を以下に書き換え。
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 に追加します。
<add key="ClientId" value="e3e6b8fe-36a2-4c9e-bc5c-52c1fe7b0504" />
<add key="ClientSecret" value="X8oGRlT5eyWz9g2e....." />
<add key="RedirectUri" value="http://localhost:53154/" />
アプリケーションのテスト
1. F5 キーを押下してアプリケーションを起動。「ログイン」をクリック。
2. サインインして、要求される権限を「承認」
3. 「詳細」をクリックして Microsoft Graph より情報が取れることを確認。
##コードの詳細確認
ここまでのコードを確認してみます。
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 コールバックの最後に以下のコードを追加。
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. アプリケーションを実行してログイン。名前が表示されることを確認。
考慮事項
今回はあくまで動作確認のための開発をしましたが、本番環境では以下の考慮が必要です。
- キャッシュの有効期限が切れることも考えて、アクセストークン取得のエラーはハンドルする
- Web サーバーの増減や再起動を考慮して、キャッシュの場所を検討する
まとめ
今回の開発を通して、Web アプリケーションを Azure AD v1 でセキュアにすると同時に、OpenId Connect で認証時に認可コードを取得し、認可コードで一度アクセストークンを取得することで、トークンキャッシュが生成される仕組みが見えたと思います。是非試してください。
# 参考
Azure ポータル
Azure AD の認証シナリオ
GitHub:active-directory-webapp-webapi-multitenant-openidconnect-aspnetcore