基于 Cookie 的身份验证和 Microsoft Identity 的 Blazor WASM 托管应用





5.00/5 (6投票s)
构建带有身份验证和授权的 Blazor WASM 托管应用
引言
在本文中,我们将了解如何构建一个 Blazor WASM 托管应用程序,该应用程序需要使用 Microsoft Identity 框架进行身份验证和授权,而不是默认 Blazor 模板附带的 IdentityServer
。
我们将重点关注基于 Cookie 的身份验证,对于大多数与后端一起托管并且不需要单一登录的 WASM 托管应用程序来说,这种身份验证已足够。
另一个重要因素是 Blazor WASM 应用程序中默认身份验证方案缺乏预呈现支持(ASP.NET Core Blazor WebAssembly 附加安全场景 | Microsoft Learn)。
考虑因素
由于 Blazor WASM 被视为 SPA 项目,因此与服务器的通信是通过 API 调用完成的。在大多数情况下,需要身份验证的 API 端点调用是使用令牌完成的。在本例中,我们将不得不覆盖此行为,允许 API 调用以 Cookie 进行身份验证,同时确保我们的后端支持此功能。
登录通过重定向到默认的 ASP.NET Core Identity UI 来处理,该 UI 托管在我们的后端。由于 Blazor WASM 和 ASP.NET Core 后端托管在同一个 URL 上,因此 Cookie 可以在后端和我们的 WASM 前端之间共享。
在 Visual Studio 中创建我们的项目
在 Visual Studio 2022 中创建您的项目。请确保选择 Blazor WebAssembly
模板,并且身份验证设置为无……我们将稍后手动添加身份验证。
安装 Microsoft Identity UI
使用包管理器控制台安装 Identity UI
Install-Package Microsoft.AspNetCore.Identity.UI
在此示例中,我们将使用 MS SQL 来存储我们的用户。您可以随意使用任何其他提供程序,甚至实现必要的接口来根据您的基础架构自定义 Identity
。您可以在此处找到有关如何执行此操作的更多信息。
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools
将 Microsoft Identity 默认 UI 添加到我们的服务器项目
创建您的 Identity DbContext
并将其注册到 ASP.NET Core 默认依赖项管理器中。
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
在 appsettings.json 中创建您的数据库连接,并在注册 DBContext
时设置此项。
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException
("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
注册默认的 ASP.NET Identity
存储
builder.Services.AddDefaultIdentity<IdentityUser>
(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
由于我们将使用基于 Cookie 的身份验证,并且 Cookie 将传递给我们的 Blazor WASM 项目中的 HttpClient
,因此我们必须确保未经验证的响应返回状态代码 401 而不是 302(重定向),并将重定向位置传递给 Location
标头,Blazor 客户端可以使用该标头重定向到登录 URL。
builder.Services.ConfigureApplicationCookie(options =>
{
options.Events.OnRedirectToLogin = context =>
{
context.Response.Headers["Location"] = context.RedirectUri;
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
在 Server
项目的 Pages/Shared 文件夹下创建部分登录页面。
指示您的后端在路由后使用授权
app.UseRouting();
app.UseAuthorization();
此时,我们的后端已配置为使用 Microsoft Identity 框架进行授权,该框架将在用户使用内置 UI 登录到服务器后端时生成 Cookie。
您可以通过运行应用程序并手动访问 URL:/Identity/Account/Login 来验证 Identity UI 是否可用。
由于身份验证发生在我们的后端(服务器应用程序),因此我们需要一种方法将声明和基本用户信息与我们的客户端 WASM 应用程序共享。
为了处理这个问题,我们需要一个 API 端点,该端点将用户配置文件返回给我们的客户端应用程序。
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
private readonly ApplicationDbContext _applicationDbContext;
public AuthController(ApplicationDbContext applicationDbContext)
{
_applicationDbContext = applicationDbContext;
}
[Authorize]
[HttpGet]
[Route("user-profile")]
public async Task<IActionResult> UserProfileAsync()
{
string userId = HttpContext.User.Claims.Where
(_ => _.Type == ClaimTypes.NameIdentifier).Select(_ => _.Value).First();
var userProfile = await _applicationDbContext.Users.Where(_ => _.Id == userId)
.Select(_ => new UserProfileDto
{
UserId = _.Id,
Email = _.Email,
Name = _.UserName,
}).FirstOrDefaultAsync();
return Ok(userProfile);
}
}
您应该在默认的共享项目中添加 UserProfile
DTO,因为这应该可以从服务器和客户端应用程序访问。
public class UserProfileDto
{
public string UserId { get; set; }
public string? Email { get; set; }
public string? Name { get; set; }
}
将身份验证 Cookie 传递给 Blazor WASM 客户端应用程序中的 HttpClient 并设置身份验证状态
由于我们的服务器应用程序现在需要身份验证,并且身份验证设置为需要身份验证 Cookie,因此您必须将 Cookie 传递给我们用于执行到服务器的 API 请求的 HttpClient
。
在开始之前,请确保在您的客户端 WASM 项目中安装了授权和 HTTP 扩展。
Install-Package Microsoft.AspNetCore.Components.WebAssembly.Authentication
Install-Package Microsoft.Extensions.Http
要将 Cookie 注入我们的 HttpClient
,我们需要一个 Handler,它将指示 HttpClient
从存储在我们浏览器中的 Cookie 设置凭据。
public class CookieHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage>
SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
return await base.SendAsync(request, cancellationToken);
}
}
然后,我们需要在我们的容器中注册 HttpClient
和 CookieHandler
。
builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<CookieHandler>();
builder.Services.AddHttpClient("BlazorWasmAppCookieAuth.ServerAPI",
client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler<CookieHandler>();
最后,我们需要提供我们对 AuthenticationStateProvider
的实现,该实现现在根据从服务器检索到的用户配置文件设置 ClaimsPrincipal
。
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
private readonly IHttpClientFactory _httpClientFactory;
private UserProfileDto? _userProfileDto;
public CustomAuthStateProvider(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if(_userProfileDto == null)
{
var client = _httpClientFactory.CreateClient
("BlazorWasmAppCookieAuth.ServerAPI");
var response = await client.GetAsync("/api/Auth/user-profile");
if (response.IsSuccessStatusCode)
{
_userProfileDto =
await response.Content.ReadFromJsonAsync<UserProfileDto>();
var identity = new ClaimsIdentity(new[]{
new Claim(ClaimTypes.Email, _userProfileDto?.Email ?? ""),
new Claim(ClaimTypes.Name, _userProfileDto ?.Name ?? ""),
new Claim("UserId", _userProfileDto?.ToString() ?? "")
}, "AuthCookie");
claimsPrincipal = new ClaimsPrincipal(identity);
//NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
return new AuthenticationState(claimsPrincipal);
}
public void SignOut()
{
_userProfileDto = null;
claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
我们覆盖 GetAuthenticationStateAsync
方法,在该方法中,我们在用户在服务器应用程序中进行身份验证后,调用之前发布的服务器 API 来读取用户信息。如果 API 客户端返回 401 消息,我们将设置一个空的 ClaimsPrincipal
,这表示当前没有用户登录。
您可以选择实现 SignOut
方法,然后可以使用该方法从客户端应用程序注销用户,而无需重定向到服务器 IdentityUI
注销页面。为此,我们必须在 Auth
控制器中实现 Logout
端点,以从服务器注销用户并销毁身份验证 Cookie。
CustomAuthStateProvider
应与核心身份验证类一起在您的客户端依赖项容器中注册。
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddAuthorizationCore();
在您的 App.razor
组件中提供 CascadingAuthenticationState
。
以及 RedirectToLogin.razor
组件。
请注意,在这种情况下,我们正在覆盖 OnAfterRender
方法而不是 OnInitialized
,这将支持预呈现。
启用预呈现
Blazor WASM 托管应用程序的一个重要限制是它们的初始加载时间很长。这是因为浏览器首先必须下载应用程序的二进制文件,然后才能使用这些文件来呈现页面。由于呈现逻辑依赖于 C# 代码并最终依赖于 wasm 二进制文件,因此在应用程序开始呈现 html 之前,需要下载大量二进制文件。当然,这些二进制文件可以在浏览器中缓存以供将来加载,但这取决于客户端,我们还必须考虑发布应用程序更新的情况。在这种情况下,客户端需要重新下载二进制文件。尽管对于已缓存的应用程序来说,这可能会在后台发生,但这将需要客户端重新加载应用程序。
值得庆幸的是,这个问题可以通过预呈现来解决!预呈现确保客户端立即获得页面 html,同时浏览器在后台开始下载 wasm 二进制文件,这些文件将用于后续的请求。本质上,这意味着 C# 应用程序逻辑在服务器而不是客户端上执行,并且服务器以最终 html 进行响应。应用程序操作和交互性仍然由 wasm 二进制文件在客户端处理。预呈现也有助于 SEO,因为搜索引擎可以使用初始 html 响应来计算页面排名。
要启用预呈现,我们必须确保我们的客户端 wasm 代码也可以在服务器上执行,因此我们需要在服务器依赖项容器中包含所有客户端服务,或者通过注册相应的服务器实现或服务来提供等效的服务。
替换应用程序入口点
要启用预呈现,我们首先必须将入口路由从客户端移动到服务器。
为此,我们必须将默认回退页面从客户端 wasm 项目中的 index.html 更改为服务器可以提供的页面。只需在服务器 program.cs 类中进行如下代码更改。
//app.MapFallbackToFile("index.html");
app.MapFallbackToPage("/_Host");
创建 _Host 页面
对于 _Host
页面,只需在服务器项目的 pages 文件夹下添加一个新的 Razor 页面,并将 html 代码从 index.html 文件粘贴进去。Microsoft 建议从空的服务器项目获取默认模板,我倾向于复制我的 index.html 页面的内容,以确保我没有跳过任何我添加到项目中的客户端 js 库或 css。
然后,您可以将页面路由“/”添加到 using
语句中,以指示这是默认路由,并将您的客户端命名空间添加到其中。这将使您能够将应用程序 div
标签(应用程序呈现的占位符)替换为呈现客户端应用程序的组件。组件的渲染模式应设置为“WebAssemblyPrerendered
”,这会指示我们的服务器应用程序预呈现该组件并以 html 进行响应。
最终代码将如下所示。
@page "/"
@using BlazorWasmAppCookieAuth.Client
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>BlazorWasmAppCookieAuth</title>
<base href="/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="BlazorWasmAppCookieAuth.Client.styles.css" rel="stylesheet" />
<link href="manifest.json" rel="manifest" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
</head>
<body>
<component type="typeof(BlazorWasmAppCookieAuth.Client.App)"
render-mode="WebAssemblyPrerendered" />
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script>
</body>
</html>
然后,您应该从客户端 Program.cs 类中注释掉下面显示的两个行,这两行定义了客户端将渲染应用程序的 div
的 id
。
var builder = WebAssemblyHostBuilder.CreateDefault(args);
//builder.RootComponents.Add<App>("#app");
//builder.RootComponents.Add<HeadOutlet>("head::after");
在服务器上注册特定于客户端的服务
最后,我们需要在服务器项目中注册我们的客户端服务,因为服务器在预呈现以生成要发送到浏览器的 html 代码时将使用这些服务。
我们需要注册的第一个类是 IHttpClientFactory
,它用于创建用于执行 API 调用的 HttpClient
。现在,由于此 HttpClient
将执行到同一应用程序的 API 调用,而不是执行到外部应用程序的 API 调用,因此我们可以注册一个不同的服务类,该类将直接从 DbContext
加载数据,而不是执行 API 调用,并有一个不同的客户端实现来通过 API 获取数据。为了简单起见,我在服务器上注册了 HttpClient
,但请注意,这不是最佳方案。
然后我们注册我们的自定义身份验证提供程序,该提供程序处理 Cookie 身份验证和重定向到登录页面。
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient
("BlazorWasmAppCookieAuth.ServerAPI"));
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<CookieHandler>();
builder.Services.AddHttpClient("BlazorWasmAppCookieAuth.ServerAPI",
client => client.BaseAddress = new Uri("https://:7182"))
.AddHttpMessageHandler<CookieHandler>();
结束语
这个解决方案帮助我极大地提升了 Blazor wasm 应用程序的体验。我相信 Blazor 的发展方向是正确的,我迫不及待地等待 .NET 8.0 中的流式渲染与 SSR 以及 Blazor 宣布的其他功能。
完整解决方案
启用预呈现的完整解决方案可以在此处找到。
历史
- 2023年6月23日:初始版本
- 2023年7月20日:预呈现更新