使用 Microsoft Graph 生成 Azure 函数
本教程指导你如何生成 Azure 函数,该函数使用 Microsoft Graph API 检索用户的日历信息。
提示
如果只想下载已完成的教程,可以下载或克隆GitHub存储库。 有关使用应用 ID 和密码配置应用的说明,请参阅演示文件夹中的自述文件。
先决条件
在开始本教程之前,应在开发计算机上安装以下工具。
还应具有 Microsoft 工作或学校帐户,具有对同一组织中全局管理员帐户的访问权限。 如果你没有 Microsoft 帐户,可以注册 Microsoft 365 计划,获取免费的 Office 365 订阅。
备注
本教程是使用上述工具的以下版本编写的。 本指南中的步骤可能与其他版本一起运行,但该版本尚未经过测试。
- .NET Core SDK 5.0.203
- Azure Functions Core Tools 3.0.3442
- Azure CLI 2.23.0
- ngrok 2.3.40
反馈
Please provide any feedback on this tutorial in the GitHub repository.
创建 Azure Functions 项目
在本教程中,你将创建一个简单的 Azure 函数,该函数实现调用 Microsoft Graph 的 HTTP 触发器Graph。 这些函数将涵盖以下方案:
- 实现 API 以使用代表流身份验证访问 用户的 收件箱。
- 使用客户端凭据授予流身份验证,实现用于订阅和取消订阅用户收件箱通知的 API。
- 实现 Webhook 以接收来自 Microsoft Graph更改通知,以及使用客户端凭据授予流访问数据。
此外,你还将在 SPA (创建简单的 JavaScript 单页) 以调用在 Azure 函数中实现的 API。
创建 Azure Functions 项目
在要创建项目的 (CLI) 打开命令行接口。 运行以下命令:
func init GraphTutorial --worker-runtime dotnetisolated
将 CLI 中的当前目录更改为 GraphTu一l 目录,并运行以下命令在项目中创建三个函数。
func new --name GetMyNewestMessage --template "HTTP trigger" func new --name SetSubscription --template "HTTP trigger" func new --name Notify --template "HTTP trigger"
打开 local.settings.json ,将以下内容添加到 文件以允许来自 的测试应用程序的 URL 的 CORS
http://localhost:8080
。"Host": { "CORS": "http://localhost:8080" }
运行以下命令以在本地运行项目。
func start
如果一切正常,你将看到以下输出:
Functions: GetMyNewestMessage: [GET,POST] http://localhost:7071/api/GetMyNewestMessage Notify: [GET,POST] http://localhost:7071/api/Notify SetSubscription: [GET,POST] http://localhost:7071/api/SetSubscription
打开浏览器并浏览到输出中显示的函数 URL,验证函数是否正常工作。 您应该在浏览器中看到以下消息:
Welcome to Azure Functions!
。
创建单页应用程序
在要创建项目的目录中打开 CLI。 创建一个名为 TestClient 的 目录以保存 HTML 和 JavaScript 文件。
在 TestClient index.html 新建一个名为index.html的文件,并添加以下代码。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <title>Azure Functions Graph Tutorial Test Client</title> <link rel="shortcut icon" href="g-raph.png"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.1/css/all.css" crossorigin="anonymous"> <link href="style.css" rel="stylesheet" type="text/css" /> </head> <body> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <div class="container"> <a href="/" class="navbar-brand">Azure Functions Graph Test Client</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul id="authenticated-nav" class="navbar-nav mr-auto"></ul> <ul class="navbar-nav justify-content-end"> <li class="nav-item"> <a class="nav-link" href="https://developer.microsoft.com/graph/docs/concepts/overview" target="_blank"> <i class="fas fa-external-link-alt mr-1"></i>Docs </a> </li> <li id="account-nav" class="nav-item"></li> </ul> </div> </div> </nav> <main id="main-container" role="main" class="container"> </main> <!-- Bootstrap/jQuery --> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script> <!-- MSAL --> <script src="https://alcdn.msauth.net/browser/2.0.0/js/msal-browser.min.js" integrity="sha384-n3aacu1eFuIAfS3ZY4WGIZiQG/skqpT+cbeqIwLddpmMWcxWZwYdt+F0PgKyw+m9" crossorigin="anonymous"></script> <script src="config.js"></script> <script src="ui.js"></script> <script src="auth.js"></script> <script src="azurefunctions.js"></script> </body> </html>
这将定义应用的基本布局,包括导航栏。 它还添加了以下内容:
- Bootstrap 及其支持的 JavaScript
- Font提供
- 适用于 JavaScript 的 Microsoft 身份验证库 (MSAL.js) 2.0
提示
此页面包括一个 favicon、 ()
<link rel="shortcut icon" href="g-raph.png">
。 可以删除此行,也可以从 g-raph.png下载GitHub 。在 TestClient 目录中创建一个名为 style.css 的新文件并添加以下代码。
body { padding-top: 70px; }
在 TestClient ui.js 新建一个名为ui.js的文件,并添加以下代码。
// Select DOM elements to work with const authenticatedNav = document.getElementById('authenticated-nav'); const accountNav = document.getElementById('account-nav'); const mainContainer = document.getElementById('main-container'); const Views = { error: 1, home: 2, message: 3, subscriptions: 4 }; // Helper function to create an element, set class, and add text function createElement(type, className, text) { const element = document.createElement(type); element.className = className; if (text) { const textNode = document.createTextNode(text); element.appendChild(textNode); } return element; } // Show the navigation items that should only show if // the user is signed in function showAuthenticatedNav(user, view) { authenticatedNav.innerHTML = ''; if (user) { // Add message link const messageNav = createElement('li', 'nav-item'); const messageLink = createElement('button', `btn btn-link nav-link${view === Views.message ? ' active' : '' }`, 'Latest Message'); messageLink.setAttribute('onclick', 'getLatestMessage();'); messageNav.appendChild(messageLink); authenticatedNav.appendChild(messageNav); // Add subscriptions link const subscriptionNav = createElement('li', 'nav-item'); const subscriptionLink = createElement('button', `btn btn-link nav-link${view === Views.message ? ' active' : '' }`, 'Subscriptions'); subscriptionLink.setAttribute('onclick', `updatePage(${Views.subscriptions});`); subscriptionNav.appendChild(subscriptionLink); authenticatedNav.appendChild(subscriptionNav); } } // Show the sign in button or the dropdown to sign-out function showAccountNav(user) { accountNav.innerHTML = ''; if (user) { // Show the "signed-in" nav accountNav.className = 'nav-item dropdown'; const dropdown = createElement('a', 'nav-link dropdown-toggle'); dropdown.setAttribute('data-toggle', 'dropdown'); dropdown.setAttribute('role', 'button'); accountNav.appendChild(dropdown); const userIcon = createElement('i', 'far fa-user-circle fa-lg rounded-circle align-self-center'); userIcon.style.width = '32px'; dropdown.appendChild(userIcon); const menu = createElement('div', 'dropdown-menu dropdown-menu-right'); dropdown.appendChild(menu); const userName = createElement('h5', 'dropdown-item-text mb-0', user); menu.appendChild(userName); const divider = createElement('div', 'dropdown-divider'); menu.appendChild(divider); const signOutButton = createElement('button', 'dropdown-item', 'Sign out'); signOutButton.setAttribute('onclick', 'signOut();'); menu.appendChild(signOutButton); } else { // Show a "sign in" button accountNav.className = 'nav-item'; const signInButton = createElement('button', 'btn btn-link nav-link', 'Sign in'); signInButton.setAttribute('onclick', 'signIn();'); accountNav.appendChild(signInButton); } } // Renders the home view function showWelcomeMessage(user) { // Create jumbotron const jumbotron = createElement('div', 'jumbotron'); const heading = createElement('h1', null, 'Azure Functions Graph Tutorial Test Client'); jumbotron.appendChild(heading); const lead = createElement('p', 'lead', 'This sample app is used to test the Azure Functions in the Azure Functions Graph Tutorial'); jumbotron.appendChild(lead); if (user) { // Welcome the user by name const welcomeMessage = createElement('h4', null, `Welcome ${user}!`); jumbotron.appendChild(welcomeMessage); const callToAction = createElement('p', null, 'Use the navigation bar at the top of the page to get started.'); jumbotron.appendChild(callToAction); } else { // Show a sign in button in the jumbotron const signInButton = createElement('button', 'btn btn-primary btn-large', 'Click here to sign in'); signInButton.setAttribute('onclick', 'signIn();') jumbotron.appendChild(signInButton); } mainContainer.innerHTML = ''; mainContainer.appendChild(jumbotron); } // Renders an email message function showLatestMessage(message) { // Show message const messageCard = createElement('div', 'card'); const cardBody = createElement('div', 'card-body'); messageCard.appendChild(cardBody); const subject = createElement('h1', 'card-title', `${message.subject || '(No subject)'}`); cardBody.appendChild(subject); const fromLine = createElement('div', 'd-flex'); cardBody.appendChild(fromLine); const fromLabel = createElement('div', 'mr-3'); fromLabel.appendChild(createElement('strong', '', 'From:')); fromLine.appendChild(fromLabel); fromLine.appendChild(createElement('div', '', message.from.emailAddress.name)); const receivedLine = createElement('div', 'd-flex'); cardBody.appendChild(receivedLine); const receivedLabel = createElement('div', 'mr-3'); receivedLabel.appendChild(createElement('strong', '', 'Received:')); receivedLine.appendChild(receivedLabel); receivedLine.appendChild(createElement('div', '', message.receivedDateTime)); mainContainer.innerHTML = ''; mainContainer.appendChild(messageCard); } // Renders current subscriptions from the session, and allows the user // to add new subscriptions function showSubscriptions() { const subscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')); // Show new subscription form const form = createElement('form', 'form-inline mb-3'); const userInput = createElement('input', 'form-control mb-2 mr-2 flex-grow-1'); userInput.setAttribute('id', 'subscribe-user'); userInput.setAttribute('type', 'text'); userInput.setAttribute('placeholder', 'User to subscribe to (user ID or UPN)'); form.appendChild(userInput); const subscribeButton = createElement('button', 'btn btn-primary mb-2', 'Subscribe'); subscribeButton.setAttribute('type', 'button'); subscribeButton.setAttribute('onclick', 'createSubscription();'); form.appendChild(subscribeButton); const card = createElement('div', 'card'); const cardBody = createElement('div', 'card-body'); card.appendChild(cardBody); cardBody.appendChild(createElement('h2', 'card-title mb-4', 'Existing subscriptions')); const subscriptionTable = createElement('table', 'table'); cardBody.appendChild(subscriptionTable); const thead = createElement('thead', ''); subscriptionTable.appendChild(thead); const theadRow = createElement('tr', ''); thead.appendChild(theadRow); theadRow.appendChild(createElement('th', '')); theadRow.appendChild(createElement('th', '', 'User')); theadRow.appendChild(createElement('th', '', 'Subscription ID')) if (subscriptions) { // List subscriptions for (const subscription of subscriptions) { const row = createElement('tr', ''); subscriptionTable.appendChild(row); const deleteButtonCell = createElement('td', ''); row.appendChild(deleteButtonCell); const deleteButton = createElement('button', 'btn btn-sm btn-primary', 'Delete'); deleteButton.setAttribute('onclick', `deleteSubscription("${subscription.subscriptionId}");`); deleteButtonCell.appendChild(deleteButton); row.appendChild(createElement('td', '', subscription.userId)); row.appendChild(createElement('td', '', subscription.subscriptionId)); } } mainContainer.innerHTML = ''; mainContainer.appendChild(form); mainContainer.appendChild(card); } // Renders an error function showError(error) { const alert = createElement('div', 'alert alert-danger'); const message = createElement('p', 'mb-3', error.message); alert.appendChild(message); if (error.debug) { const pre = createElement('pre', 'alert-pre border bg-light p-2'); alert.appendChild(pre); const code = createElement('code', 'text-break text-wrap', JSON.stringify(error.debug, null, 2)); pre.appendChild(code); } mainContainer.innerHTML = ''; mainContainer.appendChild(alert); } // Re-renders the page with the selected view function updatePage(view, data) { if (!view) { view = Views.home; } // Get the user name from the session const user = sessionStorage.getItem('msal-userName'); if (!user && view !== Views.error) { view = Views.home; } showAccountNav(user); showAuthenticatedNav(user, view); switch (view) { case Views.error: showError(data); break; case Views.home: showWelcomeMessage(user); break; case Views.message: showLatestMessage(data); break; case Views.subscriptions: showSubscriptions(); break; } } updatePage(Views.home);
此代码使用 JavaScript 根据所选视图呈现当前页面。
测试单页应用程序
备注
本节包含有关在开发 计算机上使用 dotnet-serve 运行简单测试 HTTP 服务器的说明。 不需要使用此特定工具。 可以使用您喜欢的任何测试服务器来为 TestClient 目录提供服务。
在 CLI 中运行以下命令以安装 dotnet-serve。
dotnet tool install --global dotnet-serve
将 CLI 中的当前目录更改为 TestClient 目录并运行以下命令以启动 HTTP 服务器。
dotnet serve -h "Cache-Control: no-cache, no-store, must-revalidate" -p 8080
打开浏览器,并导航到
http://localhost:8080
。 页面应呈现,但当前没有任何按钮可以正常工作。
添加 Nuget 程序包
在继续之前,请安装一些NuGet程序包,你稍后会使用它。
- Microsoft.Azure.Functions.Extensions ,用于启用 Azure Functions 项目中的依赖关系注入。
- 用于从 .NET 开发密码存储中读取应用程序配置的 Microsoft.Extensions.Configuration.UserSecrets。
- Microsoft.Graph 用来呼叫 Microsoft Graph。
- 用于验证和管理令牌的 Microsoft.Identity.Client 。
- 用于检索 OpenID 配置以用于令牌验证的 Microsoft.IdentityModel.Protocols.OpenIdConnect。
- System.IdentityModel.Tokens.Jwt ,用于验证发送到 Web API 的令牌。
将 CLI 中的当前目录更改为 GraphTu一l 目录并运行以下命令。
dotnet add package Microsoft.Azure.Functions.Extensions --version 1.1.0 dotnet add package Microsoft.Extensions.Configuration.UserSecrets --version 5.0.0 dotnet add package Microsoft.Graph --version 4.0.0 dotnet add package Microsoft.Identity.Client --version 4.35.1 dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect --version 6.12.0 dotnet add package System.IdentityModel.Tokens.Jwt --version 6.12.0
在门户中注册应用
在此练习中,你将使用管理中心Azure AD三个新的Azure Active Directory应用程序:
- 单页应用程序的应用注册,以便它可以登录用户并获取允许应用程序调用 Azure Function 的令牌。
- Azure 函数的应用注册,允许其使用代表流交换 SPA 发送的令牌,获取允许 SPA 调用 Microsoft Graph。
- Azure Function Webhook 的应用注册,允许其在没有用户的情况下使用客户端凭据流Graph Microsoft 帐户。
备注
此示例需要三个应用程序注册,因为它同时实现代表流和客户端凭据流。 如果你的 Azure 函数仅使用其中一个流,你只需创建对应于该流的应用注册。
打开浏览器,然后导航到Azure Active Directory中心,然后使用租户Microsoft 365登录。
选择左侧导航栏中的“Azure Active Directory”,再选择“管理”下的“应用注册”。
为单页应用程序注册应用程序
选择“新注册”。 在“注册应用”页上,按如下方式设置值。
- 将“名称”设置为“
Graph Azure Function Test App
”。 - 将 "支持的帐户类型"****设置为"仅此组织目录中的帐户"。
- 在 "重定向 URI"下,将下拉列表更改为 SPA (单页 ) ,将 值设置为
http://localhost:8080
。
- 将“名称”设置为“
选择“注册”。 在 Graph Azure Function Test App 页面上,复制 Application (client) ID 和 Directory (tenant) ID 的值并保存它们,在稍后的步骤中将需要这些值。
为 Azure 函数注册应用
返回到" 应用注册", 然后选择" 新建注册"。 在“注册应用”页上,按如下方式设置值。
- 将“名称”设置为“
Graph Azure Function
”。 - 将 "支持的帐户类型"****设置为"仅此组织目录中的帐户"。
- 将 重定向 URI 留 空。
- 将“名称”设置为“
选择“注册”。 在"Graph Azure 函数"页上,复制"应用程序 (客户端) ID"的值并保存,下一步中将需要该值。
选择“管理”下的“证书和密码”。 选择“新客户端密码”按钮。 在“说明”中输入值,并选择“过期”下的一个选项,再选择“添加”。
离开此页前,先复制客户端密码值。 将在下一步中用到它。
重要
此客户端密码不会再次显示,所以请务必现在就复制它。
选择 "管理"下的"API 权限"。 选择 "添加权限"。
选择 "Microsoft Graph",然后选择"委派权限"。 添加 Mail.Read ,然后选择 "添加权限"。
选择 "管理"下的"公开 API**"**,然后选择"添加范围"。
接受默认应用程序 ID URI, 然后选择" 保存并继续"。
填写" 添加范围" 表单,如下所示:
- 范围名称: Mail.Read
- Who同意?: 管理员和用户
- 管理员同意显示名称: 读取所有用户的收件箱
- 管理员同意说明: 允许应用读取所有用户的收件箱
- 用户同意显示名称: 阅读收件箱
- 用户同意说明: 允许应用读取收件箱
- 状态: 已启用
选择“添加作用域”。
复制新范围,你将在稍后的步骤中需要它。
选择 "管理" 下的"清单 "。
在
knownClientApplications
清单中查找,并将其[]``[TEST_APP_ID]
当前值替换为 ,其中TEST_APP_ID
是 Azure Function Test 应用注册Graph 的应用程序 ID。 选择“保存”。
备注
将测试应用程序的应用 ID knownClientApplications
添加到 Azure 函数清单中的 属性后,测试应用程序可以触发组合 同意流。 这是代表流正常工作所必需的。
添加 Azure 函数范围以测试应用程序注册
返回到 Azure Graph测试应用 注册,然后选择"管理"下的 "API 权限"。 选择“添加权限”。
选择 "我的 API",然后选择" 加载更多"。 选择 Graph Azure 函数"。
选择 "Mail.Read" 权限,然后选择" 添加权限"。
在 "已配置 的权限"中,删除 Microsoft Graph下的 User.Read 权限,选择权限右侧"..."并选择"删除权限"。 选择 "是,删除 "以确认。
为 Azure Function Webhook 注册应用
返回到" 应用注册", 然后选择" 新建注册"。 在“注册应用”页上,按如下方式设置值。
- 将“名称”设置为“
Graph Azure Function Webhook
”。 - 将 "支持的帐户类型"****设置为"仅此组织目录中的帐户"。
- 将 重定向 URI 留 空。
- 将“名称”设置为“
选择“注册”。 在"Graph Azure Function webhook"页面上,复制"应用程序 (客户端) ID"的值并保存它,下一步中将需要该值。
选择“管理”下的“证书和密码”。 选择“新客户端密码”按钮。 在“说明”中输入值,并选择“过期”下的一个选项,再选择“添加”。
离开此页前,先复制客户端密码值。 将在下一步中用到它。
选择 "管理"下的"API 权限"。 选择 "添加权限"。
选择 "Microsoft Graph",然后选择"应用程序权限"。 添加 User.Read.All 和 Mail.Read,然后选择" 添加权限"。
在 "已配置 的权限"中,删除 Microsoft Graph下的委派 User.Read 权限,选择权限右侧"..."并选择"删除权限"。 选择 "是,删除 "以确认。
选择"授予管理员同意..."按钮,然后选择"是"以授予对已配置应用程序权限的管理员同意。 " 已 配置的权限" 表中的 "状态"列将更改 为"已授予..."。
使用代表身份验证实现 API
在此练习中,你将完成 Azure 函数 GetMyNewestMessage
的实现,并更新测试客户端以调用 函数。
Azure 函数 使用代表流。 此流中事件的基本顺序为:
- 测试应用程序使用交互式身份验证流来允许用户登录并授予同意。 它将返回一个作用域为 Azure 函数的令牌。 令牌不包含 任何 Microsoft Graph作用域。
- 测试应用程序调用 Azure Function,在 标头中发送其访问
Authorization
令牌。 - Azure 函数验证令牌,然后将该令牌交换为包含 Microsoft 作用域的第二个Graph令牌。
- Azure 函数Graph第二个访问令牌代表用户调用 Microsoft 服务。
重要
为了避免在源中存储应用程序 ID 和密码,你将使用 .NET 密码管理器 存储这些值。 密码管理器仅供开发使用,生产应用应该使用受信任的密码管理器来存储密码。
向单页应用程序添加身份验证
首先将身份验证添加到 SPA。 这将允许应用程序获取访问令牌,以授予调用 Azure Function 的访问权限。 因为这是一个 SPA,它将授权 代码流与 PKCE 一同使用。
在 TestClient 目录中新建一个名为 config.js 并添加以下代码。
const msalConfig = { auth: { clientId: 'YOUR_TEST_APP_APP_ID_HERE', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID_HERE' } }; const msalRequest = { // Scope of the Azure Function scopes: [ 'YOUR_AZURE_FUNCTION_APP_ID_HERE/.default' ] }
将
YOUR_TEST_APP_APP_ID_HERE
替换为在 Azure 门户中为 Azure Function Test Graph创建的应用程序 ID。 将YOUR_TENANT_ID_HERE
替换为 从 Azure (复制) 的 Directory 租户租户 ID 值。 将YOUR_AZURE_FUNCTION_APP_ID_HERE
替换为 Azure Function Graph ID。重要
如果你使用的是源代码管理(如 git),那么现在应该将 config.js 文件从源代码管理中排除,以避免意外泄露应用 ID 和租户 ID。
在 TestClient 目录中新建一个名为 auth.js 并添加以下代码。
// Create the main MSAL instance // configuration parameters are located in config.js const msalClient = new msal.PublicClientApplication(msalConfig); async function signIn() { // Login try { // Use MSAL to login const authResult = await msalClient.loginPopup(msalRequest); // Save the account username, needed for token acquisition sessionStorage.setItem('msal-userName', authResult.account.username); // Refresh home page updatePage(Views.home); } catch (error) { console.log(error); updatePage(Views.error, { message: 'Error logging in', debug: error }); } } function signOut() { account = null; sessionStorage.removeItem('msal-userName'); msalClient.logout(); }
考虑此代码执行哪些功能。
- 它使用存储在config.js
PublicClientApplication
中的 值进行初始化。 - 它
loginPopup
使用 Azure 函数的权限范围登录用户。 - 它将用户的用户名存储在会话中。
重要
由于应用使用
loginPopup
,因此你可能需要更改浏览器的弹出窗口阻止程序,以允许弹出窗口http://localhost:8080
。- 它使用存储在config.js
刷新页面并登录。 页面应该使用用户名进行更新,指示登录成功。
向 Azure 函数添加身份验证
在此部分中,你将在 GetMyNewestMessage
Azure 函数中实现代表流,以获取与 Microsoft Graph。
在包含 GraphTu一l.csproj 的目录中打开 CLI 并运行以下命令,初始化 .NET 开发密码存储。
dotnet user-secrets init
使用下列命令将应用程序 ID、机密和租户 ID 添加到密码存储。 将
YOUR_API_FUNCTION_APP_ID_HERE
替换为 Azure Function Graph ID。 将YOUR_API_FUNCTION_APP_SECRET_HERE
替换为你在 Azure 门户中为 Azure 函数Graph 密码。 将YOUR_TENANT_ID_HERE
替换为 从 Azure (复制) 的 Directory 租户租户 ID 值。dotnet user-secrets set apiFunctionId "YOUR_API_FUNCTION_APP_ID_HERE" dotnet user-secrets set apiFunctionSecret "YOUR_API_FUNCTION_APP_SECRET_HERE" dotnet user-secrets set tenantId "YOUR_TENANT_ID_HERE"
处理传入的 bearer 令牌
在此部分中,你将实现一个类,以验证并处理从 SPA 发送到 Azure 函数的令牌。
在 GraphTu一l 目录中新建一个名为 Authentication 的目录。
在 ./GraphTu一l/Authentication 文件夹中创建一个名为 TokenValidationResult.cs 的新文件,并添加以下代码。
namespace GraphTutorial.Authentication { public class TokenValidationResult { // MSAL account ID - used to access the token // cache public string MsalAccountId { get; private set; } // The extracted token - used to build user assertion // for OBO flow public string Token { get; private set; } public TokenValidationResult(string msalAccountId, string token) { MsalAccountId = msalAccountId; Token = token; } } }
在 ./GraphTu一l/Authentication 文件夹中创建一个名为 TokenValidation.cs 的新文件,并添加以下代码。
using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System; using System.Security.Claims; using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public static class TokenValidation { private static TokenValidationParameters _validationParameters = null; public static async Task<TokenValidationResult> ValidateAuthorizationHeader( HttpRequest request, string tenantId, string expectedAudience, ILogger log) { // Check for Authorization header if (request.Headers.ContainsKey("authorization")) { var authHeader = AuthenticationHeaderValue.Parse(request.Headers["authorization"]); if (authHeader != null && authHeader.Scheme.ToLower() == "bearer" && !string.IsNullOrEmpty(authHeader.Parameter)) { if (_validationParameters == null) { // Load the tenant-specific OpenID config from Azure var configManager = new ConfigurationManager<OpenIdConnectConfiguration>( $"https://login.microsoftonline.com/{tenantId}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); var config = await configManager.GetConfigurationAsync(); _validationParameters = new TokenValidationParameters { // Use signing keys retrieved from Azure IssuerSigningKeys = config.SigningKeys, ValidateAudience = true, // Audience MUST be the app ID for the Web API ValidAudience = expectedAudience, ValidateIssuer = true, // Use the issuer retrieved from Azure ValidIssuer = config.Issuer, ValidateLifetime = true }; } var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken jwtToken; try { // Validate the token var result = tokenHandler.ValidateToken(authHeader.Parameter, _validationParameters, out jwtToken); // If ValidateToken did not throw an exception, token is valid. return new TokenValidationResult(GetMsalAccountId(result), authHeader.Parameter); } catch (Exception exception) { log.LogError(exception, "Error validating bearer token"); } } } return null; } // Helper function to construct an MSAL account ID from the // claims in the token. MSAL uses an ID in the format // oid.tid, where oid is the object ID of the user, and tid is // the tenant ID. private static string GetMsalAccountId(ClaimsPrincipal principal) { var objectId = principal?.FindFirst("oid"); if (objectId == null) { objectId = principal?.FindFirst( "http://schemas.microsoft.com/identity/claims/objectidentifier"); } var tenantId = principal?.FindFirst("tid"); if (tenantId == null) { tenantId = principal?.FindFirst( "http://schemas.microsoft.com/identity/claims/tenantid"); } if (objectId != null && tenantId != null) { return $"{objectId.Value}.{tenantId.Value}"; } return null; } } }
考虑此代码执行哪些功能。
- 它确保 标头中具有一个 bearer
Authorization
标记。 - 它验证 Azure 已发布 OpenID 配置中的签名和颁发者。
- 它验证声明 (
aud
是否) Azure Function 的应用程序 ID 匹配。 - 它分析令牌并生成 MSAL 帐户 ID,这是利用令牌缓存所需要的。
创建代表身份验证提供程序
在身份验证目录中新建一个名为 OnBehalfOfAuthProvider.cs 的文件,并添加以下代码到该文件。
using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Identity.Client; using System; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public class OnBehalfOfAuthProvider : IAuthenticationProvider { private IConfidentialClientApplication _msalClient; private TokenValidationResult _tokenResult; private string[] _scopes; private ILogger _logger; public OnBehalfOfAuthProvider( IConfidentialClientApplication msalClient, TokenValidationResult tokenResult, string[] scopes, ILogger logger) { _scopes = scopes; _logger = logger; _tokenResult = tokenResult; _msalClient = msalClient; } public async Task<string> GetAccessToken() { try { // First attempt to get token from the cache for this user // Check for a matching account in the cache var account = await _msalClient.GetAccountAsync(_tokenResult.MsalAccountId); if (account != null) { // Make a "silent" request for a token. This will // return the cached token if still valid, and will handle // refreshing the token if needed var cacheResult = await _msalClient .AcquireTokenSilent(_scopes, account) .ExecuteAsync(); _logger.LogInformation($"User access token: {cacheResult.AccessToken}"); return cacheResult.AccessToken; } } catch (MsalUiRequiredException) { // This exception indicates that a new token // can only be obtained by invoking the on-behalf-of // flow. "UiRequired" isn't really accurate since the OBO // flow doesn't involve UI. // Catching the exception so code will continue to the // AcquireTokenOnBehalfOf call below. } catch (Exception exception) { _logger.LogError(exception, "Error getting access token via on-behalf-of flow"); return null; } try { _logger.LogInformation("Token not found in cache, attempting OBO flow"); // Use the token sent by the calling client as a // user assertion var userAssertion = new UserAssertion(_tokenResult.Token); // Invoke on-behalf-of flow var result = await _msalClient .AcquireTokenOnBehalfOf(_scopes, userAssertion) .ExecuteAsync(); _logger.LogInformation($"User access token: {result.AccessToken}"); return result.AccessToken; } catch (Exception exception) { _logger.LogError(exception, "Error getting access token from cache"); return null; } } // This is the delegate called by the GraphServiceClient on each // request. public async Task AuthenticateRequestAsync(HttpRequestMessage requestMessage) { // Get the current access token var token = await GetAccessToken(); // Add the token in the Authorization header requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } }
花些时间考虑 OnBehalfOfAuthProvider.cs 中的代码 有什么功能。
- 在 函数
GetAccessToken
中,它首先尝试使用 从令牌缓存获取用户令牌AcquireTokenSilent
。 如果此操作失败,它将使用测试应用发送到 Azure Function 的 bearer 令牌生成用户断言。 然后,它使用该用户断言获取Graph兼容的令牌AcquireTokenOnBehalfOf
。 - 它实现 接口
Microsoft.Graph.IAuthenticationProvider
,从而允许在GraphServiceClient
的构造函数中传递此类以对传出请求进行身份验证。
实现Graph客户端服务
在此部分中,你将实现可注册用于依赖 关系注入的服务。 该服务将用于获取经过身份验证Graph客户端。
在 GraphTu一 l 目录中新建一个名为"服务" 的目录。
在"服务"目录中新建一个名为 IGraphClientService.cs 的文件,然后向该文件中添加以下代码。
using GraphTutorial.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Graph; namespace GraphTutorial.Services { public interface IGraphClientService { GraphServiceClient GetUserGraphClient( TokenValidationResult validation, string[] scopes, ILogger logger); GraphServiceClient GetAppGraphClient(ILogger logger); } }
在"服务"目录中新建一个名为 GraphClientService.cs 的文件,然后向该文件中添加以下代码。
using GraphTutorial.Authentication; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using Microsoft.Graph; namespace GraphTutorial.Services { // Service added via dependency injection // Used to get an authenticated Graph client public class GraphClientService : IGraphClientService { } }
将以下属性添加到 类
GraphClientService
。// Configuration private IConfiguration _config; // Single MSAL client object used for all user-related // requests. Making this a "singleton" here because the sample // uses the default in-memory token cache. private IConfidentialClientApplication _userMsalClient;
将以下函数添加到 类
GraphClientService
。public GraphClientService(IConfiguration config) { _config = config; } public GraphServiceClient GetUserGraphClient(TokenValidationResult validation, string[] scopes, ILogger logger) { // Only create the MSAL client once if (_userMsalClient == null) { _userMsalClient = ConfidentialClientApplicationBuilder .Create(_config["apiFunctionId"]) .WithAuthority(AadAuthorityAudience.AzureAdMyOrg, true) .WithTenantId(_config["tenantId"]) .WithClientSecret(_config["apiFunctionSecret"]) .Build(); } // Create a new OBO auth provider for the specific user var authProvider = new OnBehalfOfAuthProvider(_userMsalClient, validation, scopes, logger); // Return a GraphServiceClient initialized with the auth provider return new GraphServiceClient(authProvider); }
为 函数添加占位符
GetAppGraphClient
实现。 您将在稍后部分中实现此方案。public GraphServiceClient GetAppGraphClient() { throw new System.NotImplementedException(); }
函数
GetUserGraphClient
获取令牌验证的结果,并生成一个经过身份验证GraphServiceClient
的用户。打开 ./GraphTu一l/Program.cs ,并将其内容替换为以下内容。
此代码将用户密码添加到配置 ,并启用 Azure 函数中的依赖项注入,从而公开
GraphClientService
服务。
实现 GetMyNewestMessage 函数
打开 ./GraphTu一l/GetMyNewestMessage.cs ,并将其全部内容替换为以下内容。
using GraphTutorial.Authentication; using GraphTutorial.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Threading.Tasks; using System.Web.Http; namespace GraphTutorial { public class GetMyNewestMessage { private IConfiguration _config; private IGraphClientService _clientService; public GetMyNewestMessage(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [FunctionName("GetMyNewestMessage")] public async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log) { // Check configuration if (string.IsNullOrEmpty(_config["apiFunctionId"]) || string.IsNullOrEmpty(_config["apiFunctionSecret"]) || string.IsNullOrEmpty(_config["tenantId"])) { log.LogError("Invalid app settings configured"); return new InternalServerErrorResult(); } // Validate the bearer token var validationResult = await TokenValidation.ValidateAuthorizationHeader( req, _config["tenantId"], _config["apiFunctionId"], log); // If token wasn't returned it isn't valid if (validationResult == null) { return new UnauthorizedResult(); } // Initialize a Graph client for this user var graphClient = _clientService.GetUserGraphClient(validationResult, new[] { "https://graph.microsoft.com/.default" }, log); // Get the user's newest message in inbox // GET /me/mailfolders/inbox/messages var messagePage = await graphClient.Me .MailFolders .Inbox .Messages .Request() // Limit the fields returned .Select(m => new { m.From, m.ReceivedDateTime, m.Subject }) // Sort by received time, newest on top .OrderBy("receivedDateTime DESC") // Only get back one message .Top(1) .GetAsync(); if (messagePage.CurrentPage.Count < 1) { return new OkObjectResult(null); } // Return the message in the response return new OkObjectResult(messagePage.CurrentPage[0]); } } }
查看 GetMyNewestMessage.cs 中的代码
花些时间考虑 GetMyNewestMessage.cs 中的代码有什么功能。
- 在构造函数中,它保存
IConfiguration``IGraphClientService
通过依赖关系注入传入的 和 对象。 - 在 函数
Run
中,它执行以下操作:- 验证对象中是否包含所需的配置
IConfiguration
值。 - 验证 bearer 令牌,如果令牌
401
无效,则返回状态代码。 - 从 Graph
GraphClientService
请求的用户获取一个客户端。 - 使用 Microsoft Graph SDK 从用户收件箱获取最新邮件,并作为响应中的 JSON 正文返回。
- 验证对象中是否包含所需的配置
从测试应用调用 Azure 函数
打开 auth.js 并添加以下函数,获取访问令牌。
async function getToken() { let account = sessionStorage.getItem('msal-userName'); if (!account){ throw new Error( 'User account missing from session. Please sign out and sign in again.'); } try { // First, attempt to get the token silently const silentRequest = { scopes: msalRequest.scopes, account: msalClient.getAccountByUsername(account) }; const silentResult = await msalClient.acquireTokenSilent(silentRequest); return silentResult.accessToken; } catch (silentError) { // If silent requests fails with InteractionRequiredAuthError, // attempt to get the token interactively if (silentError instanceof msal.InteractionRequiredAuthError) { const interactiveResult = await msalClient.acquireTokenPopup(msalRequest); return interactiveResult.accessToken; } else { throw silentError; } } }
考虑此代码执行哪些功能。
- 它首先尝试以静默方式获取访问令牌,而无需用户交互。 由于用户应该已经登录,因此 MSAL 的缓存中应包含用户令牌。
- 如果失败并出现错误,指示用户需要交互,它将尝试以交互方式获取令牌。
提示
可以在 中https://jwt.ms
aud
分析访问令牌,并确认声明是 Azure Functionscp
的应用 ID,并且声明包含 Azure Function 的权限范围,而不是 Microsoft Graph。在 TestClient 目录中新建一个名为 azurefunctions.js 并添加以下代码。
async function getLatestMessage() { const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } try { const response = await fetch('http://localhost:7071/api/GetMyNewestMessage', { headers: { Authorization: `Bearer ${token}` } }); const message = await response.json(); updatePage(Views.message, message); } catch (error) { updatePage(Views.error, { message: 'Error getting message', debug: error }); } }
将 CLI 中的当前目录更改为 ./GraphTu一l 目录,并运行以下命令以在本地启动 Azure Function。
func start
如果尚未提供 SPA,请打开第二个 CLI 窗口,将当前目录更改为 ./TestClient 目录。 运行以下命令以运行测试应用程序。
dotnet serve -h "Cache-Control: no-cache, no-store, must-revalidate"
打开浏览器,并导航到
http://localhost:8080
。 登录并选择"最新 邮件" 导航项。 应用程序显示有关用户收件箱中最新邮件的信息。
使用客户端凭据身份验证实现 webhook
在此练习中,你将完成实现 Azure 函数SetSubscription``Notify
和 ,并更新测试应用程序以订阅和取消订阅用户收件箱中的更改。
这两个函数都将使用客户端凭据授予流获取仅应用令牌,以调用 Microsoft Graph。 由于管理员授予了对所需权限范围的管理员同意,因此无需用户交互来获取令牌。
将客户端凭据身份验证添加到 Azure Functions 项目
在此部分中,你将在 Azure Functions 项目中实现客户端凭据流,以获得与 Microsoft Graph。
在包含 GraphTu一一l.csproj 的目录中打开 CLI。
使用下列命令将 webhook 应用程序 ID 和密码添加到密码存储。 将
YOUR_WEBHOOK_APP_ID_HERE
替换为 Azure Function Webhook Graph ID。 将YOUR_WEBHOOK_APP_SECRET_HERE
替换为你在 Azure 门户中为 Azure Function Webhook Graph密码。dotnet user-secrets set webHookId "YOUR_WEBHOOK_APP_ID_HERE" dotnet user-secrets set webHookSecret "YOUR_WEBHOOK_APP_SECRET_HERE"
创建客户端凭据身份验证提供程序
在名为 ClientCredentialsAuthProvider.cs 的 ./GraphTu一l/Authentication 目录中创建新文件并添加以下代码。
using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Identity.Client; using System; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public class ClientCredentialsAuthProvider : IAuthenticationProvider { private IConfidentialClientApplication _msalClient; private string[] _scopes; private ILogger _logger; public ClientCredentialsAuthProvider( string appId, string clientSecret, string tenantId, string[] scopes, ILogger logger) { _scopes = scopes; _logger = logger; _msalClient = ConfidentialClientApplicationBuilder .Create(appId) .WithAuthority(AadAuthorityAudience.AzureAdMyOrg, true) .WithTenantId(tenantId) .WithClientSecret(clientSecret) .Build(); } public async Task<string> GetAccessToken() { try { // Invoke client credentials flow // NOTE: This will return a cached token if a valid one // exists var result = await _msalClient .AcquireTokenForClient(_scopes) .ExecuteAsync(); _logger.LogInformation($"App-only access token: {result.AccessToken}"); return result.AccessToken; } catch (Exception exception) { _logger.LogError(exception, "Error getting access token via client credentials flow"); return null; } } // This is the delegate called by the GraphServiceClient on each // request. public async Task AuthenticateRequestAsync(HttpRequestMessage requestMessage) { // Get the current access token var token = await GetAccessToken(); // Add the token in the Authorization header requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } }
花些时间考虑 ClientCredentialsAuthProvider.cs 中的代码有什么功能。
- 在构造函数中,它从程序包初始化 ConfidentialClientApplication
Microsoft.Identity.Client
。 它使用WithAuthority(AadAuthorityAudience.AzureAdMyOrg, true)
和.WithTenantId(tenantId)
函数将登录访问群体限制为仅指定Microsoft 365访问群体。 GetAccessToken
在 函数中,它调用AcquireTokenForClient
获取应用程序的令牌。 客户端凭据令牌流始终非交互。- 它实现 接口
Microsoft.Graph.IAuthenticationProvider
,从而允许在GraphServiceClient
的构造函数中传递此类以对传出请求进行身份验证。
更新 GraphClientService
打开 GraphClientService.cs ,将以下属性添加到 类。
private GraphServiceClient _appGraphClient;
将现有的
GetAppGraphClient
函数替换为以下内容。public GraphServiceClient GetAppGraphClient(ILogger logger) { if (_appGraphClient == null) { // Create a client credentials auth provider var authProvider = new ClientCredentialsAuthProvider( _config["webHookId"], _config["webHookSecret"], _config["tenantId"], // The https://graph.microsoft.com/.default scope // is required for client credentials. It requests // all of the permissions that are explicitly set on // the app registration new[] { "https://graph.microsoft.com/.default" }, logger); _appGraphClient = new GraphServiceClient(authProvider); } return _appGraphClient; }
实现 Notify 函数
在此部分中,你将实现 Notify
函数,它将用作更改通知的通知 URL。
在 GraphTu一ls 目录中新建一个名为 Models 的目录。
在 Models 目录中新建 一个名为 ResourceData.cs 的文件 并添加以下代码。
namespace GraphTutorial.Models { // Class to represent the resourceData object // inside a change notification public class ResourceData { public string Id { get;set; } } }
在 Models 目录中新建一个名为 ChangeNotificationPayload.cs 的文件并添加以下代码。
在 Models 目录中新建 一个名为 NotificationList.cs 的文件 并添加以下代码。
namespace GraphTutorial.Models { // Class representing an array of notifications // in a notification payload public class NotificationList { public ChangeNotification[] Value { get;set; } } }
打开 ./GraphTu一l/Notify.cs ,并将其全部内容替换为以下内容。
using GraphTutorial.Models; using GraphTutorial.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; using System.IO; using System.Text.Json; using System.Threading.Tasks; using System.Web.Http; namespace GraphTutorial { public class Notify { public static readonly string ClientState = "GraphTutorialState"; private IConfiguration _config; private IGraphClientService _clientService; public Notify(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [FunctionName("Notify")] public async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, ILogger log) { // Check configuration if (string.IsNullOrEmpty(_config["webHookId"]) || string.IsNullOrEmpty(_config["webHookSecret"]) || string.IsNullOrEmpty(_config["tenantId"])) { log.LogError("Invalid app settings configured"); return new InternalServerErrorResult(); } // Is this a validation request? // https://docs.microsoft.com/graph/webhooks#notification-endpoint-validation string validationToken = req.Query["validationToken"]; if (!string.IsNullOrEmpty(validationToken)) { // Because validationToken is a string, OkObjectResult // will return a text/plain response body, which is // required for validation return new OkObjectResult(validationToken); } // Not a validation request, process the body var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); log.LogInformation($"Change notification payload: {requestBody}"); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Deserialize the JSON payload into a list of ChangeNotification // objects var notifications = JsonSerializer.Deserialize<NotificationList>(requestBody, jsonOptions); foreach (var notification in notifications.Value) { if (notification.ClientState == ClientState) { // Process each notification await ProcessNotification(notification, log); } else { log.LogInformation($"Notification received with unexpected client state: {notification.ClientState}"); } } // Return 202 per docs return new AcceptedResult(); } private async Task ProcessNotification(ChangeNotification notification, ILogger log) { var graphClient = _clientService.GetAppGraphClient(log); // The resource field in the notification has the URL to the // message, including the user ID and message ID. Since we // have the URL, use a MessageRequestBuilder instead of the fluent // API var msgRequestBuilder = new MessageRequestBuilder( $"https://graph.microsoft.com/v1.0/{notification.Resource}", graphClient); var message = await msgRequestBuilder.Request() .Select(m => new { m.Subject }) .GetAsync(); log.LogInformation($"The following message was {notification.ChangeType}:"); log.LogInformation($"Subject: {message.Subject}, ID: {message.Id}"); } } }
花些时间考虑 Notify.cs 中的代码 功能。
- 该
Run
函数检查是否存在查询validationToken
参数。 如果该参数存在,它会将请求作为 验证请求处理,并相应地做出响应。 - 如果请求不是验证请求,JSON 有效负载会反作用于
ChangeNotificationCollection
。 - 检查列表中的每个通知的预期客户端状态值,并进行处理。
- 触发通知的邮件通过 Microsoft Graph。
实现 SetSubscription 函数
在此部分中,您将实现 SetSubscription 函数。 此函数将充当由测试应用程序调用以在用户收件箱上创建或删除订阅的 API。
在 Models 目录中新建一个名为 SetSubscriptionPayload.cs 的文件并添加以下代码。
namespace GraphTutorial.Models { // Class to represent the payload sent to the // SetSubscription function public class SetSubscriptionPayload { // "subscribe" or "unsubscribe" public string RequestType { get;set; } // If unsubscribing, the subscription to delete public string SubscriptionId { get;set; } // If subscribing, the user ID to subscribe to // Can be object ID of user, or userPrincipalName public string UserId { get;set; } } }
打开 ./GraphTu一l/SetSubscription.cs ,并将其全部内容替换为以下内容。
using GraphTutorial.Authentication; using GraphTutorial.Models; using GraphTutorial.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; using System; using System.IO; using System.Text.Json; using System.Threading.Tasks; using System.Web.Http; namespace GraphTutorial { public class SetSubscription { private IConfiguration _config; private IGraphClientService _clientService; public SetSubscription(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [FunctionName("SetSubscription")] public async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, ILogger log) { // Check configuration if (string.IsNullOrEmpty(_config["webHookId"]) || string.IsNullOrEmpty(_config["webHookSecret"]) || string.IsNullOrEmpty(_config["tenantId"]) || string.IsNullOrEmpty(_config["apiFunctionId"])) { log.LogError("Invalid app settings configured"); return new InternalServerErrorResult(); } var notificationHost = _config["ngrokUrl"]; if (string.IsNullOrEmpty(notificationHost)) { notificationHost = req.Host.Value; } // Validate the bearer token var validationResult = await TokenValidation.ValidateAuthorizationHeader( req, _config["tenantId"], _config["apiFunctionId"], log); // If token wasn't returned it isn't valid if (validationResult == null) { return new UnauthorizedResult(); } var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Deserialize the JSON payload into a SetSubscriptionPayload object var payload = JsonSerializer.Deserialize<SetSubscriptionPayload>(requestBody, jsonOptions); if (payload == null) { return new BadRequestErrorMessageResult("Invalid request payload"); } // Initialize Graph client var graphClient = _clientService.GetAppGraphClient(log); if (payload.RequestType.ToLower() == "subscribe") { if (string.IsNullOrEmpty(payload.UserId)) { return new BadRequestErrorMessageResult("Required fields in payload missing"); } // Create a new subscription object var subscription = new Subscription { ChangeType = "created,updated", NotificationUrl = $"{notificationHost}/api/Notify", Resource = $"/users/{payload.UserId}/mailfolders/inbox/messages", ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(2), ClientState = Notify.ClientState }; // POST /subscriptions var createdSubscription = await graphClient.Subscriptions .Request() .AddAsync(subscription); return new OkObjectResult(createdSubscription); } else { if (string.IsNullOrEmpty(payload.SubscriptionId)) { return new BadRequestErrorMessageResult("Subscription ID missing in payload"); } // DELETE /subscriptions/subscriptionId await graphClient.Subscriptions[payload.SubscriptionId] .Request() .DeleteAsync(); return new AcceptedResult(); } } } }
花些时间考虑 SetSubscription.cs 中的代码 有什么功能。
- 函数
Run
读取 POST 请求中发送的 JSON 有效负载,以确定请求类型 (订阅或取消订阅) 、要订阅的用户 ID 以及要取消订阅的订阅 ID。 - 如果请求是订阅请求,它将使用 Microsoft Graph SDK 在指定用户的收件箱中创建新订阅。 订阅将在创建或更新邮件时通知。 新订阅在响应的 JSON 有效负载中返回。
- 如果请求是取消订阅请求,它将使用 Microsoft Graph SDK 删除指定的订阅。
从测试应用调用 SetSubscription
在此部分中,你将实现用于创建和删除测试应用中的订阅的函数。
打开 ./TestClient/azurefunctions.js 并添加以下函数。
async function createSubscription() { // Get the user to subscribe for const userId = document.getElementById('subscribe-user').value; if (!userId) { updatePage(Views.error, { message: 'Please provide a user ID or userPrincipalName' }); return; } const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } // Build the JSON payload for the subscribe request const payload = { requestType: 'subscribe', userId: userId }; const response = await fetch('http://localhost:7071/api/SetSubscription', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); if (response.ok) { // Get the new subscription from the response const subscription = await response.json(); // Add the new subscription to the array of subscriptions // in the session let existingSubscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')) || []; existingSubscriptions.push({ userId: userId, subscriptionId: subscription.id }); sessionStorage.setItem('graph-subscriptions', JSON.stringify(existingSubscriptions)); // Refresh the subscriptions page to display the new // subscription updatePage(Views.subscriptions); return; } updatePage(Views.error, { message: `Call to SetSubscription returned ${response.status}`, debug: response.statusText }); }
此代码调用
SetSubscription
Azure 函数来订阅并将新订阅添加到会话中的订阅数组。将以下函数添加到 azurefunctions.js。
async function deleteSubscription(subscriptionId) { const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } // Build the JSON payload for the unsubscribe request const payload = { requestType: 'unsubscribe', subscriptionId: subscriptionId }; const response = await fetch('http://localhost:7071/api/SetSubscription', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); if (response.ok) { // Remove the subscription from the array let existingSubscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')) || []; const subscriptionIndex = existingSubscriptions.findIndex((item) => { return item.subscriptionId === subscriptionId; }); existingSubscriptions.splice(subscriptionIndex, 1); sessionStorage.setItem('graph-subscriptions', JSON.stringify(existingSubscriptions)); // Refresh the subscriptions page updatePage(Views.subscriptions); return; } updatePage(Views.error, { message: `Call to SetSubscription returned ${response.status}`, debug: response.statusText }); }
此代码调用
SetSubscription
Azure 函数取消订阅并从会话中的订阅数组中删除订阅。如果没有运行 ngrok,请运行 ngrok
ngrok http 7071
() 复制 HTTPS 转发 URL。通过运行以下命令,将 ngrok URL 添加到用户密码存储。
dotnet user-secrets set ngrokUrl "YOUR_NGROK_URL_HERE"
重要
如果重新启动 ngrok,则需要重复此命令以更新 ngrok URL。
将 CLI 中的当前目录更改为 ./GraphTu一l 目录,并运行以下命令以在本地启动 Azure Function。
func start
刷新 SPA 并选择" 订阅" 导航项。 输入组织中拥有邮箱的用户Microsoft 365用户EXCHANGE ONLINE ID。 这可以是来自
id
Microsoft (的用户Graph) 或用户的userPrincipalName
。 单击 "订阅"。页面将刷新,在表中显示新订阅。
向用户发送电子邮件。 过一小段时间,
Notify
应调用 函数。 可以在 ngrok Web 界面http://localhost:4040
() Azure Function 项目的调试输出中对此进行验证。... [7/8/2020 7:33:57 PM] The following message was created: [7/8/2020 7:33:57 PM] Subject: Hi Megan!, ID: AAMkAGUyN2I4N2RlLTEzMTAtNDBmYy1hODdlLTY2NTQwODE2MGEwZgBGAAAAAAA2J9QH-DvMRK3pBt_8rA6nBwCuPIFjbMEkToHcVnQirM5qAAAAAAEMAACuPIFjbMEkToHcVnQirM5qAACHmpAsAAA= [7/8/2020 7:33:57 PM] Executed 'Notify' (Succeeded, Id=9c40af0b-e082-4418-aa3a-aee624f30e7a) ...
在测试应用中,单击 订阅 的表行中的"删除"。 页面将刷新,并且订阅不再位于表中。
准备发布到 Azure
在此练习中,你将了解示例 Azure 函数需要哪些更改来准备 发布到 Azure Functions 应用。
更新代码
配置从用户密码存储中读取,仅适用于你的开发计算机。 在发布到 Azure 之前,你需要更改存储配置的位置,并相应地更新 Program.cs 中的代码。
应用程序密钥应存储在安全存储中,如 Azure 密钥保管库。
更新 Azure 函数的 CORS 设置
在此示例中,我们在 local.settings.json 中配置 CORS 以允许测试应用程序调用 函数。 您需要配置已发布函数以允许任何将调用函数的 SPA 应用。
更新应用注册
Azure knownClientApplications
Function 应用注册Graph清单中的 属性将需要使用将调用 Azure Function 的任何应用的应用程序 ID 进行更新。
重新创建现有订阅
使用本地计算机或 ngrok 上的 webhook URL 创建的任何订阅都应使用 Azure Function 的生产 URL Notify
重新创建。
恭喜!
你已完成 Azure Functions Microsoft Graph教程。 现在,你已经拥有一个调用 Microsoft Graph,可以试验并添加新功能。 请访问 Microsoft Graph概述,查看可以使用 Microsoft Graph 访问的所有数据。
反馈
Please provide any feedback on this tutorial in the GitHub repository.
你有关于此部分的问题? 如果有,请向我们提供反馈,以便我们对此部分作出改进。