練習 - 將 Microsoft 驗證程式庫納入 ASP.NET MVC Web 應用程式
在此練習中,您將從上一個練習擴充應用程式,以支援使用 Microsoft Entra ID 進行驗證。 若要取得呼叫 Microsoft Graph API 所需的 OAuth 存取權杖,此為必要項目。 在此步驟中,您會將 OWIN 中介軟體和 Microsoft 驗證程式庫的文件庫整合至應用程式。
以滑鼠右鍵按兩下 [方案總管] 中的 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>
將 取代 YOUR_APP_ID_HERE
為 Microsoft Entra 系統管理中心的應用程式識別碼,並將 取代 YOUR_APP_PASSWORD_HERE
為您產生的客戶端密碼。 如果您的用戶端密碼包含任何 & 符號 (&
),請務必在 PrivateSettings.config
中將其取代為 &
。 此外,請務必修改 ida:RedirectUri
的 PORT
值,以符合應用程式的 URL。
重要
如果您使用原始檔控制 (例如 git),應該在這時候將 PrivateSettings.config 檔案從原始檔控制中排除,以避免意外洩漏您的應用程式識別碼和密碼。
更新 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
。 這些回呼方法會在登入程序從 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");
}
新增控制器以處理登入作業。 以滑鼠右鍵按兩下 [方案總管] 中的 [ 控制 器] 資料夾,然後選取 [ 新增 > 控制器...]。選擇 [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
中的變更會執行下列動作:
- 程式碼現在會使用
SessionTokenStore
類別包裝ConfidentialClientApplication
的預設使用者權杖快取。 MSAL 文件庫會處理儲存權杖的邏輯,並在需要時重新整理。 - 程式碼現在會將從 Microsoft Graph 取得的使用者詳細資料傳遞給
SessionTokenStore
物件,以儲存於工作階段中。 - 成功時,程式碼就不會再重新導向,而是直接傳回。 這可讓 OWIN 中介軟體完成驗證程序。
將 SignOut
動作更新為在登出之前清除權杖存放區。將下列 using
陳述式新增到 Controllers/AccountController.cs 的頂端。
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 的權杖。
不過,此權杖是短期的。 權杖會在發行後一小時到期。 這就是需要使用重新整理權杖的地方。 重新整理權杖可讓應用程式要求新的存取權杖,而不需要使用者再次登入。
因為應用程式使用 MSAL 文件庫,並且序列化 TokenCache
物件,所以您不需要實作任何權杖重新整理邏輯。
ConfidentialClientApplication.AcquireTokenSilentAsync
方法會為您執行所有邏輯。 其會先檢查快取的權杖,如果尚未過期,則傳回該權杖。 如果已過期,則會使用快取的重新整理權杖來取得新的權杖。 您將在稍後的練習中使用此方法。
摘要
在此練習中,您已擴充上一個練習中的應用程式,以支援使用 Microsoft Entra ID 進行驗證。 若要取得呼叫 Microsoft Graph API 所需的 OAuth 存取權杖,此為必要項目。 在該步驟中,您已將 OWIN 中介軟體和 Microsoft 驗證程式庫的文件庫整合至應用程式。