使用 Identity 服务器保护托管 ASP.NET Core Blazor WebAssembly 应用

本文介绍如何创建托管 Blazor WebAssembly 解决方案,该解决方案使用 Duende Identity Server 对用户和 API 调用进行身份验证。

重要

Duende Software 可能会要求你为 Duende Identity Server 的生产使用支付许可证费用。 有关详细信息,请参阅从 ASP.NET Core 5.0 迁移到 6.0

注意

要将独立或托管的 Blazor WebAssembly 应用配置为使用现有的外部 Identity 服务器实例,请按照使用身份验证库保护 ASP.NET Core Blazor WebAssembly 独立应用中的指导进行操作。

阅读本文后,有关其他安全场景的介绍,请参阅 ASP.NET Core Blazor WebAssembly 其他安全场景

演练

演练的子节介绍了如何:

  • 创建 Blazor 应用
  • 运行应用

创建 Blazor 应用

新建具有身份验证机制的 Blazor WebAssembly 项目:

  1. 创建新项目。

  2. 选择“Blazor WebAssembly 应用”模板。 选择“下一步”。

  3. 提供不带短划线的项目名称。 确认位置正确无误。 选择“下一页”。

    请勿在项目名称中使用短划线 (-),它会破坏 OIDC 应用标识符的构成。 Blazor WebAssembly 项目模板中的逻辑使用解决方案配置中的 OIDC 应用标识符的项目名称,并且 OIDC 应用标识符中不允许使用短划线。 接受改用帕斯卡命名法 (BlazorSample) 或下划线 (Blazor_Sample)。

  4. 在“其他信息”对话框中,选择“个人帐户”作为“身份验证类型”,以使用 ASP.NET Core 的 Identity 系统存储应用内的用户。

  5. 选中“托管的 ASP.NET Core”复选框。

  6. 选择“创建”按钮以创建应用。

运行应用

Server 项目运行应用。 使用 Visual Studio 时,请执行以下任一操作:

  • 选择“运行”按钮旁边的下拉箭头。 从下拉列表中打开“配置启动项目”。 选择“单启动项目”选项。 确认启动项目的项目或将该项目更改为 Server 项目。

  • 确认在用以下任一方法启动应用之前,解决方案资源管理器中突出显示了 Server 项目:

    • 选择“运行”按钮。
    • 从菜单栏中,依次使用“调试”>“开始调试” 。
    • 按 F5
  • 在命令 shell 中,导航到解决方案的 Server 项目文件夹。 执行 dotnet run 命令。

解决方案的各个部分

本部分介绍从 Blazor WebAssembly 项目模板生成的解决方案的各个部分,并介绍如何配置解决方案的 ClientServer 项目以供参考。 如果使用演练部分的指南创建基本工作应用程序,则本部分中没有可用于该应用的特定指南。 本部分中的指南有助于更新应用以对用户进行身份验证和授权。 更新应用的另一种方法是根据演练部分的指南创建新的应用,然后将应用的组件、类和资源移动到新应用。

Server 应用服务

本部分涉及解决方案的 Server 应用。

下列服务已注册。

  • Program 文件中:

    • Entity Framework Core 和 ASP.NET Core Identity:

      builder.Services.AddDbContext<ApplicationDbContext>(options =>
          options.UseSqlite( ... ));
      builder.Services.AddDatabaseDeveloperPageExceptionFilter();
      
      builder.Services.AddDefaultIdentity<ApplicationUser>(options => 
              options.SignIn.RequireConfirmedAccount = true)
          .AddEntityFrameworkStores<ApplicationDbContext>();
      
    • 包含附加 AddApiAuthorization 帮助器方法的 Identity Server,该方法在 Identity Server 之上设置默认 ASP.NET Core 约定:

      builder.Services.AddIdentityServer()
          .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
      
    • 包含附加 AddIdentityServerJwt 帮助器方法的身份验证,该方法将应用配置为验证由 Identity Server 生成的 JWT 令牌:

      builder.Services.AddAuthentication()
          .AddIdentityServerJwt();
      
  • Startup.csStartup.ConfigureServices 中:

    • Entity Framework Core 和 ASP.NET Core Identity:

      services.AddDbContext<ApplicationDbContext>(options =>
          options.UseSqlite(
              Configuration.GetConnectionString("DefaultConnection")));
      
      services.AddDefaultIdentity<ApplicationUser>(options => 
              options.SignIn.RequireConfirmedAccount = true)
          .AddEntityFrameworkStores<ApplicationDbContext>();
      
    • 包含附加 AddApiAuthorization 帮助器方法的 Identity Server,该方法在 Identity Server 之上设置默认 ASP.NET Core 约定:

      services.AddIdentityServer()
          .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
      
    • 包含附加 AddIdentityServerJwt 帮助器方法的身份验证,该方法将应用配置为验证由 Identity Server 生成的 JWT 令牌:

      services.AddAuthentication()
          .AddIdentityServerJwt();
      

注意

注册单个身份验证方案时,身份验证方案将自动用作应用的默认方案,无需向 AddAuthentication 或通过 AuthenticationOptions 声明方案。 有关详细信息,请参阅 ASP.NET Core 身份验证概述ASP.NET Core 公告 (aspnet/Announcements #490)

  • Program 文件中:
  • Startup.csStartup.Configure 中:
  • Identity Server 中间件公开 OpenID Connect (OIDC) 终结点:

    app.UseIdentityServer();
    
  • 身份验证中间件负责验证请求凭据并在请求上下文中设置用户:

    app.UseAuthentication();
    
  • 授权中间件支持授权功能:

    app.UseAuthorization();
    

API 授权

本部分涉及解决方案的 Server 应用。

AddApiAuthorization 帮助器方法针对 ASP.NET Core 场景配置 IdentityServer。 Identity Server 是一个功能强大且可扩展的框架,用于处理应用安全问题。 Identity Server 公开大多数情况下不必要的复杂性。 因此,提供了一组约定和配置选项作为良好的起点。 一旦身份验证需要更改,就可使用 Identity Server 的完整功能自定义身份验证以满足应用的要求。

为与 Identity Server 共存的 API 添加身份验证处理程序

本部分涉及解决方案的 Server 应用。

AddIdentityServerJwt 帮助器方法将应用的策略方案配置为默认身份验证处理程序。 该策略配置为允许 Identity 处理路由到 /Identity 下 Identity URL 空间中任何子路径的所有请求。 JwtBearerHandler 处理所有其他请求。 此外,此方法还可以:

  • 将 API 资源注册到 Identity Server,默认范围为 {PROJECT NAME}API,其中 {PROJECT NAME} 占位符是创建应用时项目的名称。
  • 配置 JWT 持有者令牌中间件以验证 Identity Server 为应用颁发的令牌。

天气预报控制器

本部分涉及解决方案的 Server 应用。

WeatherForecastController (Controllers/WeatherForecastController.cs) 中,[Authorize] 特性应用于该类。 该属性指示用户必须根据默认策略获得授权才能访问资源。 默认授权策略配置为使用默认身份验证方案,由 AddIdentityServerJwt 设置。 帮助器方法将 JwtBearerHandler 配置为对应用的请求的默认处理程序。

应用程序数据库上下文

本部分涉及解决方案的 Server 应用。

ApplicationDbContext (Data/ApplicationDbContext.cs) 中,DbContext 扩展 ApiAuthorizationDbContext<TUser> 以包括 Identity Server 的架构。 ApiAuthorizationDbContext<TUser> 派生自 IdentityDbContext

要获取对数据库架构的完全控制,请从其中一个可用的 IdentityDbContext 类继承,并通过在 OnModelCreating 方法中调用 builder.ConfigurePersistedGrantContext(_operationalStoreOptions.Value) 来配置上下文以包括 Identity 架构。

OIDC 配置控制器

本部分涉及解决方案的 Server 应用。

OidcConfigurationController (Controllers/OidcConfigurationController.cs) 中,客户端终结点预配为提供 OIDC 参数。

应用设置

本部分涉及解决方案的 Server 应用。

在项目根目录的应用设置文件 (appsettings.json) 中,IdentityServer 部分描述已配置的客户端列表。 下例中存在一个客户端。 客户端名称对应于 Client 应用的程序集名称,并通过约定映射到 OAuth ClientId 参数。 配置文件指示正在配置的应用类型。 配置文件在内部用于促进简化服务器配置过程的约定。

"IdentityServer": {
  "Clients": {
    "{ASSEMBLY NAME}": {
      "Profile": "IdentityServerSPA"
    }
  }
}

占位符 {ASSEMBLY NAME}Client 应用的程序集名称(例如 BlazorSample.Client)。

身份验证包

本部分涉及解决方案的 Client 应用。

创建应用以使用个人用户帐户时 (Individual),该应用会自动接收 Microsoft.AspNetCore.Components.WebAssembly.Authentication 包的包引用。 此包提供了一组基元,可帮助应用验证用户身份并获取令牌以调用受保护的 API。

如果向应用添加身份验证,请手动将 Microsoft.AspNetCore.Components.WebAssembly.Authentication 包添加到应用中。

注意

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

HttpClient 配置

本部分涉及解决方案的 Client 应用。

Program 文件中,命名 HttpClient 配置为提供 HttpClient 实例,以在向服务器 API 发出请求时包含访问令牌。 默认情况下,在创建解决方案时,命名的 HttpClient{PROJECT NAME}.ServerAPI,其中 {PROJECT NAME} 占位符是项目的名称。

builder.Services.AddHttpClient("{PROJECT NAME}.ServerAPI", 
        client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
    .CreateClient("{PROJECT NAME}.ServerAPI"));

占位符 {PROJECT NAME} 是创建解决方案时的项目名称。 例如,提供 BlazorSample 的项目名称将生成 BlazorSample.ServerAPI 的命名的 HttpClient

注意

若要将 Blazor WebAssembly 应用配置为使用不属于托管 Blazor 解决方案的现有 Identity 服务器实例,请将 HttpClient 基址注册从 IWebAssemblyHostEnvironment.BaseAddress (builder.HostEnvironment.BaseAddress) 更改为服务器应用的 API 授权终结点 URL。

API 身份验证支持

本部分涉及解决方案的 Client 应用。

使用 Microsoft.AspNetCore.Components.WebAssembly.Authentication 包中提供的扩展方法在服务容器中加入用户身份验证支持。 此方法设置应用所需的服务以与现有授权系统交互。

builder.Services.AddApiAuthorization();

默认情况下,应用的配置按约定从 _configuration/{client-id} 加载。 按照约定,客户端 ID 设置为应用的程序集名称。 可以通过使用选项调用重载来更改此 URL 以指向单独的终结点。

Imports 文件

本部分涉及解决方案的 Client 应用。

整个应用通过 _Imports.razor 文件提供 Microsoft.AspNetCore.Components.Authorization 命名空间:

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using {APPLICATION ASSEMBLY}
@using {APPLICATION ASSEMBLY}.Shared

Index 页面

本部分涉及解决方案的 Client 应用。

索引页 (wwwroot/index.html) 包含一个脚本,用于在 JavaScript 中定义 AuthenticationServiceAuthenticationService 处理 OIDC 协议的低级别详细信息。 应用从内部调用脚本中定义的方法以执行身份验证操作。

<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>

App 组件

本部分涉及解决方案的 Client 应用。

App 组件 (App.razor) 类似于 Blazor Server 应用中的 App 组件:

由于不同版本的 ASP.NET Core 中的框架发生了更改,因此本部分不会显示 App 组件 (App.razor) 的 Razor 标记。 若要检查给定版本的组件的标记,请使用以下方法之一

  • 创建一个应用,预配为从要使用的 ASP.NET Core 版本的默认 Blazor WebAssembly 项目模板进行身份验证。 在生成的应用中检查 App 组件 (App.razor)。

  • 引用源中检查 App 组件 (App.razor)。 从分支选择器中选择版本,并在存储库的 ProjectTemplates 文件夹中搜索该组件,因为经过多年它已移动。

    注意

    指向 .NET 参考源的文档链接通常会加载存储库的默认分支,该分支表示针对下一个 .NET 版本的当前开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉列表。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

RedirectToLogin 组件

本部分涉及解决方案的 Client 应用。

RedirectToLogin 组件 (RedirectToLogin.razor):

  • 管理将未经授权的用户重定向到登录页。
  • 保留用户尝试访问的当前 URL,以便在身份验证成功时可以通过以下方式将其返回到该页:
    • .NET 7 或更高版本中的 ASP.NET Core 的导航历史记录状态
    • .NET 6 或更早版本中的 ASP.NET Core 的查询字符串。

引用源中检查 RedirectToLogin 组件。 组件的位置随时间而更改,因此请使用 GitHub 搜索工具来查找该组件。

注意

指向 .NET 参考源的文档链接通常会加载存储库的默认分支,该分支表示针对下一个 .NET 版本的当前开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉列表。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

LoginDisplay 组件

本部分涉及解决方案的 Client 应用。

LoginDisplay 组件 (LoginDisplay.razor) 在 MainLayout 组件 (MainLayout.razor) 中呈现并管理以下行为:

  • 对于经过身份验证的用户:
    • 显示当前用户名。
    • 提供指向 ASP.NET Core Identity 中的用户配置文件页面的链接。
    • 提供用于注销应用的按钮。
  • 对于匿名用户:
    • 提供用于注册的选项。
    • 提供用于登录的选项。

由于不同版本的 ASP.NET Core 中的框架发生了更改,因此本部分不会显示 LoginDisplay 组件的 Razor 标记。 若要检查给定版本的组件的标记,请使用以下方法之一

  • 创建一个应用,预配为从要使用的 ASP.NET Core 版本的默认 Blazor WebAssembly 项目模板进行身份验证。 在生成的应用中检查 LoginDisplay 组件。

  • 引用源中检查 LoginDisplay 组件。 组件的位置随时间而更改,因此请使用 GitHub 搜索工具来查找该组件。 使用 Hosted(为 true)的模板化内容。

    注意

    指向 .NET 参考源的文档链接通常会加载存储库的默认分支,该分支表示针对下一个 .NET 版本的当前开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉列表。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

Authentication 组件

本部分涉及解决方案的 Client 应用。

Authentication 组件 (Pages/Authentication.razor) 生成的页面定义了处理各种身份验证阶段所需的路由。

RemoteAuthenticatorView 组件:

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="Action" />

@code {
    [Parameter]
    public string? Action { get; set; }
}

注意

.NET 6 或更高版本的 ASP.NET Core 支持空引用类型 (NRT) 和 .NET 编译器 Null 状态静态分析。 在 .NET 6 中的 ASP.NET Core 发布之前,string 类型没有 null 类型指定 (?)。

FetchData 组件

本部分涉及解决方案的 Client 应用。

FetchData 组件显示如何:

  • 预配访问令牌。
  • 使用访问令牌调用 Server 应用中受保护的资源 API。

@attribute [Authorize] 指令向 Blazor WebAssembly 授权系统发出指示,用户必须获得授权才能访问此组件。 如果 Client 应用中存在该属性,则在没有正确凭据的情况下,不会阻止调用服务器上的 API。 Server 应用还必须在相应的终结点上使用 [Authorize] 才能适当地保护这些终结点。

IAccessTokenProvider.RequestAccessToken 负责请求可添加到请求中的访问令牌,以调用 API。 如果该令牌已缓存,或者该服务在没有用户交互的情况下能够预配新的访问令牌,则令牌请求会成功。 否则,令牌请求会失败,并出现 AccessTokenNotAvailableException,这是在 try-catch 语句中捕获的。

为了获得要包含在请求中的实际令牌,应用必须通过调用 tokenResult.TryGetToken(out var token) 来检查请求是否成功。

如果请求成功,将使用访问令牌填充令牌变量。 此标记的 AccessToken.Value 属性会公开要包含在 Authorization 请求标头中的文本字符串。

如果请求失败,因为在没有用户交互的情况下无法预配令牌:

  • .NET 7 或更高版本中的 ASP.NET Core:应用使用给定的 AccessTokenResult.InteractionOptions 导航到 AccessTokenResult.InteractiveRequestUrl 以允许刷新访问令牌。
  • .NET 6 或更低版本中的 ASP.NET Core:令牌结果包含重定向 URL。 导航到此 URL 后,用户将进入登录页,并在身份验证成功后返回到当前页面。
@page "/fetchdata"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using {APP NAMESPACE}.Shared
@attribute [Authorize]
@inject HttpClient Http

...

@code {
    private WeatherForecast[] forecasts;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}

Linux 上的 Azure 应用服务

部署到 Linux 上的 Azure 应用服务时,显式指定颁发者。 有关详细信息,请参阅使用 Identity 保护 SPA 的 Web API 后端

具有 API 授权的名称和角色声明

自定义用户工厂

Client 应用中,创建自定义用户工厂。 Identity 服务器在一个 role 声明中发送多个角色作为 JSON 数组。 单个角色在该声明中作为单个字符串值进行发送。 工厂为每个用户的角色创建单个 role 声明。

CustomUserFactory.cs

using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

public class CustomUserFactory
    : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public CustomUserFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
    {
    }

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

        if (user.Identity is not null && user.Identity.IsAuthenticated)
        {
            var identity = (ClaimsIdentity)user.Identity;
            var roleClaims = identity.FindAll(identity.RoleClaimType).ToArray();

            if (roleClaims.Any())
            {
                foreach (var existingClaim in roleClaims)
                {
                    identity.RemoveClaim(existingClaim);
                }

                var rolesElem = 
                    account.AdditionalProperties[identity.RoleClaimType];

                if (options.RoleClaim is not null && rolesElem is JsonElement roles)
                {
                    if (roles.ValueKind == JsonValueKind.Array)
                    {
                        foreach (var role in roles.EnumerateArray())
                        {
                            var roleValue = role.GetString();

                            if (!string.IsNullOrEmpty(roleValue))
                            {
                                identity.AddClaim(
                                  new Claim(options.RoleClaim, roleValue));
                            }

                        }
                    }
                    else
                    {
                        var roleValue = roles.GetString();

                        if (!string.IsNullOrEmpty(roleValue))
                        {
                            identity.AddClaim(
                              new Claim(options.RoleClaim, roleValue));
                        }
                    }
                }
            }
        }

        return user;
    }
}
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

public class CustomUserFactory
    : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public CustomUserFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
    {
    }

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

        if (user.Identity.IsAuthenticated)
        {
            var identity = (ClaimsIdentity)user.Identity;
            var roleClaims = identity.FindAll(identity.RoleClaimType).ToArray();

            if (roleClaims.Any())
            {
                foreach (var existingClaim in roleClaims)
                {
                    identity.RemoveClaim(existingClaim);
                }

                var rolesElem = account.AdditionalProperties[identity.RoleClaimType];

                if (rolesElem is JsonElement roles)
                {
                    if (roles.ValueKind == JsonValueKind.Array)
                    {
                        foreach (var role in roles.EnumerateArray())
                        {
                            identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
                        }
                    }
                    else
                    {
                        identity.AddClaim(new Claim(options.RoleClaim, roles.GetString()));
                    }
                }
            }
        }

        return user;
    }
}

Client 应用中,在 Program 文件中注册工厂:

builder.Services.AddApiAuthorization()
    .AddAccountClaimsPrincipalFactory<CustomUserFactory>();

Server 应用中,调用 Identity 生成器上的 AddRoles,用于添加与角色相关的服务。

Program 文件中:

using Microsoft.AspNetCore.Identity;

...

builder.Services.AddDefaultIdentity<ApplicationUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

Startup.cs中:

using Microsoft.AspNetCore.Identity;

...

services.AddDefaultIdentity<ApplicationUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

配置 Identity 服务器

使用以下方法之一:

API 身份验证选项

Server 应用中:

  • 配置 Identity 服务器,将 namerole 声明放入 ID 令牌和访问令牌中。
  • 阻止 JWT 令牌处理程序中角色的默认映射。

Program 文件中:

using System.IdentityModel.Tokens.Jwt;

...

builder.Services.AddIdentityServer()
    .AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options => {
        options.IdentityResources["openid"].UserClaims.Add("name");
        options.ApiResources.Single().UserClaims.Add("name");
        options.IdentityResources["openid"].UserClaims.Add("role");
        options.ApiResources.Single().UserClaims.Add("role");
    });

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");

Startup.cs中:

using System.IdentityModel.Tokens.Jwt;
using System.Linq;

...

services.AddIdentityServer()
    .AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options => {
        options.IdentityResources["openid"].UserClaims.Add("name");
        options.ApiResources.Single().UserClaims.Add("name");
        options.IdentityResources["openid"].UserClaims.Add("role");
        options.ApiResources.Single().UserClaims.Add("role");
    });

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");

配置文件服务

Server 应用中,创建 ProfileService 实现。

ProfileService.cs

using IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;

public class ProfileService : IProfileService
{
    public ProfileService()
    {
    }

    public async Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        var nameClaim = context.Subject.FindAll(JwtClaimTypes.Name);
        context.IssuedClaims.AddRange(nameClaim);

        var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
        context.IssuedClaims.AddRange(roleClaims);

        await Task.CompletedTask;
    }

    public async Task IsActiveAsync(IsActiveContext context)
    {
        await Task.CompletedTask;
    }
}
using IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using System.Threading.Tasks;

public class ProfileService : IProfileService
{
    public ProfileService()
    {
    }

    public async Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        var nameClaim = context.Subject.FindAll(JwtClaimTypes.Name);
        context.IssuedClaims.AddRange(nameClaim);

        var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
        context.IssuedClaims.AddRange(roleClaims);

        await Task.CompletedTask;
    }

    public async Task IsActiveAsync(IsActiveContext context)
    {
        await Task.CompletedTask;
    }
}

Server 应用中,在 Program 文件中注册配置文件服务:

using Duende.IdentityServer.Services;

...

builder.Services.AddTransient<IProfileService, ProfileService>();

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");

Server 应用中,在 Startup.csStartup.ConfigureServices 中注册配置文件服务:

using IdentityServer4.Services;

...

services.AddTransient<IProfileService, ProfileService>();

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");

使用授权机制

Client 应用中,组件授权方法此时可以正常工作。 组件中的任何授权机制都可以使用角色来授权用户:

User.Identity.NameClient 应用中进行填充,并带有用户的用户名,这通常是他们的登录电子邮件地址。

UserManagerSignInManager

在服务器应用需要以下项时设置用户标识符声明类型:

在 .NET 6 或更高版本的 ASP.NET Core 的 Program.cs 中:

using System.Security.Claims;

...

builder.Services.Configure<IdentityOptions>(options => 
    options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier);

在版本低于 6.0 的 ASP.NET Core 版本中的 Startup.ConfigureServices 中:

using System.Security.Claims;

...

services.Configure<IdentityOptions>(options => 
    options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier);

以下 WeatherForecastController 在调用 Get 方法时记录 UserName

注意

以下示例使用 文件范围的命名空间,它是 C# 10 或更高版本(.NET 6 或更高版本)功能。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using BlazorSample.Server.Models;
using BlazorSample.Shared;

namespace BlazorSample.Server.Controllers;

[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly UserManager<ApplicationUser> userManager;

    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", 
        "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> logger;

    public WeatherForecastController(ILogger<WeatherForecastController> logger, 
        UserManager<ApplicationUser> userManager)
    {
        this.logger = logger;
        this.userManager = userManager;
    }

    [HttpGet]
    public async Task<IEnumerable<WeatherForecast>> Get()
    {
        var rng = new Random();

        var user = await userManager.GetUserAsync(User);

        if (user != null)
        {
            logger.LogInformation("User.Identity.Name: {UserIdentityName}", user.UserName);
        }

        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

在上面的示例中:

  • Server 项目的命名空间为 BlazorSample.Server
  • Shared 项目的命名空间为 BlazorSample.Shared

使用自定义域和证书托管在 Azure 应用服务中

以下指南介绍:

  • 如何通过 Identity 服务器,使用自定义域将托管的 Blazor WebAssembly 应用部署到 Azure 应用服务
  • 如何创建和使用 TLS 证书与浏览器进行 HTTPS 协议通信。 虽然本指南重点介绍如何将证书用于自定义域,但本指南同样适用于使用默认的 Azure 应用域,例如 contoso.azurewebsites.net

对于这种托管方案,请勿将相同的证书用于 Identity 服务器的令牌签名密钥以及站点与浏览器的 HTTPS 安全通信:

  • 针对这两个要求使用不同的证书是一种很好的安全做法,因为这样能隔离不同用途的私钥。
  • 用于与浏览器进行通信的 TLS 证书是独立管理的,不会影响 Identity 服务器的令牌签名。
  • Azure Key Vault 向应用服务应用提供用于绑定自定义域的证书时,Identity 服务器无法从 Azure Key Vault 获取相同的证书来进行令牌签名。 尽管可以将 Identity 服务器配置为使用来自物理路径的相同 TLS 证书,但将安全证书置于源代码管理中是一种不太好的做法,在大多数情况下都应尽量避免此类行为。

接下来的指南将在 Azure Key Vault 中创建一个仅用于 Identity 服务器令牌签名的自签名证书。 Identity 服务器配置通过应用的 CurrentUser>My 证书存储使用密钥保管库证书。 其他用于自定义域 HTTPS 流量的证书是与 Identity 服务器签名证书分开创建和配置的。

若要将应用、Azure 应用服务和 Azure Key Vault 配置为使用自定义域和 HTTPS 进行托管,请执行以下操作:

  1. 创建计划级别不低于 Basic B1应用服务计划。 应用服务需要 Basic B1 或更高的服务层级才能使用自定义域。

  2. 使用组织控制的站点的完全限定的域名 (FQDN) 的公用名(例如 www.contoso.com)为站点的安全浏览器通信(HTTPS 协议)创建 PFX 证书。 通过以下几项内容创建证书:

    • 密钥使用
      • 数字签名验证 (digitalSignature)
      • 密钥加密 (keyEncipherment)
    • 增强/扩展的密钥使用
      • 客户端身份验证 (1.3.6.1.5.5.7.3.2)
      • 服务器身份验证 (1.3.6.1.5.5.7.3.1)

    若要创建证书,请使用以下方法之一或任何其他合适的工具或在线服务:

    记下密码,稍后将证书导入 Azure Key Vault 时会用到该密码。

    有关 Azure Key Vault 证书的详细信息,请参阅 Azure Key Vault:证书”。

  3. 创建新的 Azure Key Vault 或使用 Azure 订阅中现有的密钥保管库。

  4. 在密钥保管库的“证书”区域中,导入 PFX 站点证书。 记录证书的指纹,稍后在应用的配置过程中会用到它。

  5. 在 Azure Key Vault 中,生成新的自签名证书以用于 Identity 服务器令牌签名。 给出证书的“证书名称”以及“使用者” 。 “使用者”指定为 CN={COMMON NAME},其中 {COMMON NAME} 占位符是证书的公用名。 公用名可以是任意字母数字字符串。 例如 CN=IdentityServerSigning 就是一个有效的证书“使用者”。 在“颁发策略”>“高级策略配置”中,使用默认设置。 记录证书的指纹,稍后在应用的配置过程中会用到它。

  6. 导航到 Azure 门户中的 Azure 应用服务,并使用以下配置创建新的应用服务:

    • 将“发布”设置为 Code
    • 将“运行时堆栈”设置为应用的运行时。
    • 对于“Sku 和大小”,请确认应用服务层级不低于 Basic B1。 应用服务需要 Basic B1 或更高的服务层级才能使用自定义域。
  7. 在 Azure 创建应用服务后,请打开应用的“配置”并添加一个新的应用程序设置,该设置指定之前记录的证书指纹。 应用设置密钥为 WEBSITE_LOAD_CERTIFICATES。 使用逗号分隔应用设置值中的证书指纹,如下面的示例所示:

    • 键:WEBSITE_LOAD_CERTIFICATES
    • 值:57443A552A46DB...D55E28D412B943565,29F43A772CB6AF...1D04F0C67F85FB0B1

    在 Azure 门户中,可通过两个步骤保存应用设置:保存 WEBSITE_LOAD_CERTIFICATES 键-值设置,然后选择边栏选项卡顶部的“保存”按钮。

  8. 选择应用的“TLS/SSL 设置”。 选择“私钥证书(.pfx)”。 使用“导入 Key Vault 证书”过程。 完成两次流程,导入用于 HTTPS 通信的站点证书以及站点的自签名 Identity 服务器令牌签名证书。

  9. 导航到“自定义域”边栏选项卡。 在域注册机构的网站上,使用 IP 地址和自定义域验证 ID 来配置域 。 域配置通常包括:

    • 一个 A 记录,主机为 @,以及来自 Azure 门户的 IP 地址值 。
    • 一个 TXT 记录,主机为 asuid,以及由 Azure 生成且由 Azure 门户提供的验证 ID 的值 。

    请确保将更改正确地保存到域注册机构的网站上。 部分注册机构网站需要执行两个步骤来保存域记录:先单独保存一条或多条记录,然后使用单独的按钮更新域的注册。

  10. 返回到 Azure 门户中的“自定义域”边栏选项卡。 选择“添加自定义域”。 选择“A 记录”选项。 提供域并选择“验证”。 如果域记录正确并跨 Internet 传播,则门户允许选择“添加自定义域”按钮。

    域注册机构处理域注册更改后,可能需要几天的时间才能让更改在 Internet 域名服务器 (DNS) 之间传播。 如果域记录在三个工作日内未更新,请确认是否已通过域注册机构正确设置记录,并与其客户支持部门联系。

  11. 在“自定义域”边栏选项卡中,域的“SSL STATE”被标记为 Not Secure 。 选择“添加绑定”链接。 从密钥保管库中选择站点 HTTPS 证书以绑定自定义域。

  12. 在 Visual Studio 中,打开 Server 项目的应用设置文件(appsettings.jsonappsettings.Production.json)。 在 Identity 服务器配置中,添加以下 Key 部分。 为 Name 密钥指定自签名证书使用者。 在以下示例中,密钥保管库中分配的证书公用名为 IdentityServerSigning,它生成的使用者为 CN=IdentityServerSigning

    "IdentityServer": {
    
      ...
    
      "Key": {
        "Type": "Store",
        "StoreName": "My",
        "StoreLocation": "CurrentUser",
        "Name": "CN=IdentityServerSigning"
      }
    },
    
  13. 在 Visual Studio 中,创建用于“Server”项目的 Azure 应用服务发布配置文件。 在菜单栏中,依次选择:“生成”>“发布”>“新建”>“Azure”>“Azure 应用服务”(Windows 或 Linux) 。 Visual Studio 连接到 Azure 订阅后,可以按资源类型来设置 Azure 资源的视图 。 在“Web 应用”列表中导航,查找应用的应用服务并将其选中。 选择“完成” 。

  14. 当 Visual Studio 返回到“发布”窗口时,会自动检测密钥保管库和 SQL Server 数据库服务依赖关系。

    对于密钥保管库服务,不需要对默认设置进行配置更改。

    为了进行测试,应用的本地 SQLite 数据库(默认情况下由 Blazor 模板配置)可以与应用一起部署,而无需进行其他配置。 本文不讨论在生产环境中为 Identity 服务器配置其他数据库的情况。 有关详细信息,请参阅以下文档集中的数据库资源:

  15. 在窗口顶部的部署配置文件名称下,选择“编辑”链接。 将“目标 URL”更改为站点的自定义域 URL(例如 https://www.contoso.com)。 保存设置。

  16. 发布应用。 Visual Studio 将打开一个浏览器窗口,并请求其自定义域所对应的站点。

Azure 文档包含有关在应用服务中通过 TLS 绑定使用 Azure 服务和自定义域的其他详细信息,包括有关使用 CNAME 记录而不是 A 记录的信息。 有关更多信息,请参见以下资源:

建议在 Azure 门户中,当应用、应用程序配置或 Azure 服务发生更改后,为每个应用测试运行使用新的专用模式浏览器窗口(例如 Microsoft Edge InPrivate 模式或 Google Chrome Incognito 模式)。 在测试站点时,即使站点的配置是正确的,前一个测试运行中的延迟 cookie 仍然可能导致身份验证失败或授权失败。 若要详细了解如何将 Visual Studio 配置为针对每个测试运行打开新的私密浏览器窗口,请参阅 Cookie 和站点数据部分。

如果在 Azure 门户中更改了应用服务配置,则更新通常会快速生效,但不会立即生效。 有时,必须等待一小段时间来让应用服务重新启动,配置更改才能生效。

若要排查 Identity 服务器密钥签名证书加载问题,请在 Azure 门户 Kudu PowerShell 命令行界面中执行以下命令。 该命令提供应用可在 CurrentUser>My 证书存储中访问的证书列表。 调试应用时,输出包括非常有用的证书使用者和指纹:

Get-ChildItem -path Cert:\CurrentUser\My -Recurse | Format-List DnsNameList, Subject, Thumbprint, EnhancedKeyUsageList

疑难解答

日志记录

若要为 Blazor WebAssembly 身份验证启用调试或跟踪日志记录,请参阅 ASP.NET CoreBlazor 日志记录客户端身份验证日志记录部分,其中项目版本选择器设置为 ASP.NET Core 7.0 或更高版本。

常见错误

  • 应用或 Identity 提供者 (IP) 配置错误

    最常见的错误是因为配置不正确导致的。 下面是几个示例:

    • 根据具体情景的要求,缺少或不正确的颁发机构、实例、租户 ID、租户域、客户端 ID 或重定向 URI 会阻止应用对客户端进行身份验证。
    • 不正确的请求范围会阻止客户端访问服务器 Web API 终结点。
    • 服务器 API 权限不正确或缺失会阻止客户端访问服务器 Web API 终结点。
    • 在不同于 IP 应用注册的重定向 URI 中配置的应用的端口运行应用。 请注意,Microsoft Entra ID 和在 localhost 开发测试地址上运行的应用不需要端口,但应用的端口配置和运行应用的端口必须与非 localhost 地址匹配。

    本文指导的配置部分显示了正确的配置示例。 请仔细查看本文的每个部分,以查找应用和 IP 配置错误。

    如果配置看起来是正确的:

    • 分析应用程序日志。

    • 通过浏览器的开发人员工具,检查客户端应用和 IP 或服务器应用之间的网络流量。 通常,在发出请求后,IP 或服务器应用会向客户端返回一条确切的错误消息或包含线索的消息,其中指出了导致问题的原因。 有关开发人员工具指导,请参阅以下文章:

    • 对于使用 JS ON Web 令牌 (JWT) 的 Blazor 版本,请解码用于对客户端进行身份验证或访问服务器 Web API 的令牌内容,具体取决于问题发生的位置。 有关详细信息,请参阅检查 JSON Web 令牌 (JWT) 的内容

    文档团队会响应文章中的文档反馈和 bug(从“此页面”反馈部分提交问题),但无法提供产品支持。 可以借助多个公共支持论坛来帮助排查应用问题。 建议如下:

    上述论坛并非 Microsoft 所拥有或者不受 Microsoft 控制。

    对于非安全、非敏感且非机密的可重现框架 bug 报告,请向 ASP.NET Core 产品团队提交问题。 请务必先彻底调查问题原因,并确定无法自行解决问题,在公共支持论坛的社区帮助下同样无法解决问题后,再向该产品团队提交问题。 如果应用问题是由简单的配置错误引起或涉及第三方服务,该产品团队无法对此进行故障排除。 如果报告包含敏感或机密内容,或者描述了可能会被攻击者利用的潜在产品安全缺陷,请参阅报告安全问题和 bug(dotnet/aspnetcore GitHub 存储库)

  • ME-ID 的客户端未获得授权

    信息:Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2] 授权失败。 不符合以下要求:DenyAnonymousAuthorizationRequirement:要求用户经过身份验证。

    ME-ID 返回的登录回叫错误:

    • 错误:unauthorized_client
    • 说明:AADB2C90058: The provided application is not configured to allow public clients.

    若要解决该错误:

    1. 在 Azure 门户中访问应用的清单
    2. allowPublicClient 属性设置为 nulltrue

Cookie 和站点数据

Cookie 和站点数据在经过应用更新后仍可保持不变,并且会干扰测试和故障排除。 在更改应用代码、更改提供程序的用户帐户或更改提供程序的应用配置时,请清除以下内容:

  • 用户登录 cookie
  • 应用 cookie
  • 缓存和存储的站点数据

防止存留的 cookie 和站点数据干扰测试和故障排除的一种方法是:

  • 配置浏览器
    • 使用浏览器测试是否可以配置为在每次关闭浏览器时删除所有 cookie 和站点数据。
    • 对于应用、测试用户或提供程序配置的任何更改,请确保浏览器是手动关闭的或由 IDE 关闭的。
  • 在 Visual Studio 中使用自定义命令以 InPrivate 或无痕模式打开浏览器:
    • 通过 Visual Studio 的“运行”按钮打开“浏览工具”对话框 。
    • 选择“添加”按钮。
    • 在“程序”字段中提供浏览器的路径。 以下可执行路径是适用于 Windows 10 的典型安装位置。 如果浏览器安装在其他位置,或者未使用 Windows 10,请提供浏览器可执行文件的路径。
      • Microsoft Edge:C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
      • Google Chrome:C:\Program Files (x86)\Google\Chrome\Application\chrome.exe
      • Mozilla Firefox:C:\Program Files\Mozilla Firefox\firefox.exe
    • 在“参数”字段中,提供浏览器用来在 InPrivate 或无痕模式下执行打开操作的命令行选项。 某些浏览器需要应用的 URL。
      • Microsoft Edge:请使用 -inprivate
      • Google Chrome:使用 --incognito --new-window {URL},其中占位符 {URL} 是要打开的 URL(例如,https://localhost:5001)。
      • Mozilla Firefox:使用 -private -url {URL},其中占位符 {URL} 是要打开的 URL(例如,https://localhost:5001)。
    • 在“友好名称”字段中提供名称。 例如 Firefox Auth Testing
    • 选择“确定”按钮。
    • 若要避免在每次迭代使用应用进行测试时必须选择浏览器配置文件,请使用“设置为默认值”按钮将配置文件设置为默认值。
    • 对于应用、测试用户或提供程序配置的任何更改,请确保浏览器是由 IDE 关闭的。

应用升级

正常运行的应用在开发计算机上升级 .NET Core SDK 或在应用内更改包版本后可能会立即出现故障。 在某些情况下,不同的包可能在执行主要升级时中断应用。 可以按照以下说明来修复其中大部分问题:

  1. 从命令 shell 执行 dotnet nuget locals all --clear 以清空本地系统的 NuGet 包缓存。
  2. 删除项目的 binobj 文件夹。
  3. 还原并重新生成项目。
  4. 在重新部署应用前,在服务器上删除部署文件夹中的所有文件。

注意

不支持使用与应用的目标框架不兼容的包版本。 有关包的信息,请使用 NuGet GalleryFuGet Package Explorer 进行了解。

运行 Server 应用

在对托管的 Blazor WebAssembly 解决方案进行测试和故障排除时,请确保从 Server 项目运行应用。

检查用户

可直接在应用中使用以下 User 组件或将其用作进一步自定义的基础。

User.razor

@page "/user"
@attribute [Authorize]
@using System.Text.Json
@using System.Security.Claims
@inject IAccessTokenProvider AuthorizationService

<h1>@AuthenticatedUser?.Identity?.Name</h1>

<h2>Claims</h2>

@foreach (var claim in AuthenticatedUser?.Claims ?? Array.Empty<Claim>())
{
    <p class="claim">@(claim.Type): @claim.Value</p>
}

<h2>Access token</h2>

<p id="access-token">@AccessToken?.Value</p>

<h2>Access token claims</h2>

@foreach (var claim in GetAccessTokenClaims())
{
    <p>@(claim.Key): @claim.Value.ToString()</p>
}

@if (AccessToken != null)
{
    <h2>Access token expires</h2>

    <p>Current time: <span id="current-time">@DateTimeOffset.Now</span></p>
    <p id="access-token-expires">@AccessToken.Expires</p>

    <h2>Access token granted scopes (as reported by the API)</h2>

    @foreach (var scope in AccessToken.GrantedScopes)
    {
        <p>Scope: @scope</p>
    }
}

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

    public ClaimsPrincipal AuthenticatedUser { get; set; }
    public AccessToken AccessToken { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();
        var state = await AuthenticationState;
        var accessTokenResult = await AuthorizationService.RequestAccessToken();

        if (!accessTokenResult.TryGetToken(out var token))
        {
            throw new InvalidOperationException(
                "Failed to provision the access token.");
        }

        AccessToken = token;

        AuthenticatedUser = state.User;
    }

    protected IDictionary<string, object> GetAccessTokenClaims()
    {
        if (AccessToken == null)
        {
            return new Dictionary<string, object>();
        }

        // header.payload.signature
        var payload = AccessToken.Value.Split(".")[1];
        var base64Payload = payload.Replace('-', '+').Replace('_', '/')
            .PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');

        return JsonSerializer.Deserialize<IDictionary<string, object>>(
            Convert.FromBase64String(base64Payload));
    }
}

检查 JSON Web 令牌 (JWT) 的内容

若要对 JSON Web 令牌 (JWT) 进行解码,请使用 Microsoft 的 jwt.ms 工具。 UI 中的值永远不会离开浏览器。

编码 JWT 示例(为便于显示,已经缩短):

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1j ... bQdHBHGcQQRbW7Wmo6SWYG4V_bU55Ug_PW4pLPr20tTS8Ct7_uwy9DWrzCMzpD-EiwT5IjXwlGX3IXVjHIlX50IVIydBoPQtadvT7saKo1G5Jmutgq41o-dmz6-yBMKV2_nXA25Q

工具针对向 Azure AAD B2C 进行身份验证的应用解码的 JWT 示例:

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk"
}.{
  "exp": 1610059429,
  "nbf": 1610055829,
  "ver": "1.0",
  "iss": "https://mysiteb2c.b2clogin.com/5cc15ea8-a296-4aa3-97e4-226dcc9ad298/v2.0/",
  "sub": "5ee963fb-24d6-4d72-a1b6-889c6e2c7438",
  "aud": "70bde375-fce3-4b82-984a-b247d823a03f",
  "nonce": "b2641f54-8dc4-42ca-97ea-7f12ff4af871",
  "iat": 1610055829,
  "auth_time": 1610055822,
  "idp": "idp.com",
  "tfp": "B2C_1_signupsignin"
}.[Signature]

其他资源