冻葱Tewi
文章26
标签47
分类2

文章分类

一言

【笔记】在 Blazor WebAssembly Hosted 中使用 Identity

【笔记】在 Blazor WebAssembly Hosted 中使用 Identity

记录了一些 Blazor WebAssembly 的基础知识和在其中使用 Identity 进行身份验证的知识点。好久不写博客了,手都生了。

注:本文中使用的 .NET 版本为 .NET 5.0(.NET Core 和 .NET Framework 合并的版本)

什么是 WebAssembly

顾名思义,WebAssembly(wasm)是一种实验性的 Web 低级编程语言,应用于浏览器的客户端,以提供比 JavaScript 更快速的便宜和运行。并允许开发者将自己喜欢的编程语言编译成 wasm 后在客户浏览器内以接近原生的性能运行。打破前端只有 JavaScript 的桎梏,让各种“后端”语言走上前台,走进用户的浏览器。

WebAssembly 由 W3C 发起,由 Mozilla,Google,Microsoft,Apple 分别代表四大浏览器牵头,从2017年起开始实验性支持。最终在 2019 年 12 月被 W3C 推荐,成为 Web 的第四种语言。

什么是 Blazor

Blazor 是 .NET 社区的 Web 应用解决方案,名字取自 Browser + Razor 之意,以 C# 和 HTML 创建 Web 应用。

Blazor 分为 Blazor Server 和 Blazor WebAssembly。Blazor Server 指托管于 Asp.Net Core 的服务器上的瘦客户端。绝大多数操作在服务器端进行,客户端指挥下载所需要的最小化的页面,并通过 SignalR 的连接更新浏览器中的界面。Blazor WebAssembly 是基于 wasm 的单页应用。初始加载内容远大于 Blazor Server,但之后的处理过程将在客户端硬件内完成,所以它具有更快的响应速度。

Blazor WebAssembly Hosted 是 Blazor WebAssembly with Asp.Net Core Hosted 架构的简称。他是由交给客户的 Blazor WebAssembly 作为前端应用,同时后端由 Asp.Net Core Api 进行数据交换的一种结构。

使用 Identity 进行身份验证

新建项目

.NET 官方推荐的身份验证方法是通过 Microsoft.AspNetCore.Identity 和 Identity Server 4 配合 Jwt 来进行用户身份的验证。

新建工程时,将身份验证和 Asp.Net Core Hosted 正确配置:

DoeYrR.png

重建数据库上下文

等待示例工程创建完毕之后,可以看到 Solution 中被分成了三个项目。分别是盛放 Blazor WebAssembly 的 Client 项目,盛放 Asp.Net Core 服务器的 Server 项目以及两个项目之间互相通信的共享项目 Shared。

Doezz4.png

其中,Server 项目中已经写好了默认的用户类、数据库上下文类以及数据库初始化 Migration。默认情况下,Entity Framewok Core 使用的数据库是 SQL Server LocalDB。虽然我更偏好 SQLite,但其实只是换一个 NuGet 包和连接字符串的事,所以等到下次杂项的时候在提,这次先用 LocalDB 凑合。

删除Server/Data/Migrations文件夹,在选中 Server 项目的情况下打开 工具 - NuGet 包管理器 - 程序包管理器控制台(或使用 Ctrl + `打开),使用以下指令重新建立 Migration 和数据库:

1
2
Add-Migration InitCreate -o Data/Migrations
Update-Database

或者,如果你使用的是 .NET Core CLI:

1
2
dotnet ef migrations add InitCreate -o Data/Migrations
dotnet ef database update

通过基架创建 Identity 所需的 Remote Pages

如果不创建基架,Identity 模块的 <RemoteAuthenticatorView> 控件连接到的界面都会使用默认的页面。通过基架将所有的页面创建后,就可以通过修改cshtml文件来对逻辑和界面进行自定义。

从 Server 项目的新建菜单中新建基架项目,选择 Identity 并指定数据库上下文类,接着替换全部(不得不说巨硬的这个机翻属实不行,Identity 都能翻译成标识)。

DoKRXQ.png
DoQCan.png
DoQP5q.png

使用基于 Role 的身份验证

基于默认设置,Jwt 口令中是没有 Role 相关信息的。我们需要让服务器发出的 Jwt 口令中带上 Role 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// Server/Startup.cs
/// Startup.ConfigureServices(IServiceCollection)

services.AddDefaultIdentity<ApplicationUser>()
.AddRoles<IdentityRole>() // 指定Role的类
.AddEntityFrameworkStores<ApplicationDbContext>();

services.AddIdentityServer(options => options.IssuerUri = Configuration.GetValue<string>(Literal.Domain))
// 在JWT中添加Role信息
.AddApiAuthorization<AppUser, ApplicationDbContext>(options =>
{
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");

services.AddAuthentication()
.AddIdentityServerJwt();

坑爹的一点是,当用户只有一个 Role 的时候,Claims 中的信息为:

1
2
3
{
"role": "Admin"
}

当用户有不止一个 Role 的时候,Claims 中的信息为:

1
2
3
{
"role": ["Admin", "Operator"]
}

但!我们亲爱的 Blazor 端的 ClaimsPrincipal.IsInRole()函数和[Authorize(Roles = "")]属性以及<AuthorizeView Roles="">控件都是通过直接判断字符串是否相等来判断用户是否在 Role 内。

我暴怒,这个问题困扰了我好几天的时间。但在 Github Issue 中,Blazor 的 Contributor 竟然说这是 a Feature

对此,我们需要手动 Hack 一下客户端内的 AccountClaimsPrincipalFactory,创建如下子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// Client.Service.RolesAccountClaimsPrincipalFactory.cs

public class RolesAccountClaimsPrincipalFactory
: AccountClaimsPrincipalFactory<RemoteUserAccount>
{
public RolesAccountClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor) : base(accessor) { }
public override ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account,
RemoteAuthenticationUserOptions options)
{
// 提取
var roles = account?.AdditionalProperties["role"] as JsonElement?;
// 是数组的情况,分割
if (roles?.ValueKind == JsonValueKind.Array)
{
account.AdditionalProperties.Remove("role");
var claimsPrincipal = base.CreateUserAsync(account, options).Result;

foreach (var ele in roles.Value.EnumerateArray())
{
(claimsPrincipal.Identity as ClaimsIdentity)
.AddClaim(new Claim("role", ele.GetString()));
}
return new ValueTask<ClaimsPrincipal>(claimsPrincipal);
}
// 返回给基类处理
return base.CreateUserAsync(account, options);
}
}

并在 Client 端的 Program.cs 注册中间件:

1
2
3
4
5
6
/// Client/Program.cs
/// Program.Main

builder.Services.AddScoped(typeof(AccountClaimsPrincipalFactory<RemoteUserAccount>),
typeof(RolesAccountClaimsPrincipalFactory));
builder.Services.AddApiAuthorization();

只能说,希望这个 Feature 能早日被定性成 Bug。

自定义

然后就是自由发挥的时刻了。

通过中间件自定义账户规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// Server/Startup.cs
/// Startup.ConfigureServices(IServiceCollection)

services
.AddDefaultIdentity<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
options.Password.RequiredLength = 6;
options.Password.RequiredUniqueChars = 2;
options.Password.RequireNonAlphanumeric = false;
options.User.AllowedUserNameCharacters = string.Empty;
options.User.RequireUniqueEmail = true;
})
.AddUserValidator<UserNameValidator<ApplicationUser>>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();

自定义登录加载页面(通过提供 Render Fragment 参数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/// Client/Pages/Authentication.razor

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

<RemoteAuthenticatorView Action="@Action" >
<CompletingLoggingIn>
<Loading LoadingMessage="@LocalStrings.LoadMessage.CompletingLogin"></Loading>
</CompletingLoggingIn>
<CompletingLogOut>
<Loading LoadingMessage="@LocalStrings.LoadMessage.CompletingLogout"></Loading>
</CompletingLogOut>
<LoggingIn>
<Loading LoadingMessage="@LocalStrings.LoadMessage.LoggingIn"></Loading>
</LoggingIn>
<LogInFailed>
<ErrorComponent ErrorTitle="@LocalStrings.Error.BadRequest400" ErrorContent="@LocalStrings.Error.LoginFailed"></ErrorComponent>
</LogInFailed>
<LogOut>
<Loading LoadingMessage="@LocalStrings.LoadMessage.LogOut"></Loading>
</LogOut>
<LogOutFailed>
<ErrorComponent ErrorTitle="@LocalStrings.Error.BadRequest400" ErrorContent="@LocalStrings.Error.LogOutFailed"></ErrorComponent>
</LogOutFailed>
<LogOutSucceeded>
<RedirectToHome></RedirectToHome>
</LogOutSucceeded>
<Registering>
<Loading LoadingMessage="@LocalStrings.LoadMessage.Registering"></Loading>
</Registering>
<UserProfile>
<Loading LoadingMessage="@LocalStrings.LoadMessage.LoadingUserProfile"></Loading>
</UserProfile>
</RemoteAuthenticatorView>

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

参考资料

本文作者:冻葱Tewi
本文链接:https://blog.dctewi.com/2020/12/blazor-webassembly-hosted-with-identity/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可