練習 - 將 Microsoft 驗證程式庫納入 ASP.NET MVC Web 應用程式

已完成 200 XP

在此練習中,您將從上一個練習擴充應用程式,以支援使用 Microsoft Entra ID 進行驗證。 若要取得呼叫 Microsoft Graph API 所需的 OAuth 存取權杖,此為必要項目。 在此步驟中,您會將 OWIN 中介軟體和 Microsoft 驗證程式庫的文件庫整合至應用程式。

以滑鼠右鍵按兩下 [方案總管] 中的 graph-tutorial 專案,然後選取 [新增>專案...]

選取[Web 組態檔],將檔案命名為 PrivateSettings.config,然後選取[新增]。

以下列程式碼取代其整個內容:

XML
<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 為 Microsoft Entra 系統管理中心的應用程式識別碼,並將 取代 YOUR_APP_PASSWORD_HERE 為您產生的客戶端密碼。 如果您的用戶端密碼包含任何 & 符號 (&),請務必在 PrivateSettings.config 中將其取代為 &amp;。 此外,請務必修改 ida:RedirectUriPORT 值,以符合應用程式的 URL。

重要

如果您使用原始檔控制 (例如 git),應該在這時候將 PrivateSettings.config 檔案從原始檔控制中排除,以避免意外洩漏您的應用程式識別碼和密碼。

更新 Web.config 以載入此新檔案。 使用下列項目取代 <appSettings> (第 7 行)。

XML
<appSettings file="PrivateSettings.config">

實作登入作業

首先,將 OWIN 中間件初始化為使用應用程式的 Microsoft Entra 驗證。

以滑鼠右鍵按兩下 [方案總管] 中的 [App_Start ] 資料夾,然後選取 [ 新增 > 類別...]。將檔案 命名Startup.Auth.cs ,然後選取 [ 新增]。 以下列程式碼取代整個內容。

C#
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。 這些回呼方法會在登入程序從 Azure 傳回時叫用。

現在,更新 Startup.cs 檔案以呼叫 ConfigureAuth 方法。 以下列程式碼取代 Startup.cs 的整個內容。

C#
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 類別,以將 messagedebug 查詢參數轉換為 Alert 物件。 開啟 Controllers/HomeController.cs 並新增下列函數。

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

新增控制器以處理登入作業。 以滑鼠右鍵按兩下 [方案總管] 中的 [ 控制 器] 資料夾,然後選取 [ 新增 > 控制器...]。選擇 [MVC 5 控制器 - 空白] ,然後選取 [ 新增]。 將控制器命名為 AccountController,然後選取[新增]。 以下列程式碼取代檔案的整個內容。

C#
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");
        }
    }
}

這會定義 SignInSignOut 動作。 SignIn 動作會檢查要求是否已經過驗證。 如果未經過驗證,其會叫用 OWIN 中介軟體來驗證使用者。 SignOut 動作會叫用 OWIN 中介軟體以登出。

儲存您的變更並啟動專案。 選取 [登入]按鈕,系統應會將您重新導向至 https://login.microsoftonline.com。 使用 Microsoft 帳戶登入,並同意要求的權限。 瀏覽器會重新導向至應用程式,並顯示權杖。

取得使用者詳細資料

使用者登入後,您就可以從 Microsoft Graph 取得其資訊。

以滑鼠右鍵按兩下 [方案總管] 中的 [Models] 資料夾,然後選取 [新增>類別...]。將類別命名為 CachedUser,然後選取 [新增]。 以下列程式碼取代 CachedUser.cs 的整個內容。

C#
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 ,然後選取 [ 新增]。 以下列程式碼取代此檔案的內容。

C#
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 陳述式新增到檔案頂端。

C#
using graph_tutorial.Helpers;

以下列程式碼取代 OnAuthorizationCodeReceivedAsync 中的 try 區塊。

C#
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 ,然後選取 [ 新增]。 以下列程式碼取代此檔案的內容。

C#
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 檔案的頂端。

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

以下列內容取代現有的 OnAuthorizationCodeReceivedAsync 函數。

C#
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 中的變更會執行下列動作:

  • 程式碼現在會使用 SessionTokenStore 類別包裝 ConfidentialClientApplication 的預設使用者權杖快取。 MSAL 文件庫會處理儲存權杖的邏輯,並在需要時重新整理。
  • 程式碼現在會將從 Microsoft Graph 取得的使用者詳細資料傳遞給 SessionTokenStore 物件,以儲存於工作階段中。
  • 成功時,程式碼就不會再重新導向,而是直接傳回。 這可讓 OWIN 中介軟體完成驗證程序。

SignOut 動作更新為在登出之前清除權杖存放區。將下列 using 陳述式新增到 Controllers/AccountController.cs 的頂端。

C#
using graph_tutorial.TokenStorage;

以下列內容取代現有的 SignOut 函數。

C#
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陳述式新增至檔案頂端。

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

新增下列函數。

C#
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 的權杖。

不過,此權杖是短期的。 權杖會在發行後一小時到期。 這就是需要使用重新整理權杖的地方。 重新整理權杖可讓應用程式要求新的存取權杖,而不需要使用者再次登入。

因為應用程式使用 MSAL 文件庫,並且序列化 TokenCache 物件,所以您不需要實作任何權杖重新整理邏輯。 ConfidentialClientApplication.AcquireTokenSilentAsync 方法會為您執行所有邏輯。 其會先檢查快取的權杖,如果尚未過期,則傳回該權杖。 如果已過期,則會使用快取的重新整理權杖來取得新的權杖。 您將在稍後的練習中使用此方法。

摘要

在此練習中,您已擴充上一個練習中的應用程式,以支援使用 Microsoft Entra ID 進行驗證。 若要取得呼叫 Microsoft Graph API 所需的 OAuth 存取權杖,此為必要項目。 在該步驟中,您已將 OWIN 中介軟體和 Microsoft 驗證程式庫的文件庫整合至應用程式。

測試您的知識

1.

註冊 Microsoft Entra 應用程式以搭配 OAuth 2.0 授權碼授與流程使用時,需要哪些元素才能建立、設定和/或收集?

2.

靜態和動態同意之間有什麼不同?


下一個單元: 將 Microsoft Graph 納入 ASP.NET MVC Web App

上一個 下一個