Microsoft Entra (ME-ID) 组、管理员角色和应用角色

本文介绍如何配置 Blazor WebAssembly 以使用 Microsoft Entra ID (ME-ID) 组和角色。

ME-ID 提供了多种授权方法,这些方法可与 ASP.NET Core Identity 结合使用:

    • 安全性
    • Microsoft 365
    • 分布
  • 角色
    • ME-ID 内置 Administrator 角色
    • 应用角色

本文中的指南适用于以下文章中所述的 Blazor WebAssembly ME-ID 部署方案:

本文中的示例会利用新的 .NET/C# 功能。 使用 .NET 7 或更早版本中的示例时,需要稍作修改。 但是,与 ME-ID 和 Microsoft Graph 交互有关的文本和代码示例对所有版本的 ASP.NET Core 都是相同的。

示例应用

使用以下链接,通过存储库根目录中的最新版本文件夹访问名为 BlazorWebAssemblyEntraGroupsAndRoles 的示例应用。 这些示例针对 .NET 8 或更高版本提供。 有关如何运行应用的步骤,请参阅示例应用的 README 文件。

示例应用包含用于显示用户声明的 UserClaims 组件。 该 UserData 组件显示用户的基本帐户属性。

查看或下载示例代码如何下载

先决条件

本文中的指南根据将图形 API 与 ASP.NET Core Blazor WebAssembly 配合使用中的 Graph SDK 指南实现 Microsoft Graph API。 按照 Graph SDK 实现指南配置应用并对其进行测试,以确认应用可以获取测试用户帐户的图形 API 数据。 此外,请参阅图形 API 文章的安全文章交叉链接,以查看 Microsoft Graph 安全概念。

在本地使用 Graph SDK 进行测试时,建议为每个测试使用一个新的专用/隐身浏览器会话,防止 Cookie 干扰测试的情况继续存在。 有关详细信息,请参阅使用 Microsoft Entra ID 保护 ASP.NET Core Blazor WebAssembly 独立应用

ME-ID 应用注册联机工具

本文在提示你配置应用的 ME-ID 应用注册时,始终会参考 Azure 门户,但 Microsoft Entra 管理中心也是管理 ME-ID 应用注册的可行选项。 可以使用任一接口,但本文中的指南专门介绍了 Azure 门户的手势。

作用域

权限作用域是同一个意思,在安全文档和 Azure 门户中可以互换使用。 除非文本引用 Azure 门户,否则本文在引用 Graph 权限时使用作用域/作用域

范围不区分大小写,因此 User.Readuser.read 相同。 请随意使用任一格式,但我们建议在应用程序代码之间保持一致选择。

为了允许 Microsoft Graph API 调用用户配置文件、角色分配和组成员身份数据,在 Azure 门户中为应用配置了委派 User.Read 作用域 (https://graph.microsoft.com/User.Read),因为读取用户数据的访问权限由授予(委派)给单个用户的作用域决定。 除前面所列文章(《Microsoft 帐户的独立产品》或《ME-ID 的独立产品》)介绍的 ME-ID 部署方案中所需的作用域外,还需要此作用域。

其他必需的作用域包括:

  • 委派RoleManagement.Read.Directory作用域 (https://graph.microsoft.com/RoleManagement.Read.Directory):允许应用代表已登录用户读取公司目录的基于角色的访问控制 (RBAC) 设置。 这包括读取目录角色模板、目录角色和成员身份。 目录角色成员身份用于在应用中为 ME-ID 内置 Administrator 角色创建 directoryRole 声明。 需要管理员同意。
  • 委派AdministrativeUnit.Read.All作用域 (https://graph.microsoft.com/AdministrativeUnit.Read.All):允许应用代表已登录用户读取管理单元和管理单元成员身份。 这些成员身份用于在应用中创建 administrativeUnit 声明。 需要管理员同意。

有关详细信息,请参阅 Microsoft identity 平台中的权限和同意概述以及 Microsoft Graph 权限概述

自定义用户帐户

将用户分配到 Azure 门户中的 ME-ID 安全组和 ME-ID 管理员角色。

本文中的示例:

  • 假设将用户分配到 Azure 门户 ME-ID 租户中的 ME-ID 计费管理员角色,以授权访问服务器 API 数据。
  • 使用授权策略控制应用内的访问。

扩展 RemoteUserAccount 以包括以下内容的属性:

CustomUserAccount.cs:

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace BlazorWebAssemblyEntraGroupsAndRoles;

public class CustomUserAccount : RemoteUserAccount
{
    [JsonPropertyName("roles")]
    public List<string>? Roles { get; set; }

    [JsonPropertyName("oid")]
    public string? Oid { get; set; }
}

Microsoft.Graph 添加对应用的包引用。

注意

有关将包添加到 .NET 应用的指南,请参阅包使用工作流(NuGet 文档)中“安装和管理包”下的文章。 在 NuGet.org 中确认正确的包版本。

将图形 API 与 ASP.NET Core Blazor WebAssembly 一起使用一文的 Graph SDK 部分中,添加 Graph SDK 实用工具类和配置。 指定访问令牌的 User.ReadRoleManagement.Read.DirectoryAdministrativeUnit.Read.All 作用域,如文章的示例 wwwroot/appsettings.json 文件中所示。

将以下自定义用户帐户工厂添加到应用。 自定义用户工厂用于建立以下内容:

  • 应用角色声明 (role)(包含在应用角色部分)。

  • 用户移动电话号码的示例用户配置文件数据声明 (mobilePhone) 和办公地点 (officeLocation)。

  • ME-ID 管理员角色声明 (directoryRole)。

  • ME-ID 管理单元声明 (administrativeUnit)。

  • ME-ID 组声明 (directoryGroup)。

  • ILogger (logger),便于你要记录信息或错误时使用。

CustomAccountFactory.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Graph;
using Microsoft.Kiota.Abstractions.Authentication;

namespace BlazorWebAssemblyEntraGroupsAndRoles;

public class CustomAccountFactory(IAccessTokenProviderAccessor accessor,
        IServiceProvider serviceProvider, ILogger<CustomAccountFactory> logger,
        IConfiguration config)
    : AccountClaimsPrincipalFactory<CustomUserAccount>(accessor)
{
    private readonly ILogger<CustomAccountFactory> logger = logger;
    private readonly IServiceProvider serviceProvider = serviceProvider;
    private readonly string? baseUrl = string.Join("/",
        config.GetSection("MicrosoftGraph")["BaseUrl"],
        config.GetSection("MicrosoftGraph")["Version"]);

    public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
        CustomUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);

        if (initialUser.Identity is not null &&
            initialUser.Identity.IsAuthenticated)
        {
            var userIdentity = initialUser.Identity as ClaimsIdentity;

            if (userIdentity is not null && !string.IsNullOrEmpty(baseUrl) &&
                account.Oid is not null)
            {
                account?.Roles?.ForEach((role) =>
                {
                    userIdentity.AddClaim(new Claim("role", role));
                });

                try
                {
                    var client = new GraphServiceClient(
                        new HttpClient(),
                        serviceProvider
                            .GetRequiredService<IAuthenticationProvider>(),
                        baseUrl);

                    var user = await client.Me.GetAsync();

                    if (user is not null)
                    {
                        userIdentity.AddClaim(new Claim("mobilephone",
                            user.MobilePhone ?? "(000) 000-0000"));
                        userIdentity.AddClaim(new Claim("officelocation",
                            user.OfficeLocation ?? "Not set"));
                    }

                    var memberOf = client.Users[account?.Oid].MemberOf;

                    var graphDirectoryRoles = await memberOf.GraphDirectoryRole.GetAsync();

                    if (graphDirectoryRoles?.Value is not null)
                    {
                        foreach (var entry in graphDirectoryRoles.Value)
                        {
                            if (entry.RoleTemplateId is not null)
                            {
                                userIdentity.AddClaim(
                                    new Claim("directoryRole", entry.RoleTemplateId));
                            }
                        }
                    }

                    var graphAdministrativeUnits = await memberOf.GraphAdministrativeUnit.GetAsync();

                    if (graphAdministrativeUnits?.Value is not null)
                    {
                        foreach (var entry in graphAdministrativeUnits.Value)
                        {
                            if (entry.Id is not null)
                            {
                                userIdentity.AddClaim(
                                    new Claim("administrativeUnit", entry.Id));
                            }
                        }
                    }

                    var graphGroups = await memberOf.GraphGroup.GetAsync();

                    if (graphGroups?.Value is not null)
                    {
                        foreach (var entry in graphGroups.Value)
                        {
                            if (entry.Id is not null)
                            {
                                userIdentity.AddClaim(
                                    new Claim("directoryGroup", entry.Id));
                            }
                        }
                    }
                }
                catch (AccessTokenNotAvailableException exception)
                {
                    exception.Redirect();
                }
            }
        }

        return initialUser;
    }
}

前面的代码:

  • 不包含可传递的成员身份。 如果应用需要直接且可传递的组成员身份声明,请将 MemberOf 属性 (IUserMemberOfCollectionWithReferencesRequestBuilder) 替换为 TransitiveMemberOf (IUserTransitiveMemberOfCollectionWithReferencesRequestBuilder)。
  • directoryRole 声明中设置的 GUID 值为 ME-ID 管理员角色模板 ID (Microsoft.Graph.Models.DirectoryRole.RoleTemplateId)。 模板 ID 是用于在应用中创建用户授权策略的稳定标识符,本文稍后将对此进行介绍。 请勿将 entry.Id 用于目录角色声明值,因为它们在各租户之间不稳定。

接下来,将 MSAL 身份验证配置为使用自定义用户帐户工厂。

确认 Program 文件使用 Microsoft.AspNetCore.Components.WebAssembly.Authentication 命名空间:

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

更新对以下内容的 AddMsalAuthentication 调用。 请注意,Blazor 框架的 RemoteUserAccount 将替换为 MSAL 身份验证和帐户声明主体工厂的应用的 CustomUserAccount

builder.Services.AddMsalAuthentication<RemoteAuthenticationState,
    CustomUserAccount>(options =>
    {
        builder.Configuration.Bind("AzureAd",
            options.ProviderOptions.Authentication);
        options.UserOptions.RoleClaim = "role";
    })
    .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount,
        CustomAccountFactory>();

确认《将图形 API 与 ASP.NET Core Blazor WebAssembly 结合使用》文章描述的 Program 文件中是否存在 Graph SDK 代码:

var baseUrl =
    string.Join("/",
        builder.Configuration.GetSection("MicrosoftGraph")["BaseUrl"] ??
            "https://graph.microsoft.com",
        builder.Configuration.GetSection("MicrosoftGraph")["Version"] ??
            "v1.0");
var scopes = builder.Configuration.GetSection("MicrosoftGraph:Scopes")
    .Get<List<string>>() ?? [ "user.read" ];

builder.Services.AddGraphClient(baseUrl, scopes);

重要

在 Azure 门户的应用注册中确认已授予以下权限:

  • User.Read
  • RoleManagement.Read.Directory(需要管理员同意)
  • AdministrativeUnit.Read.All(需要管理员同意)

根据 Graph SDK 指南确认 wwwroot/appsettings.json 配置正确无误。

wwwroot/appsettings.json:

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/{TENANT ID}",
    "ClientId": "{CLIENT ID}",
    "ValidateAuthority": true
  },
  "MicrosoftGraph": {
    "BaseUrl": "https://graph.microsoft.com",
    "Version": "v1.0",
    "Scopes": [
      "User.Read",
      "RoleManagement.Read.Directory",
      "AdministrativeUnit.Read.All"
    ]
  }
}

在 Azure 门户中为应用的 ME-ID 注册中的以下占位符提供值:

  • {TENANT ID}:目录(租户)ID GUID 值。
  • {CLIENT ID}:应用程序 (客户端) ID GUID 值。

授权配置

Program 文件中为每个应用角色(按角色名称)、ME-ID 内置管理员角色(按角色模板 ID/GUID)或安全组(按对象 ID/GUID)创建策略。 以下示例为 ME-ID 内置计费管理员角色创建策略:

builder.Services.AddAuthorizationCore(options =>
{
    options.AddPolicy("BillingAdministrator", policy => 
        policy.RequireClaim("directoryRole", 
            "b0f54661-2d74-4c50-afa3-1ec803f12efe"));
});

有关 ME-ID 管理员角色 ID (GUID) 的完整列表,请参阅 ME-ID 文档中的角色模板 ID。 有关 Azure 安全或 O365 组 ID (GUID),请参阅应用注册的 Azure 门户的“”窗格中组的对象 ID。 有关授权策略详细信息,请参阅 ASP.NET Core 中基于策略的授权

在以下示例中,应用使用前面的策略来授权用户。

AuthorizeView 组件适用于以下策略:

<AuthorizeView Policy="BillingAdministrator">
    <Authorized>
        <p>
            The user is in the 'Billing Administrator' ME-ID Administrator Role
            and can see this content.
        </p>
    </Authorized>
    <NotAuthorized>
        <p>
            The user is NOT in the 'Billing Administrator' role and sees this
            content.
        </p>
    </NotAuthorized>
</AuthorizeView>

对整个组件的访问可以基于使用 [Authorize] 属性指令 (AuthorizeAttribute) 的策略:

@page "/"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "BillingAdministrator")]

如果用户未获得授权,则会将他们重定向到 ME-ID 登录页。

策略检查也可以在具有过程逻辑的代码中执行

CheckPolicy.razor:

@page "/checkpolicy"
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService

<h1>Check Policy</h1>

<p>This component checks a policy in code.</p>

<button @onclick="CheckPolicy">Check 'BillingAdministrator' policy</button>

<p>Policy Message: @policyMessage</p>

@code {
    private string policyMessage = "Check hasn't been made yet.";

    [CascadingParameter]
    private Task<AuthenticationState> authenticationStateTask { get; set; }

    private async Task CheckPolicy()
    {
        var user = (await authenticationStateTask).User;

        if ((await AuthorizationService.AuthorizeAsync(user, 
            "BillingAdministrator")).Succeeded)
        {
            policyMessage = "Yes! The 'BillingAdministrator' policy is met.";
        }
        else
        {
            policyMessage = "No! 'BillingAdministrator' policy is NOT met.";
        }
    }
}

使用上述方法,还可以为安全组创建基于策略的访问,其中用于策略的 GUID 匹配

应用角色

要在 Azure 门户中配置应用以提供应用角色成员资格声明,请参阅 ME-ID 文档中的在应用程序中添加应用角色并在令牌中接收这些角色

下面的示例假定应用配置了两个角色,并将角色分配给测试用户:

  • Admin
  • Developer

尽管没有 ME-ID Premium 帐户就无法将角色分配给组,但你可以将角色分配给用户,并为具有标准 Azure 帐户的用户接收角色声明。 本部分中的指南不需要 ME-ID Premium 帐户。

采用以下任一方法在 ME-ID 中添加应用角色:

  • 使用默认目录时,请按照将应用角色添加到应用程序中并在令牌中接收它们中的指南来创建 ME-ID 角色。

  • 如果不使用默认目录,请在 Azure 门户中编辑应用的清单,以在清单文件的 appRoles 条目中手动建立应用的角色。 下面是创建 AdminDeveloper 角色的示例 appRoles 条目。 稍后在组件级别使用这些示例角色来实现访问限制:

    重要

    以下方法仅建议用于未在 Azure 帐户的默认目录中注册的应用。 有关在默认目录中注册的应用,请参阅此列表的上述项目符号。

    "appRoles": [
      {
        "allowedMemberTypes": [
          "User"
        ],
        "description": "Administrators manage developers.",
        "displayName": "Admin",
        "id": "{ADMIN GUID}",
        "isEnabled": true,
        "lang": null,
        "origin": "Application",
        "value": "Admin"
      },
      {
        "allowedMemberTypes": [
          "User"
        ],
        "description": "Developers write code.",
        "displayName": "Developer",
        "id": "{DEVELOPER GUID}",
        "isEnabled": true,
        "lang": null,
        "origin": "Application",
        "value": "Developer"
      }
    ],
    

    对于前面的示例中的{ADMIN GUID}占位符和{DEVELOPER GUID}占位符,可以使用联机 GUID 生成器生成 GUID(“guid 生成器”的 Google 搜索结果)。

若要将角色分配给用户(或组,如果你有高级层 Azure 帐户),请执行以下操作:

  1. 导航到 Azure 门户的 ME-ID 区域中的“企业应用程序”。
  2. 选择应用。 从边栏中选择“管理”>“用户和组”。
  3. 选中一个或多个用户帐户的复选框。
  4. 在用户列表上方的菜单中,选择“编辑分配”。
  5. 对于“选择角色”条目,选择“未选择任何项”。
  6. 从列表中选择一个角色,并使用“选择”按钮将其选中。
  7. 使用屏幕底部的“分配”按钮分配角色。

通过为其他每个角色分配重新添加用户,在 Azure 门户中分配多个角色。 使用用户列表顶部的“添加用户/组”按钮重新添加用户。 使用上述步骤向用户分配另一个角色。 可以根据需要多次重复此过程,将其他角色添加到用户(或组)。

自定义用户帐户部分中显示的 CustomAccountFactory 设置为使用 JSON 数组值应对 role 声明。 在应用中添加并注册 CustomAccountFactory,如自定义用户帐户部分所示。 无需提供代码来删除原始 role 声明,因为框架会自动删除它。

Program 文件中,添加或确认名为“role”的声明作为 ClaimsPrincipal.IsInRole 检查的角色声明:

builder.Services.AddMsalAuthentication(options =>
{
    ...

    options.UserOptions.RoleClaim = "role";
});

注意

如果希望使用 directoryRoles 声明(ME-ID 管理员角色),请将“directoryRoles”分配给 RemoteAuthenticationUserOptions.RoleClaim

完成上述步骤以创建角色并将其分配给用户(或组,如果你有高级层 Azure 帐户),并使用 Graph SDK 实现 CustomAccountFactory,如本文前面和将图形 API 与 ASP.NET Core Blazor WebAssembly 一起使用中所述,应会看到为登录用户分配的每个分配角色(或分配给他们所属的组的角色)都有一个 role 声明。 使用测试用户运行应用,以确认声明是否像预期那样存在。 在本地使用 Graph SDK 进行测试时,建议为每个测试使用一个新的专用/隐身浏览器会话,防止 Cookie 干扰测试的情况继续存在。 有关详细信息,请参阅使用 Microsoft Entra ID 保护 ASP.NET Core Blazor WebAssembly 独立应用

组件授权方法此时有效。 此应用的组件中的任何授权机制都可以使用 Admin 角色来授权用户:

支持多个角色测试:

  • 要求用户为 Admin Developer 角色,并具有 AuthorizeView 组件:

    <AuthorizeView Roles="Admin, Developer">
        ...
    </AuthorizeView>
    
  • 要求用户同时Admin Developer 角色,并具有 AuthorizeView 组件:

    <AuthorizeView Roles="Admin">
        <AuthorizeView Roles="Developer" Context="innerContext">
            ...
        </AuthorizeView>
    </AuthorizeView>
    

    有关 ContextAuthorizeView 内部的详细信息,请参阅 ASP.NET Core Blazor身份验证和授权

  • 要求用户为 Admin Developer 角色,并具有 [Authorize] 属性:

    @attribute [Authorize(Roles = "Admin, Developer")]
    
  • 要求用户同时Admin Developer 角色,并具有 [Authorize] 属性:

    @attribute [Authorize(Roles = "Admin")]
    @attribute [Authorize(Roles = "Developer")]
    
  • 要求用户为 Admin Developer 角色,并具有过程代码:

    @code {
        private async Task DoSomething()
        {
            var authState = await AuthenticationStateProvider
                .GetAuthenticationStateAsync();
            var user = authState.User;
    
            if (user.IsInRole("Admin") || user.IsInRole("Developer"))
            {
                ...
            }
            else
            {
                ...
            }
        }
    }
    
  • 要求用户同时Admin Developer 角色,并具有过程代码,方法是将 条件 OR (||) 更改为上述示例中的 条件 AND (&&)

    if (user.IsInRole("Admin") && user.IsInRole("Developer"))
    

支持多个角色测试:

  • 要求用户为 Admin Developer 角色,并具有 [Authorize] 属性:

    [Authorize(Roles = "Admin, Developer")]
    
  • 要求用户同时Admin Developer 角色,并具有 [Authorize] 属性:

    [Authorize(Roles = "Admin")]
    [Authorize(Roles = "Developer")]
    
  • 要求用户为 Admin Developer 角色,并具有过程代码:

    static readonly string[] scopeRequiredByApi = new string[] { "API.Access" };
    
    ...
    
    [HttpGet]
    public IEnumerable<ReturnType> Get()
    {
        HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);
    
        if (User.IsInRole("Admin") || User.IsInRole("Developer"))
        {
            ...
        }
        else
        {
            ...
        }
    
        return ...
    }
    
  • 要求用户同时Admin Developer 角色,并具有过程代码,方法是将 条件 OR (||) 更改为上述示例中的 条件 AND (&&)

    if (User.IsInRole("Admin") && User.IsInRole("Developer"))
    

由于 .NET 字符串比较是区分大小写的,因此匹配角色名称也区分大小写。 例如,Admin(大写 A)不被视为与 admin(小写 a)相同的角色。

Pascal 大小写通常用于角色名称(例如 BillingAdministrator),但使用 Pascal 大小写并非严格要求。 允许不同的包装方案,如骆驼元素、烤肉元素和蛇元素。 在角色名称中使用空格也是不常见的,但允许使用。 例如,billing administrator 在 .NET 应用中是一种不常见的角色名称格式,但有效。

其他资源