使用 ASP.NET Core Identity 保护 ASP.NET Core Blazor WebAssembly

按照本文中的指导,可以使用 ASP.NET Core Identity 保护独立 Blazor WebAssembly 应用。

用于注册、登录和注销的终结点

不要使用适用于 SPA 和 Blazor 应用的 ASP.NET Core Identity 提供的默认 UI(基于 Razor 页面),而应该调用后端 API 中的 MapIdentityApi,以添加 JSON API 终结点来让用户使用 ASP.NET Core Identity 注册和登录。 Identity API 终结点还支持高级功能,例如双因素身份验证和电子邮件验证。

在客户端上调用 /register 终结点,以使用用户的电子邮件地址和密码来注册用户:

var result = await _httpClient.PostAsJsonAsync(
    "register", new
    {
        email,
        password
    });

在客户端上,在将 useCookies 查询字符串设置为 true 的情况下,使用 /login 终结点通过 cookie 身份验证将用户登录:

var result = await _httpClient.PostAsJsonAsync(
    "login?useCookies=true", new
    {
        email,
        password
    });

后端服务器 API 通过调用身份验证生成器上的 AddIdentityCookies 来建立 cookie 身份验证:

builder.Services
    .AddAuthentication(IdentityConstants.ApplicationScheme)
    .AddIdentityCookies();

令牌身份验证

对于某些客户端不支持 cookie 的本机和移动方案,登录 API 将提供一个参数用于请求令牌。 将颁发一个可用于对后续请求进行身份验证的自定义令牌(ASP.NET Core Identity 平台专有的令牌)。 该令牌应作为持有者令牌在 Authorization 标头中传递。 此外还会提供刷新令牌。 此令牌允许应用在旧令牌过期时请求新令牌,而无需强制用户再次登录。

该令牌不是标准的 JSON Web 令牌 (JWT)。 使用自定义令牌是有意而为的,因为内置 Identity API 主要用于简单方案。 令牌选项不是功能齐全的标识服务提供程序或令牌服务器,而是一个替代 cookie 的选项,供无法使用 cookie 的客户端使用。

以下指南开始使用登录 API 实现基于令牌的身份验证。 完成该实现需要自定义代码。 有关详细信息,请参阅使用 Identity 保护 SPA 的 Web API 后端

服务器 API 使用 AddBearerToken 扩展方法设置持有者令牌身份验证,而不是由后端服务器 API 通过调用身份验证生成器上的 AddIdentityCookies 来建立 cookie 身份验证。 使用 IdentityConstants.BearerScheme 指定持有者身份验证令牌的方案。

Backend/Program.cs 中,将身份验证服务和配置更改为以下内容:

builder.Services
    .AddAuthentication()
    .AddBearerToken(IdentityConstants.BearerScheme);

BlazorWasmAuth/Identity/CookieAuthenticationStateProvider.cs 中,删除 CookieAuthenticationStateProviderLoginAsync 方法中的 useCookies 查询字符串参数:

- login?useCookies=true
+ login

此时,必须提供自定义代码来在客户端分析 AccessTokenResponse 并管理访问和刷新令牌。 有关详细信息,请参阅使用 Identity 保护 SPA 的 Web API 后端

其他 Identity 方案

有关 API 提供的其他 Identity 方案,请参阅使用 Identity 保护单页应用程序的 Web API 后端

  • 保护所选的终结点
  • 令牌身份验证
  • 双因素身份验证 (2FA)
  • 恢复代码
  • 用户信息管理

示例应用

在本文中,示例应用充当通过后端 Web API 访问 ASP.NET Core Identity 的独立 Blazor WebAssembly 应用的参考。 演示包括两个应用:

  • Backend:一个后端 Web API 应用,用于维护 ASP.NET Core Identity 的用户标识存储。
  • BlazorWasmAuth:具有用户身份验证的独立 Blazor WebAssembly 前端应用。

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

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

后端 Web API 应用包和代码

后端 Web API 应用为 ASP.NET Core Identity 维护用户标识存储。

应用使用以下 NuGet 包:

如果应用要使用与内存中提供程序不同的 EF Core 数据库提供程序,请不要在应用中为 Microsoft.EntityFrameworkCore.InMemory 创建包引用。

在应用的项目文件 (.csproj) 中,固定全球化已配置。

示例应用代码

应用设置配置后端和前端 URL:

  • Backend 应用 (BackendUrl):https://localhost:7211
  • BlazorWasmAuth 应用 (FrontendUrl):https://localhost:7171

Backend.http 文件可用于测试天气数据请求。 请注意,BlazorWasmAuth 应用必须正在运行以测试终结点,并且终结点已硬编码到文件中。 有关详细信息,请参阅在 Visual Studio 2022 中使用 .http 文件

在应用的 Program 文件中找到以下设置和配置。

通过调用 AddAuthenticationAddIdentityCookies 来添加具有 cookie 身份验证的用户标识。 授权检查的服务是通过调用 AddAuthorizationBuilder 添加的。

仅建议用于演示,应用使用 EF Core 内存中数据库提供程序进行数据库上下文注册 (AddDbContext)。 内存中数据库提供程序使重启应用并测试注册和登录用户流变得容易。 每次运行都以新的数据库始,但该应用包括测试用户种子设定演示代码,本文稍后将对此进行介绍。 如果数据库更改为 SQLite,则会在会话之间保存用户,但必须通过迁移创建数据库,如 EF Core 入门教程中所示。 可以将其他关系提供程序(例如 SQL Server)用于生产代码。

配置 Identity 以使用 EF Core 数据库,并通过调用AddIdentityCoreAddEntityFrameworkStoresAddApiEndpoints 公开 Identity 终结点。

建立跨源资源共享 (CORS) 策略,以允许来自前端和后端应用的请求。 如果应用设置未提供回退 URL,则会为 CORS 策略配置回退 URL:

  • Backend 应用 (BackendUrl):https://localhost:5001
  • BlazorWasmAuth 应用 (FrontendUrl):https://localhost:5002

包括适用于 Swagger/OpenAPI 的服务和终结点以用于 Web API 文档和开发测试。 有关 NSwag 的详细信息,请参阅 NSwag 和 ASP.NET Core 入门

用户角色声明从 /roles 终结点的最小 API 发送。

通过调用 MapIdentityApi<AppUser>() 为 Identity 终结点映射路由。

在中间件管道中配置注销终结点 (/Logout) 以注销用户。

若要保护终结点,请将 RequireAuthorization 扩展方法添加到路由定义。 对于控制器,请将 [Authorize] 属性添加到控制器或操作。

有关初始化和配置 DbContext 实例的基本模式的详细信息,请参阅 EF Core 文档中的 DbContext 生存期、配置和初始化

前端独立 Blazor WebAssembly 应用包和代码

独立 Blazor WebAssembly 前端应用演示了访问专用网页的用户身份验证和授权。

应用使用以下 NuGet 包:

示例应用代码

Models 文件夹包含应用的模型:

IAccountManagement 接口 (Identity/CookieHandler.cs) 提供帐户管理服务。

CookieAuthenticationStateProvider 类 (Identity/CookieAuthenticationStateProvider.cs) 处理基于 cookie 的身份验证的状态,并提供由 IAccountManagement 接口描述的帐户管理服务实现。 LoginAsync 方法通过 useCookies 查询字符串值 true 显式启用 cookie 身份验证。 该类还管理为经过身份验证的用户创建的角色声明。

CookieHandler 类 (Identity/CookieHandler.cs) 可确保将 cookie 凭据与每个请求一起发送到后端 Web API,后者处理 Identity 和维护 Identity 数据存储。

wwwroot/appsettings.file 提供后端和前端 URL 终结点。

App 组件将身份验证状态公开为级联参数。 有关详细信息,请参阅 ASP.NET Core Blazor 身份验证和授权

MainLayout 组件NavMenu 组件使用 AuthorizeView 组件根据用户的身份验证状态选择性地显示内容。

以下组件处理常见的用户身份验证任务,并利用 IAccountManagement 服务:

PrivatePage 组件 (Components/Pages/PrivatePage.razor) 需要身份验证并显示用户的声明。

Program 文件 (Program.cs) 中提供了服务和配置:

  • cookie 处理程序注册为作用域服务。
  • 授权服务已注册。
  • 自定义身份验证状态提供程序注册为作用域服务。
  • 帐户管理接口 (IAccountManagement) 已注册。
  • 为已注册的 HTTP 客户端实例配置基本主机 URL。
  • 为已注册的 HTTP 客户端实例配置基本后端 URL,该实例用于与后端 Web API 进行身份验证交互。 HTTP 客户端使用 cookie 处理程序来确保 cookie 凭据随每个请求一起发送。

当用户的身份验证状态发生更改时,请调用 AuthenticationStateProvider.NotifyAuthenticationStateChanged。 有关示例,请参阅 CookieAuthenticationStateProvider 类 (Identity/CookieAuthenticationStateProvider.cs)LoginAsyncLogoutAsync 方法。

警告

AuthorizeView 组件根据用户是否获得授权来选择性地显示 UI 内容。 无需身份验证即可发现放置在 AuthorizeView 组件中的 Blazor WebAssembly 应用中的所有内容,因此身份验证成功后,应从基于后端服务器的 Web API 获取敏感内容。 有关更多信息,请参见以下资源:

测试用户种子设定演示

SeedData 类 (SeedData.cs) 演示了如何创建测试用户进行开发。 名为 Leela 的测试用户使用电子邮件地址 leela@contoso.com 登录到应用。 该用户的密码设置为 Passw0rd!。 Leela 被授予 AdministratorManager 角色进行授权,使其可以访问位于 /private-manager-page 的管理员页面,但不能访问位于 /private-editor-page 的编辑器页面。

警告

切勿允许在生产环境中运行测试用户代码。 仅在 Program 文件的 Development 环境中调用 SeedData.InitializeAsync

if (builder.Environment.IsDevelopment())
{
    await using var scope = app.Services.CreateAsyncScope();
    await SeedData.InitializeAsync(scope.ServiceProvider);
}

角色

由于框架设计问题(dotnet/aspnetcore #50037),角色声明不会从 manage/info 终结点发送回,以便为 BlazorWasmAuth 应用的用户创建用户声明。 在 Backend 项目中对用户进行身份验证后,将通过CookieAuthenticationStateProvider 类(Identity/CookieAuthenticationStateProvider.csGetAuthenticationStateAsync 方法中的单独请求来独立管理角色声明。

CookieAuthenticationStateProvider 中,对 Backend 服务器 API 项目的 /roles 终结点上发出了角色请求。 通过调用 ReadAsStringAsync() 将响应读入字符串。 JsonSerializer.Deserialize 将字符串反序列化为自定义 RoleClaim 数组。 最后,将声明添加到用户的声明集合中。

Backend 服务器 API 的 Program 文件中,一个最小 API 管理 /roles 终结点。 RoleClaimType 的声明被选入匿名类型并序列化,以便和 TypedResults.Json 返回 BlazorWasmAuth 项目。

角色终结点需要通过调用 RequireAuthorization 进行授权。 如果决定不使用最小 API 来支持安全服务器 API 终结点的控制器,请确保在控制器或操作上设置[Authorize]属性

跨域托管(同站点配置)

示例应用配置为在同一域中托管这两个应用。 如果在与 BlazorWasmAuth 应用不同的域中托管 Backend 应用,请取消评论在 Backend 应用的 Program 文件中配置 cookie (ConfigureApplicationCookie) 的代码。 默认值为:

将值更改为:

- options.Cookie.SameSite = SameSiteMode.Lax;
- options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
+ options.Cookie.SameSite = SameSiteMode.None;
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;

有关同站点 cookie 设置的详细信息,请参阅以下资源:

防伪支持

只有 Backend 应用中的注销终结点 (/logout) 需要注意缓解跨网站请求伪造 (CSRF) 的威胁。

注销终结点会检查是否有空正文以防止 CSRF 攻击。 通过要求正文,请求必须从 JavaScript 发出,这是访问身份验证 cookie 的唯一方法。 基于表单的 POST 无法访问注销终结点。 这样可以防止恶意网站将用户注销。

此外,终结点受授权 (RequireAuthorization) 保护,以防止匿名访问。

BlazorWasmAuth 客户端应用只需在请求正文中传递一个空对象 {}

在注销终结点之外,仅当将表单数据提交到编码为 application/x-www-form-urlencodedmultipart/form-datatext/plain 的服务器时,才需要防伪缓解。 Blazor 在大多数情况下管理表单的 CSRF 缓解。 有关详细信息,请参阅 ASP.NET Core Blazor 身份验证和授权ASP.NET Core Blazor 表单概述

对具有 application/json 编码的内容且启用了 CORS 的其他服务器 API 终结点 (Web API) 的请求不需要 CSRF 保护。 这就是为什么 Backend 应用的数据处理 (/data-processing) 终结点不需要 CSRF 保护。 角色 (/roles) 终结点不需要 CSRF 保护,因为它是不修改任何状态的 GET 终结点。

疑难解答

日志记录

若要为 Blazor WebAssembly 身份验证启用调试或跟踪日志记录,请参阅 ASP.NET Core Blazor 日志记录

常见错误

检查每个项目的配置。 确认 URL 正确:

  • Backend 项目
    • appsettings.json
      • BackendUrl
      • FrontendUrl
    • Backend.http: Backend_HostAddress
  • BlazorWasmAuth 项目:wwwroot/appsettings.json
    • BackendUrl
    • FrontendUrl

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

  • 分析应用程序日志。

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

  • Google Chrome(Google 文档)

  • Microsoft Edge

  • Mozilla Firefox(Mozilla 文档)

文档团队对文档反馈和文章中的 bug 做出回应。 使用文章底部的创建文档问题链接创建问题。 该团队无法提供产品支持。 可以借助多个公共支持论坛来帮助排查应用问题。 建议如下:

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

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

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 进行了解。

检查用户的声明

要排查用户声明的问题,可直接在应用中使用以下 UserClaims 组件或将其用作进一步自定义的基础。

UserClaims.razor

@page "/user-claims"
@using System.Security.Claims
@attribute [Authorize]

<PageTitle>User Claims</PageTitle>

<h1>User Claims</h1>

**Name**: @AuthenticatedUser?.Identity?.Name

<h2>Claims</h2>

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

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

    public ClaimsPrincipal? AuthenticatedUser { get; set; }

    protected override async Task OnInitializedAsync()
    {
        if (AuthenticationState is not null)
        {
            var state = await AuthenticationState;
            AuthenticatedUser = state.User;
        }
    }
}

其他资源