如何在現有 ASP.NET Core 網站加入 Azure AD 的 OpenId Connect 登入 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何在現有 ASP.NET Core 網站加入 Azure AD 的 OpenId Connect 登入

我們公司採用 Microsoft 365 辦公協作環境,這也意味著背後一定使用了 Azure AD 目錄服務,因此我們可以直接拿 Azure AD 作為公司的 OAuth 2.0 + OpenId Connect (OIDC) 認證與授權平台,因此開發公司內部應用程式就變的非常容易。這篇文章我將說明要將一個 ASP.NET Core 網站加入 Azure AD 的身份驗證流程,以及使用時的注意事項。

建立全新 ASP.NET Core MVC 專案並設定 Azure AD 登入整合 (OpenId Connect)

  1. 先註冊應用程式

    https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps

    依照下圖進行設定,尤其是 Redirect URI 千萬別忘了,網址要先設定為 https://localhost:9001/signin-oidc

    Azure AD - Register an application

    建立好之後,記得要到 Authentication 頁籤勾選 ID tokens 核取項目, 這樣才能在 Implicit Flow 取得使用者身份,這也才能完成 OpenId Connect 的認證流程!

    Azure AD - Authentication

    建立完成後,請記得從 Azure AD 取得 Application (client) ID, Directory (tenant) IDPrimary domain 這三個重要參數!

  2. 建立 ASP.NET Core MVC 專案

    依據步驟 1 取得的三項資訊,拿來建立全新的 MVC 應用程式:

    dotnet new mvc -n AzureADLoginDemo --auth SingleOrg --tenant-id 76275315-xxxx-xxxx-xxxx-a329d6222150 --domain xxxxxx.onmicrosoft.com --client-id 7332902c-xxxx-xxxx-xxxx-63a0dcbf3d80
    

    建立好專案骨架後,先調整專案下的 Properties\launchSettings.json 檔案,將 .profiles.AzureADLoginDemo.applicationUrl 的內容修改為 https://localhost:9001 即可!

  3. 大功告成!

    真的不誇張,就是上面這樣,兩個步驟就可以完成一個 ASP.NET Core 網站整合 Azure AD 的 OpenId Connect 登入!👍

    接著啟動 ASP.NET Core 網站:

    dotnet run
    

    開啟 https://localhost:9001 就會自動轉向到 Azure AD 進行登入,登入成功後就可以看到首頁顯示出畫面了!

    image

如何將現有網站加入 Azure AD 登入整合 (OpenId Connect)

說實在的,程式碼真的也不多,以下是從無到有的設定步驟:

  1. 先註冊應用程式 (設定內容與上一段敘述相同,在此省略說明)

  2. 建立 ASP.NET Core MVC 專案

    dotnet new mvc -n AzureADLoginDemo
    cd AzureADLoginDemo
    code .
    

    建立好專案骨架後,先調整專案下的 Properties\launchSettings.json 檔案,將 .profiles.AzureADLoginDemo.applicationUrl 的內容修改為 https://localhost:9001 即可!

  3. 修改 appsettings.json 組態設定

    {
      "AzureAd": {
        "Instance": "https://login.microsoftonline.com/",
        "Domain": "xxxxx.onmicrosoft.com",
        "TenantId": "76275315-xxxx-xxxx-xxxx-a329d6222150",
        "ClientId": "7332902c-xxxx-xxxx-xxxx-63a0dcbf3d80",
        "CallbackPath": "/signin-oidc"
      }
    }
    
  4. 加入以下 NuGet 套件

    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
    dotnet add package Microsoft.Identity.Web
    dotnet add package Microsoft.Identity.Web.UI
    
  5. 修改 Program.cs 加入 Services 與 Middleware 宣告

    加入 Service 宣告

    builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
        .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
    
    builder.Services.AddControllersWithViews(options =>
    {
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    });
    builder.Services.AddRazorPages()
        .AddMicrosoftIdentityUI();
    

    加入 Middleware 宣告

    app.UseAuthentication(); // <-- 加入這行
    app.UseAuthorization();
    
    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}")
    .RequireAuthorization(); // <-- 加入這行
    app.MapRazorPages(); // <-- 加入這行
    

    做到這個步驟基本上就已經完成設定了!

  6. 加入一些 UI 顯示登入狀態

    加入一個 Views/Shared/_LoginPartial.cshtml 檔案

    @using System.Security.Principal
    
    <ul class="navbar-nav">
    @if (User.Identity?.IsAuthenticated == true)
    {
            <span class="navbar-text text-dark">Hello @User.Identity?.Name!</span>
            <li class="nav-item">
                <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignOut">Sign out</a>
            </li>
    }
    else
    {
            <li class="nav-item">
                <a class="nav-link text-dark" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in</a>
            </li>
    }
    </ul>
    

    Views/Shared/_Layout.cshtml 加入 Partial View 載入(請加在選單項目的右邊,約第 28 行的位置)

    <partial name="_LoginPartial" />
    

取得一些額外的使用者資訊 (Claims)

  1. 取得預設使用者名稱

    User.Identity.Name 預設為 Azure AD 的 User Principal Name,也就是使用者的 E-mail 地址!

    var email = User.Identity?.Name;
    
  2. 取得 Azure AD 使用者的顯示名稱

    var displayName = User.FindFirst("name")?.Value;
    
  3. 取得 Azure AD 使用者的 ObjectId

    var objectId = User.FindFirst(Microsoft.Identity.Web.ClaimConstants.ObjectId)?.Value;
    
  4. 取得 Azure AD 組織的 TenantId

    var tenantId = User.FindFirst(Microsoft.Identity.Web.ClaimConstants.TenantId)?.Value;
    
  5. 顯示 OpenId Connect 登入過程中取得的 Claims 資訊

    這邊我直接修改 Views\Home\Privacy.cshtml 頁面,範例程式如下:

    @using Microsoft.AspNetCore.Authentication
    <h2>Claims</h2>
    <dl>
        @foreach (var claim in User.Claims)
        {
            <dt>@claim.Type</dt>
            <dd>@claim.Value</dd>
        }
    </dl>
    <h2>Properties</h2>
    <dl>
        @{
            var items = (await Context.AuthenticateAsync()).Properties?.Items;
        }
        @if (items != null) foreach (var prop in items)
        {
            <dt>@prop.Key</dt>
            <dd>@prop.Value</dd>
        }
    </dl>
    

取得使用者的 Access Token 並呼叫 Microsoft Graph API

當你取得使用者身份之後,或許你還需要呼叫 Microsoft Graph API 取得更多的使用者資料,這個時候你會需要微調一下專案設定。

  1. 啟用 Token 獲取機制 (Token Acquisition)

    只要修改 Program.cs 註冊 .AddMicrosoftIdentityWebApp() 的地方,加入兩行:

    builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
        .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
        .EnableTokenAcquisitionToCallDownstreamApi() // <!-- 加上這一行
        .AddInMemoryTokenCaches(); // <!-- 加上這一行
    

    你可以從 EnableTokenAcquisitionToCallDownstreamApi() 這個 API 的命名,大致得知他的真正用途。這句話的的意思就是「啟用 Token 獲取,然後可以呼叫接下來的的 API」,非常直覺的方法命名。另一行 AddInMemoryTokenCaches() 則是會將使用者的 Access Token 快取在記憶體中,避免過於頻繁的呼叫 Azure AD 的 Token Endpoint!

  2. 你必須要從 appsettings.json 加入 ClientSecret 否則將無法取得 Access Token

    {
      "AzureAd": {
        "Instance": "https://login.microsoftonline.com/",
        "Domain": "xxxxx.onmicrosoft.com",
        "TenantId": "76275315-xxxx-xxxx-xxxx-a329d6222150",
        "ClientId": "7332902c-xxxx-xxxx-xxxx-63a0dcbf3d80",
        "ClientSecret": "2JY7Q~xxxx~u-xxxxxx-xxxx_mnz2VX4a-", // <-- 加上這一行
        "CallbackPath": "/signin-oidc"
      }
    }
    
  3. 修改 HomeController 並注入 ITokenAcquisition 服務

    private readonly ITokenAcquisition tokenAcquisition;
    
    public HomeController(ITokenAcquisition tokenAcquisition)
    {
        this.tokenAcquisition = tokenAcquisition;
    }
    
  4. 取得 Access Token 的方法

    在 ASP.NET Core MVC 的 Controller 中,你通常會需要套用一個 [AuthorizeForScopes(Scopes = new[] { "user.read" })] 屬性 (Attribute),這是為了避免當執行 tokenAcquisition.GetAccessTokenForUserAsync(scopes); 的時候如果發生例外,會將使用者重新導向到 Azure AD 的授權頁面,重新走一次 OAuth 2.0 授權流程,讓使用者同意你可以取得 user.read 範圍的資料(就是使用者所有個人資料)。

    [AuthorizeForScopes(Scopes = new[] { "user.read" })]
    public async Task<IActionResult> GetMyProfile()
    {
        string[] scopes = new []{"user.read"};
        string token = await tokenAcquisition.GetAccessTokenForUserAsync(scopes);
    
        ViewBag.token = token;
    
        return View();
    }
    
  5. 呼叫 Microsoft Graph API 取得完整的個人資料

    這裡我打算拿 Access Token 呼叫 https://graph.microsoft.com/v1.0/me 這個端點,取得完整的個人資料!

    我們先安裝 IdentityServer4 提供的 IdentityModel 好用套件:

    dotnet add package IdentityModel
    

    記得先引入 IdentityModel.Client 命名空間:

    using IdentityModel.Client;
    

    接著用以下程式碼就可以很漂亮的取得使用者在 Azure AD 的個人資料了!

    [AuthorizeForScopes(Scopes = new[] { "user.read" })]
    public async Task<IActionResult> GetMyProfile()
    {
        string[] scopes = new []{"user.read"};
        string token = await tokenAcquisition.GetAccessTokenForUserAsync(scopes);
    
        var http = httpClientFactory.CreateClient();
        http.SetBearerToken(token);
        var result = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me");
    
        return Ok(result);
    }
    

    若要取得使用者在 Azure AD 的照片,可以參考以下程式碼,一樣呼叫 Microsoft Graph API 就可以取得!

    [AuthorizeForScopes(Scopes = new[] { "user.read" })]
    public async Task<IActionResult> GetMyPhoto()
    {
        string[] scopes = new []{"user.read"};
        string token = await tokenAcquisition.GetAccessTokenForUserAsync(scopes);
    
        var http = httpClientFactory.CreateClient();
        http.SetBearerToken(token);
        var result = await http.GetStreamAsync("https://graph.microsoft.com/v1.0/me/photo/$value");
    
        return File(result, "image/jpeg");
    }
    

相關連結