65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2023年6月23日

CPOL

8分钟阅读

viewsIcon

18224

构建带有身份验证和授权的 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);
    }
}

然后,我们需要在我们的容器中注册 HttpClientCookieHandler

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 类中注释掉下面显示的两个行,这两行定义了客户端将渲染应用程序的 divid

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日:预呈现更新
Blazor WASM 托管应用,支持基于 Cookie 的身份验证和 Microsoft Identity - CodeProject - 代码之家
© . All rights reserved.