WebAPI はバックエンドとして利用されるため、ユーザーのサインイン画面を Web API として出す機会がありません。そのため以下のフローを取ります。
- 独自 WebAPI 自体を Azure AD でセキュアにする
- クライアントが WebAPI を利用する際に Azure AD にて認証し、トークンを Web API に渡す
- WebAPI が受け取ったユーザー情報を元に、代理でアクセストークンを取得する
今回は以下のアプリケーションを作ります。
- C# ASP.NET WebAPI
- C# コンソールアプリケーションクライアント
- 代理認証シナリオ
- ADAL を利用
アプリケーションの開発
Web API の開発
OAuth 2.0 を使うには、まずアプリケーションの登録が必要ですが、Visual Studio の機能を使うと登録も自動で行えます。今回はこの機能を使ってアプリケーションを開発しながら、登録も行います。
1. Visual Studio より新しい ASP.NET Web アプリケーションを作成。
2. 「Web API」を選択し、「認証の変更」をクリック。
3. 「職場または学校アカウント」を選択し、アプリケーションを登録するドメインを指定し、「OK」をクリック。サインインを求められたら必要に応じて管理者権限でサインイン。
4. Azure ポータル に接続し、「Azure Active Directory」 より「アプリの登録」を選択。プロジェクト名と同じアプリケーションが作成されていることを確認。
6.「必要なアクセス許可」をクリックし、「追加」をクリック。
7. 「API を選択します」をクリックして、「Microsoft Graph」をクリック。「選択」ボタンをクリック。
8. 「アクセス許可を選択します」をクリックし、必要な権限を追加。ここでは Read and write user and shared calendars を選択し、最後に「完了」をクリック。
9. 今回はユーザー委任の権限を追加しているが、WebAPI で画面がないため、管理者として許可が必要。「アクセス許可の付与」をクリック。
10. 次に、応答 URL をクリックし、アドレスを https から http に変更して保存。
11. Visual Studio に戻り、プロジェクトのプロパティより Web を確認。既定で http:// が指定されているため、http に変更。
12. NuGet の管理より「Microsoft.IdentityModel.Clients.ActiveDirectory (ADAL)」を追加。また 「System.IdentityModel」を参照より追加。
13. ユーザーが WebAPI にログイン時、その情報を保存するよう Startup.Auth.cs を書き換え。
using System;
using System.Collections.Generic;
using System.Configuration;
using System.IdentityModel.Tokens;
using System.Linq;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.ActiveDirectory;
using Owin;
namespace GraphWebAPIDemo
{
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
app.UseWindowsAzureActiveDirectoryBearerAuthentication(
new WindowsAzureActiveDirectoryBearerAuthenticationOptions
{
Tenant = ConfigurationManager.AppSettings["ida:Tenant"],
TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = ConfigurationManager.AppSettings["ida:Audience"],
SaveSigninToken = true
}
});
}
}
}
14. ValuesController.cs で認証およびアクセストークンを取得できるようコードを差し替え。
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Http;
namespace GraphWebAPIDemo.Controllers
{
[Authorize]
public class ValuesController : ApiController
{
private static string aadInstance = "https://login.windows.net/{0}";
private static string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
private static string clientId = ConfigurationManager.AppSettings["ida:ClientID"];
private static string appKey = ConfigurationManager.AppSettings["ida:Password"];
private static string graphUri = "https://graph.microsoft.com";
// GET api/values
public async Task<IEnumerable<string>> Get()
{
string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
if (addinScopes.Contains("user_impersonation"))
{
ClientCredential clientCred = new ClientCredential(clientId, appKey);
var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
as System.IdentityModel.Tokens.BootstrapContext;
string userAccessToken = bootstrapContext.Token;
UserAssertion userAssertion = new UserAssertion(userAccessToken);
string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
AuthenticationContext authContext = new AuthenticationContext(authority);
var authResult = await authContext.AcquireTokenAsync(graphUri, clientCred, userAssertion);
using (HttpClient client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
var responseMessage = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
JToken me = JToken.Parse(await responseMessage.Content.ReadAsStringAsync());
List<string> results = new List<string>();
foreach (JProperty property in me.Children())
{
results.Add(property.Value.ToString());
}
return results;
}
}
return new string[] { "失敗", "認証情報取得失敗" };
}
// GET api/values/5
public string Get(int id)
{
return "value";
}
// POST api/values
public void Post([FromBody]string value)
{
}
// PUT api/values/5
public void Put(int id, [FromBody]string value)
{
}
// DELETE api/values/5
public void Delete(int id)
{
}
}
}
Web API クライアントの開発
1. まずクライアント用のアプリケーションを Azure ポータル より追加。アプリの登録より「新しいアプリケーションの登録」をクリック。
2. 「アプリケーションの種類」より「ネイティブ」を選択し、任意の「リダイレクト URI」を指定して作成。
3. 作成したアプリケーションの ID を確認して、「設定」をクリック。
5. 「API を選択します」をクリックし、検索バーで 「GraphWebAPIDemo」を検索して、選択。
6. 「アクセス許可を選択します」より、既存のアクセス許可を選択して「選択」し、最後に「完了」をクリック。
7. また既に作成されている WebAPI 用のアプリケーションを開き、「設定」より「プロパティ」を選択。「アプリケーション ID/URI」を確認。
8. 次に、ソリューションに新しくコンソールアプリケーションを追加。
9. NuGet の管理で 「Microsoft.IdentityModel.Clients.ActiveDirectory (ADAL)」および「JSON.NET」を追加。
10. 参照の追加より System.Security アセンブリを追加。
11. TokenCacheHelper.cs ファイルを追加し、以下のコードと差し替え。これは ADAL で取得したトークンキャッシュをローカルディスクに保存するためのクラス。
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System.IO;
using System.Security.Cryptography;
namespace GraphWebAPIClientDemo
{
public partial class Program
{
class TokenCacheHelper
{
/// <summary>
/// Get the user token cache
/// </summary>
/// <returns></returns>
public static TokenCache GetTokenCache()
{
if (myTokenCache == null)
{
myTokenCache = new TokenCache();
myTokenCache.BeforeAccess = BeforeAccessNotification;
myTokenCache.AfterAccess = AfterAccessNotification;
}
return myTokenCache;
}
static TokenCache myTokenCache;
/// <summary>
/// Path to the token cache
/// </summary>
public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin";
private static readonly object FileLock = new object();
public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
lock (FileLock)
{
args.TokenCache.Deserialize(File.Exists(CacheFilePath)
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
null,
DataProtectionScope.CurrentUser)
: null);
}
}
public static void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (args.TokenCache.HasStateChanged)
{
lock (FileLock)
{
// reflect changesgs in the persistent store
File.WriteAllBytes(CacheFilePath,
ProtectedData.Protect(args.TokenCache.Serialize(),
null,
DataProtectionScope.CurrentUser)
);
// once the write operationtakes place restore the HasStateChanged bit to filse
args.TokenCache.HasStateChanged = false;
}
}
}
}
}
}
12. Program.cs を以下のコードと差し替え。resourceUri は上記で確認した Web API の「アプリケーションID/URI」を指定。合わせてClientID も書き換え。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Newtonsoft.Json;
namespace GraphUserDemo
{
public partial class Program
{
private static AuthenticationContext authContext = new AuthenticationContext("https://login.microsoftonline.com/common/", TokenCacheHelper.GetTokenCache());
private static string clientId = "f0bf59c9-50d9-4af2-97e6-8d6c401a9c4d";
private static Uri redirectUri = new Uri("http://localhost/myapp");
private string graphUrl = "https://graph.microsoft.com";
static void Main(string[] args)
{
Program p = new Program();
p.Run();
Console.Read();
}
private async Task Run()
{
AuthenticationResult authResult = null;
try
{
if (authContext.TokenCache.ReadItems().Count() > 0)
authContext = new AuthenticationContext(authContext.TokenCache.ReadItems().First().Authority, TokenCacheHelper.GetTokenCache());
authResult = await authContext.AcquireTokenSilentAsync(graphUrl, clientId);
}
catch (AdalSilentTokenAcquisitionException ex)
{
authResult = await authContext.AcquireTokenAsync(graphUrl, clientId, redirectUri, new PlatformParameters(PromptBehavior.Always));
}
catch (Exception ex)
{
}
using (HttpClient client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
client.BaseAddress = new Uri(graphUrl);
var result = await client.GetAsync("/v1.0/me");
if (result.IsSuccessStatusCode)
Console.WriteLine(JsonConvert.SerializeObject(
JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()),
Formatting.Indented));
}
}
}
}
アプリケーションのテスト
1. まず WebAPI プロジェクトを起動してから、クライアントアプリケーションを起動。サインインしたら権限の委任を承諾。
2. WebAPI から Microsoft Graph の結果が返ることを確認。
コードの詳細を確認
まず WebAPI のコードについて見ていきます。
WebAPI としてのアクセストークン確認
user_impersonation スコープで取得しているはずのトークンを再度確認しています。
string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
if (addinScopes.Contains("user_impersonation"))
{
...
代理でアクセストークンを取得
代理取得のために、まず UserAssertion というユーザー情報を作ります。
var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
as System.IdentityModel.Tokens.BootstrapContext;
string userAccessToken = bootstrapContext.Token;
UserAssertion userAssertion = new UserAssertion(userAccessToken);
また、この BootstrapContext を取得するために、Startup.Auth.cs で SaveSigninToken = true パラメーターを指定しています。
TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = ConfigurationManager.AppSettings["ida:Audience"],
SaveSigninToken = true
}
作成した UserAssertion を使ってアクセストークンを取得。これでユーザーレベルのトークンが取得できます。
string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
AuthenticationContext authContext = new AuthenticationContext(authority);
var authResult = await authContext.AcquireTokenAsync(graphUri, clientCred, userAssertion);
Microsoft Graph の呼び出し
HttpClient を使って Graph を呼び出しています。Graph の C# SDK はまた別の機会に紹介します。
クライアント側はいつもと同じ感じですが、キーとなるのは、WebAPI に対するアクセス権の設定およびリソースが「アプリケーション ID/URI」になる点です。
private string resourceUri = "https://graphdemo01.onmicrosoft.com/GraphWebAPIDemo";
まとめ
WebAPI の場合は認証する画面をサーバー側では出さないため、クライアント側での認証、管理者の同意、シークレットの利用など、多くの知識が必要となります。最近は WebAPI を活用したアーキテクチャーが増えているため、是非一度お試しください。
参照
Azure ポータル
Azure AD の認証シナリオ
GitHub:active-directory-dotnet-webapi-onbehalfof
Azure Active Directory のコード サンプル (V1 エンドポイント)