在此练习中,你将扩展上一练习中的应用程序,以支持使用 Azure AD 的单一登录身份验证。In this exercise you will extend the application from the previous exercise to support single sign-on authentication with Azure AD. 这是获取调用 Microsoft Graph API 所需的 OAuth 访问令牌所必需的。This is required to obtain the necessary OAuth access token to call the Microsoft Graph API. 在此步骤中,您将配置 Microsoft.Identity.Web 库。In this step you will configure the Microsoft.Identity.Web library.

重要

若要避免在源中存储应用程序 ID 和密码,将使用 .NET 密码管理器 存储这些值。To avoid storing the application ID and secret in source, you will use the .NET Secret Manager to store these values. 密码管理器仅供开发使用,生产应用应使用受信任的密码管理器来存储密码。The Secret Manager is for development purposes only, production apps should use a trusted secret manager for storing secrets.

  1. 打开 ./appsettings.js, 并将其内容替换为以下内容。Open ./appsettings.json and replace its contents with the following.

    {
      "AzureAd": {
        "Instance": "https://login.microsoftonline.com/",
        "TenantId": "common"
      },
      "Graph": {
        "Scopes": "https://graph.microsoft.com/.default"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft": "Warning",
          "Microsoft.Hosting.Lifetime": "Information"
        }
      },
      "AllowedHosts": "*"
    }
    
  2. GraphTu一l.csproj 所在的目录中打开 CLI,然后运行以下命令,使用 Azure 门户中的应用程序 ID 和应用程序密码进行代用。 YOUR_APP_ID YOUR_APP_SECRETOpen your CLI in the directory where GraphTutorial.csproj is located, and run the following commands, substituting YOUR_APP_ID with your application ID from the Azure portal, and YOUR_APP_SECRET with your application secret.

    dotnet user-secrets init
    dotnet user-secrets set "AzureAd:ClientId" "YOUR_APP_ID"
    dotnet user-secrets set "AzureAd:ClientSecret" "YOUR_APP_SECRET"
    

实现登录Implement sign-in

首先,在应用的 JavaScript 代码中实现单一登录。First, implement single sign-on in the app's JavaScript code. 你将使用 Microsoft Teams JavaScript SDK 获取访问令牌,该令牌允许在 Teams 客户端中运行的 JavaScript 代码对将稍后实现的 Web API 进行 AJAX 调用。You will use the Microsoft Teams JavaScript SDK to get an access token which allows the JavaScript code running in the Teams client to make AJAX calls to Web API you will implement later.

  1. 打开 ./Pages/Index.cshtml, 在标记中添加以下 <script> 代码。Open ./Pages/Index.cshtml and add the following code inside the <script> tag.

    (function () {
      if (microsoftTeams) {
        microsoftTeams.initialize();
    
        microsoftTeams.authentication.getAuthToken({
          successCallback: (token) => {
            // TEMPORARY: Display the access token for debugging
            $('#tab-container').empty();
    
            $('<code/>', {
              text: token,
              style: 'word-break: break-all;'
            }).appendTo('#tab-container');
          },
          failureCallback: (error) => {
            renderError(error);
          }
        });
      }
    })();
    
    function renderError(error) {
      $('#tab-container').empty();
    
      $('<h1/>', {
        text: 'Error'
      }).appendTo('#tab-container');
    
      $('<code/>', {
        text: JSON.stringify(error, Object.getOwnPropertyNames(error)),
        style: 'word-break: break-all;'
      }).appendTo('#tab-container');
    }
    

    这将调用 microsoftTeams.authentication.getAuthToken 以登录 Teams 的用户身份以静默方式进行身份验证。This calls the microsoftTeams.authentication.getAuthToken to silently authenticate as the user that is signed in to Teams. 通常不会涉及到任何 UI 提示,除非用户必须同意。There is typically not any UI prompts involved, unless the user has to consent. 然后,代码在选项卡中显示令牌。The code then displays the token in the tab.

  2. 在 CLI 中运行以下命令,保存更改并启动应用程序。Save your changes and start your application by running the following command in your CLI.

    dotnet run
    

    重要

    如果已重新启动 ngrok 且 ngrok URL 已更改,请务必在测试之前在下列位置 更新 ngrok 值。If you have restarted ngrok and your ngrok URL has changed, be sure to update the ngrok value in the following place before you test.

    • 应用注册中的重定向 URIThe redirect URI in your app registration
    • 应用注册中的应用程序 ID URIThe application ID URI in your app registration
    • contentUrl in manifest.jsoncontentUrl in manifest.json
    • validDomains in manifest.jsonvalidDomains in manifest.json
    • resource in manifest.jsonresource in manifest.json
  3. 创建 ZIP 文件 ,manifest.js、color.png****和 outline.png。 ****Create a ZIP file with manifest.json, color.png, and outline.png.

  4. 在 Microsoft Teams中,选择左侧栏中的应用,选择"上传自定义应用",然后选择"为我或 我的团队上载"。In Microsoft Teams, select Apps in the left-hand bar, select Upload a custom app, then select Upload for me or my teams.

    Microsoft Teams 中"上传自定义应用"链接的屏幕截图

  5. 浏览到之前创建的 ZIP 文件,然后选择"打开"。Browse to the ZIP file you created previously and select Open.

  6. 查看应用程序信息,然后选择"添加 "。Review the application information and select Add.

  7. 应用程序将在 Teams 中打开并显示访问令牌。The application opens in Teams and displays an access token.

如果复制令牌,可以将其粘贴到jwt.ms。If you copy the token, you can paste it into jwt.ms. 确认声明 (访问群体) 应用程序 ID,并且声明 (范围) 是创建的 aud scp API access_as_user 范围。Verify that the audience (the aud claim) is your application ID, and the only scope (the scp claim) is the access_as_user API scope you created. 这意味着此令牌不会授予对 Microsoft Graph 的直接访问权!That means that this token does not grant direct access to Microsoft Graph! 相反,您即将实现的 Web API 将需要使用代表流交换此令牌,以获得将用于 Microsoft Graph 调用的令牌。Instead, the Web API you will implement soon will need to exchange this token using the on-behalf-of flow to get a token that will work with Microsoft Graph calls.

在核心应用中ASP.NET身份验证Configure authentication in the ASP.NET Core app

首先,将 Microsoft Identity 平台服务添加到应用程序。Start by adding the Microsoft Identity platform services to the application.

  1. 打开 ./Startup.cs 文件,将以下 using 语句添加到文件顶部。Open the ./Startup.cs file and add the following using statement to the top of the file.

    using Microsoft.Identity.Web;
    
  2. 在函数中的行之前 app.UseAuthorization(); 添加以下 Configure 行。Add the following line just before the app.UseAuthorization(); line in the Configure function.

    app.UseAuthentication();
    
  3. 在函数中的行后 endpoints.MapRazorPages(); 添加以下 Configure 行。Add the following line just after the endpoints.MapRazorPages(); line in the Configure function.

    endpoints.MapControllers();
    
  4. 将现有的 ConfigureServices 函数替换为以下内容。Replace the existing ConfigureServices function with the following.

    public void ConfigureServices(IServiceCollection services)
    {
        // Use Web API authentication (default JWT bearer token scheme)
        services.AddMicrosoftIdentityWebApiAuthentication(Configuration)
            // Enable token acquisition via on-behalf-of flow
            .EnableTokenAcquisitionToCallDownstreamApi()
            // Specify that the down-stream API is Graph
            .AddMicrosoftGraph(Configuration.GetSection("Graph"))
            // Use in-memory token cache
            // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization
            .AddInMemoryTokenCaches();
    
        services.AddRazorPages();
        services.AddControllers().AddNewtonsoftJson();
    }
    

    此代码将应用程序配置为允许对 Web API 的调用基于标头中的 JWT 承载令牌 Authorization 进行身份验证。This code configures the application to allow calls to Web APIs to be authenticated based on the JWT bearer token in the Authorization header. 它还添加了令牌获取服务,这些服务可以通过代表流交换该令牌。It also adds the token acquisition services that can exchange that token via the on-behalf-of flow.

创建 Web API 控制器Create the Web API controller

  1. 在名为 Controllers 的项目的根目录中创建新 目录Create a new directory in the root of the project named Controllers.

  2. 在名为 CalendarController.cs 的 ./Controllers 目录中 创建新文件,并添加以下代码。Create a new file in the ./Controllers directory named CalendarController.cs and add the following code.

    using System;
    using System.Collections.Generic;
    using System.Net;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Logging;
    using Microsoft.Identity.Web;
    using Microsoft.Identity.Web.Resource;
    using Microsoft.Graph;
    using TimeZoneConverter;
    
    namespace GraphTutorial.Controllers
    {
        [ApiController]
        [Route("[controller]")]
        [Authorize]
        public class CalendarController : ControllerBase
        {
            private static readonly string[] apiScopes = new[] { "access_as_user" };
    
            private readonly GraphServiceClient _graphClient;
            private readonly ITokenAcquisition _tokenAcquisition;
            private readonly ILogger<CalendarController> _logger;
    
            public CalendarController(ITokenAcquisition tokenAcquisition, GraphServiceClient graphClient, ILogger<CalendarController> logger)
            {
                _tokenAcquisition = tokenAcquisition;
                _graphClient = graphClient;
                _logger = logger;
            }
    
            [HttpGet]
            public async Task<string> Get()
            {
                // This verifies that the access_as_user scope is
                // present in the bearer token, throws if not
                HttpContext.VerifyUserHasAnyAcceptedScope(apiScopes);
    
                // To verify that the identity libraries have authenticated
                // based on the token, log the user's name
                _logger.LogInformation($"Authenticated user: {User.GetDisplayName()}");
    
                try
                {
                    // TEMPORARY
                    // Get a Graph token via OBO flow
                    var token = await _tokenAcquisition
                        .GetAccessTokenForUserAsync(new[]{
                            "User.Read",
                            "MailboxSettings.Read",
                            "Calendars.ReadWrite" });
    
                    // Log the token
                    _logger.LogInformation($"Access token for Graph: {token}");
                    return "{ \"status\": \"OK\" }";
                }
                catch (MicrosoftIdentityWebChallengeUserException ex)
                {
                    _logger.LogError(ex, "Consent required");
                    // This exception indicates consent is required.
                    // Return a 403 with "consent_required" in the body
                    // to signal to the tab it needs to prompt for consent
                    HttpContext.Response.ContentType = "text/plain";
                    HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
                    await HttpContext.Response.WriteAsync("consent_required");
                    return null;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error occurred");
                    return null;
                }
            }
        }
    }
    

    这将实现 Web API GET /calendar () 可以从 Teams 选项卡调用。现在,它只是尝试交换 Graph 令牌的记名令牌。This implements a Web API (GET /calendar) that can be called from the Teams tab. For now it simply tries to exchange the bearer token for a Graph token. 用户首次加载选项卡时,此操作将失败,因为他们尚未同意允许应用代表他们访问 Microsoft Graph。The first time a user loads the tab, this will fail because they have not yet consented to allow the app access to Microsoft Graph on their behalf.

  3. 打开 ./Pages/Index.cshtml,successCallback 函数替换为以下内容。Open ./Pages/Index.cshtml and replace the successCallback function with the following.

    successCallback: (token) => {
      // TEMPORARY: Call the Web API
      fetch('/calendar', {
        headers: {
          'Authorization': `Bearer ${token}`
        }
      }).then(response => {
        response.text()
          .then(body => {
            $('#tab-container').empty();
            $('<code/>', {
              text: body
            }).appendTo('#tab-container');
          });
      }).catch(error => {
        console.error(error);
        renderError(error);
      });
    }
    

    这将调用 Web API 并显示响应。This will call the Web API and display the response.

  4. 保存更改并重新启动该应用。Save your changes and restart the app. 刷新 Microsoft Teams 中的选项卡。Refresh the tab in Microsoft Teams. 页面应显示 consent_requiredThe page should display consent_required.

  5. 查看 CLI 中的日志输出。Review the log output in your CLI. 请注意两点。Notice two things.

    • 类似这样的条目 Authenticated user: MeganB@contoso.comAn entry like Authenticated user: MeganB@contoso.com. Web API 已基于随 API 请求发送的令牌对用户进行身份验证。The Web API has authenticated the user based on the token sent with the API request.
    • 类似这样的条目 AADSTS65001: The user or administrator has not consented to use the application with ID...An entry like AADSTS65001: The user or administrator has not consented to use the application with ID.... 这是预期操作,因为尚未提示用户同意请求的 Microsoft Graph 权限范围。This is expected, since the user has not yet been prompted to consent for the requested Microsoft Graph permission scopes.

由于 Web API 无法提示用户,因此 Teams 选项卡将需要实现提示。Because the Web API cannot prompt the user, the Teams tab will need to implement a prompt. 这将只需要为每个用户执行一次。This will only need to be done once for each user. 用户同意后,他们无需重新登录,除非他们明确撤销对您的应用程序的访问权限。Once a user consents, they do not need to reconsent unless they explicitly revoke access to your application.

  1. 在名为 Authenticate.cshtml.cs 的 ./Pages 目录中 创建新文件,并添加以下代码。Create a new file in the ./Pages directory named Authenticate.cshtml.cs and add the following code.

    using Microsoft.AspNetCore.Mvc.RazorPages;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Logging;
    
    namespace GraphTutorial.Pages
    {
        public class AuthenticateModel : PageModel
        {
            private readonly ILogger<IndexModel> _logger;
            public string ApplicationId { get; private set; }
            public string State { get; private set; }
            public string Nonce { get; private set; }
    
            public AuthenticateModel(IConfiguration configuration,
              ILogger<IndexModel> logger)
            {
                _logger = logger;
    
                // Read the application ID from the
                // configuration. This is used to build
                // the authorization URL for the consent prompt
                ApplicationId = configuration
                    .GetSection("AzureAd")
                    .GetValue<string>("ClientId");
    
                // Generate a GUID for state and nonce
                State = System.Guid.NewGuid().ToString();
                Nonce = System.Guid.NewGuid().ToString();
            }
        }
    }
    
  2. ./Pages 目录中新建一个名为 Authenticate.cshtml 的文件并添加以下代码。Create a new file in the ./Pages directory named Authenticate.cshtml and add the following code.

    @page
    <!-- Copyright (c) Microsoft Corporation.
         Licensed under the MIT License. -->
    @model AuthenticateModel
    
    @section Scripts
    {
      <script>
        (function () {
          microsoftTeams.initialize();
    
          // Save the state so it can be verified in
          // AuthComplete.cshtml
          localStorage.setItem('auth-state', '@Model.State');
    
          // Get the context for tenant ID and login hint
          microsoftTeams.getContext((context) => {
            // Set all of the query parameters for an
            // authorization request
            const queryParams = {
              client_id: '@Model.ApplicationId',
              response_type: 'id_token token',
              response_mode: 'fragment',
              scope: 'https://graph.microsoft.com/.default openid',
              redirect_uri: `${window.location.origin}/authcomplete`,
              nonce: '@Model.Nonce',
              state: '@Model.State',
              login_hint: context.loginHint,
            };
    
            // Generate the URL
            const authEndpoint = `https://login.microsoftonline.com/${context.tid}/oauth2/v2.0/authorize?${toQueryString(queryParams)}`;
    
            // Browse to the URL
            window.location.assign(authEndpoint);
          });
        })();
    
        // Helper function to build a query string from an object
        function toQueryString(queryParams) {
          let encodedQueryParams = [];
          for (let key in queryParams) {
            encodedQueryParams.push(key + '=' + encodeURIComponent(queryParams[key]));
          }
          return encodedQueryParams.join('&');
        }
      </script>
    }
    
  3. ./Pages 目录中新建一个名为 AuthComplete.cshtml 的文件并添加以下代码。Create a new file in the ./Pages directory named AuthComplete.cshtml and add the following code.

    @page
    <!-- Copyright (c) Microsoft Corporation.
         Licensed under the MIT License. -->
    
    @section Scripts
    {
      <script>
        (function () {
          microsoftTeams.initialize();
    
          const hashParams = getHashParameters();
          if (hashParams['error']) {
            microsoftTeams.authentication.notifyFailure(hashParams['error']);
          } else if (hashParams['access_token']) {
            // Check the state parameter
            const expectedState = localStorage.getItem('auth-state');
            if (expectedState !== hashParams['state']) {
              microsoftTeams.authentication.notifyFailure('StateDoesNotMatch');
            } else {
              // State parameter matches, report success
              localStorage.removeItem('auth-state');
              microsoftTeams.authentication.notifySuccess('Success');
            }
          } else {
            microsoftTeams.authentication.notifyFailure('NoTokenInResponse');
          }
        })();
    
        // Helper function to generate a hash from
        // a query string
        function getHashParameters() {
          let hashParams = {};
          location.hash.substr(1).split('&').forEach(function(item) {
            let s = item.split('='),
            k = s[0],
            v = s[1] && decodeURIComponent(s[1]);
            hashParams[k] = v;
          });
          return hashParams;
        }
      </script>
    }
    
  4. 打开 ./Pages/Index.cshtml, 在标记中添加以下 <script> 函数。Open ./Pages/Index.cshtml and add the following functions inside the <script> tag.

    function loadUserCalendar(token, callback) {
      // Call the API
      fetch('/calendar', {
        headers: {
          'Authorization': `Bearer ${token}`
        }
      }).then(response => {
        if (response.ok) {
          // Get the JSON payload
          response.json()
            .then(events => {
              callback(events);
            });
        }
        else if (response.status === 403) {
          response.text()
            .then(body => {
              // If the API sent 'consent_required'
              //  we need to prompt the user
              if (body === 'consent_required') {
                promptForConsent((error) => {
                  if (error) {
                    renderError(error);
                  } else {
                    // Retry API call
                    loadUserCalendar(token, callback);
                  }
                });
              }
            });
        }
      }).catch(error => {
        renderError(error);
      });
    }
    
    function promptForConsent(callback) {
      // Cause Teams to popup a window for consent
      microsoftTeams.authentication.authenticate({
        url: `${window.location.origin}/authenticate`,
        width: 600,
        height: 535,
        successCallback: (result) => {
          callback(null);
        },
        failureCallback: (error) => {
          callback(error);
        }
      });
    }
    
  5. 在标记中添加以下函数以显示 Web <script> API 的成功结果。Add the following function inside the <script> tag to display a successful result from the Web API.

    function renderCalendar(events) {
      $('#tab-container').empty();
    
      $('<pre/>').append($('<code/>', {
        text: JSON.stringify(events, null, 2),
        style: 'word-break: break-all;'
      })).appendTo('#tab-container');
    }
    
  6. 将现有 successCallback 代码替换为以下代码。Replace the existing successCallback with the following code.

    successCallback: (token) => {
      loadUserCalendar(token, (events) => {
        renderCalendar(events);
      });
    }
    
  7. 保存更改并重新启动该应用。Save your changes and restart the app. 刷新 Microsoft Teams 中的选项卡。Refresh the tab in Microsoft Teams. 选项卡应显示 { "status": "OK" }The tab should display { "status": "OK" }.

  8. 查看日志输出。Review the log output. 你应该会看到 Access token for Graph 该条目。You should see the Access token for Graph entry. 如果分析该令牌,你会注意到它包含在appsettings.js 中配置的 Microsoft Graph 作用域。If you parse that token, you'll notice that it contains the Microsoft Graph scopes configured in appsettings.json.

存储和刷新令牌Storing and refreshing tokens

此时,应用程序具有访问令牌,该令牌在 API 调用 Authorization 标头中发送。At this point your application has an access token, which is sent in the Authorization header of API calls. 这是允许应用代表用户访问 Microsoft Graph 的令牌。This is the token that allows the app to access Microsoft Graph on the user's behalf.

但是,此令牌是短期的。However, this token is short-lived. 令牌在颁发后一小时过期。The token expires an hour after it is issued. 此时刷新令牌将变得有用。This is where the refresh token becomes useful. 刷新令牌允许应用请求新的访问令牌,而无需用户重新登录。The refresh token allows the app to request a new access token without requiring the user to sign in again.

由于应用使用的是 Microsoft.Identity.Web 库,因此不需要实现任何令牌存储或刷新逻辑。Because the app is using the Microsoft.Identity.Web library, you do not have to implement any token storage or refresh logic.

应用使用内存中令牌缓存,这足以满足应用重启时无需保留令牌的应用。The app uses the in-memory token cache, which is sufficient for apps that do not need to persist tokens when the app restarts. 生产应用可能会改为使用 Microsoft.Identity.Web 库中 的分布式缓存选项。Production apps may instead use the distributed cache options in the Microsoft.Identity.Web library.

GetAccessTokenForUserAsync此方法会处理令牌到期并刷新。The GetAccessTokenForUserAsync method handles token expiration and refresh for you. 它首先检查缓存的令牌,如果它未过期,它将返回它。It first checks the cached token, and if it is not expired, it returns it. 如果已过期,它将使用缓存的刷新令牌获取新的刷新令牌。If it is expired, it uses the cached refresh token to obtain a new one.

控制器通过依赖关系注入获取的 GraphServiceClient 已使用用于你的身份验证提供程序 GetAccessTokenForUserAsync 进行预配置。The GraphServiceClient that controllers get via dependency injection is pre-configured with an authentication provider that uses GetAccessTokenForUserAsync for you.