通行密钥支持在ASP.NET Core身份认证中的应用
这是"探索.NET 10预览版"系列的第6篇文章。本文重点介绍了在.NET 10预览版6中为ASP.NET Core身份认证添加的通行密钥支持功能。文章主要关注Blazor模板中的变更,分析新增和修改的内容,并深入探讨模板变更的源代码,以理解新的WebAuthn与浏览器的交互机制。
什么是通行密钥?
通行密钥提供了一种安全、不可钓鱼、无密码的网站和应用程序身份验证方式。它们基于FIDO(快速身份在线联盟)提供的标准,允许您使用与解锁笔记本电脑或手机相同的机制(如生物识别或PIN码)登录应用程序。通行密钥本质上比密码更安全,但在多设备间共享通行密钥时存在一些可用性挑战。
在.NET 10预览版6中,ASP.NET Core添加了对通行密钥的支持,作为使用ASP.NET Core身份认证的应用程序的替代登录方式。在当前模板中,它们不会完全取代密码,因此您最初仍需要使用密码注册,但随后可以添加通行密钥以便更轻松地登录。
试用新模板
通行密钥支持是在这个大型PR中添加的,该PR向ASP.NET Core身份认证添加了新的通行密钥抽象,并对本文中讨论的Blazor Web应用程序模板进行了更改。
首先创建一个具有独立身份验证的新Blazor Web应用程序:
1
|
dotnet new blazor -au Individual
|
运行应用程序后,导航到注册页面创建新用户。注册后,登录并导航到账户页面,您会发现一个新的"通行密钥"部分,允许您注册通行密钥。
点击"添加新通行密钥"启动注册过程。点击此按钮将从浏览器弹出原生对话框。如果您使用支持通行密钥的密码管理器(如1Password),它可能会提示您在那里保存通行密钥。否则,您将获得浏览器的原生弹出窗口,其中包含可用选项。
选择保存通行密钥的位置,执行必要的身份验证,您将看到通行密钥已注册的确认信息。此时,Blazor应用程序会提示您为通行密钥选择名称。技术上通行密钥此时已保存(名称为"未命名通行密钥"),但您应选择更具描述性的密钥名称并点击"继续"。
注册通行密钥后,您可以在通行密钥页面注册另一个通行密钥、重命名现有密钥或删除密钥。
接下来尝试登录流程。注销您的账户,在登录页面上不要输入用户名和密码,而是点击"使用通行密钥登录"链接。点击此链接后,浏览器通常会弹出一个窗口,提示您选择用于登录的通行密钥。选择保存的通行密钥后,系统将提示您使用原生提示通过设备进行身份验证。身份验证后,您将立即登录到网站,无需输入用户名和密码。
查看代码变更
本节中显示的所有代码都是使用.NET 10预览版6创建新Blazor Web应用程序模板时的一部分。
在UI方面,最重要的新组件是Components/Account/Shared/PasskeySubmit.razor及其对应的JavaScript文件PasskeySubmit.razor.js。特别是JavaScript包含了所有调用浏览器WebAuthn功能以与通行密钥交互的函数。
除了PasskeySubmit组件外,还有几个新增和更新的组件:
Components/Account/Pages/Login.razor - 更新以包含"使用通行密钥登录"链接
Components/Account/Shared/ManageNavMenu.razor - 更新以包含"通行密钥"菜单项
Components/Account/Manage/Passkeys.razor - 用于添加和删除通行密钥的通行密钥管理页面
Components/Account/Manage/RenamePasskey.razor - 用于重命名通行密钥的页面
在应用程序的后端有两个主要变化:
IdentityComponentsEndpointRouteBuilderExtensions中的新API,由Blazor组件调用以与ASP.NET Core身份认证交互
- 新的EF Core迁移,用于将用户的通行密钥信息保存到数据库
PasskeySubmit组件详解
PasskeySubmit组件的标记如下所示:
1
2
|
<button type="submit" name="__passkeySubmit" @attributes="AdditionalAttributes">@ChildContent</button>
<passkey-submit operation="@Operation" name="@Name" email-name="@EmailName"></passkey-submit>
|
该组件本身相当简单,只是一个表单提交按钮和一个名为passkey-submit的自定义元素。在PasskeySubmit.razor.js中,我们可以看到这个自定义元素是如何连接的。
该文件的核心部分包括:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
|
// 注册自定义元素定义
customElements.define('passkey-submit', class extends HTMLElement {
static formAssociated = true;
// connectedCallback在元素插入DOM时触发
connectedCallback() {
// 将自定义元素附加到表单
this.internals = this.attachInternals();
// 获取作为元素属性传递的详细信息
this.attrs = {
operation: this.getAttribute('operation'),
name: this.getAttribute('name'),
emailName: this.getAttribute('email-name'),
};
// 在表单上注册提交处理程序,如果由__passkeySubmit按钮触发,则尝试提交通行密钥凭据
this.internals.form.addEventListener('submit', (event) => {
if (event.submitter?.name === '__passkeySubmit') {
event.preventDefault();
// 获取或创建通行密钥凭据并提交表单
this.obtainCredentialAndSubmit();
}
});
// 尝试自动填充通行密钥,以改善用户体验
this.tryAutofillPasskey();
}
// disconnectedCallback在元素从DOM中移除时触发
disconnectedCallback() {
this.abortController?.abort();
}
async tryAutofillPasskey() {
if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) {
// 如果组件处于"请求"模式(即登录),并且浏览器中自动填充可用且受支持,则尝试预自动填充
await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true);
}
}
async obtainCredentialAndSubmit(useConditionalMediation = false) {
// AbortController的工作方式类似于.NET中的CancelationToken
this.abortController?.abort();
this.abortController = new AbortController();
const signal = this.abortController.signal;
const formData = new FormData();
try {
let credential;
// 创建新凭据或请求现有凭据
if (this.attrs.operation === 'Create') {
credential = await createCredential(signal);
} else if (this.attrs.operation === 'Request') {
const email = new FormData(this.internals.form).get(this.attrs.emailName);
const mediation = useConditionalMediation ? 'conditional' : undefined;
credential = await requestCredential(email, mediation, signal);
} else {
throw new Error(`Unknown passkey operation '${operation}'.`);
}
// 将凭据转换为JSON并存储在表单数据中
const credentialJson = JSON.stringify(credential);
formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
} catch (error) {
if (error.name === 'AbortError') {
// 用户操作取消,不提交表单
return;
}
formData.append(`${this.attrs.name}.Error`, error.message);
console.error(error);
}
// 设置表单数据并提交
this.internals.setFormValue(formData);
this.internals.form.submit();
}
});
|
此代码显示了添加到passkey-submit元素的所有行为。我们只缺少两个函数的定义:createCredential()和requestCredential(),如下所示:
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
40
41
42
43
44
45
|
// 调用以创建新通行密钥
async function createCredential(signal) {
// 调用ASP.NET Core身份认证端点以获取应用程序的通行密钥选项
const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', {
method: 'POST',
signal,
});
// 将响应转换为通行密钥选项JSON对象
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
// 触发浏览器使用提供的选项创建通行密钥凭据并返回凭据
return await navigator.credentials.create({ publicKey: options, signal });
}
// 调用以使用通行密钥触发登录
async function requestCredential(email, mediation, signal) {
// 调用ASP.NET Core身份认证端点以获取应用程序的通行密钥选项
const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, {
method: 'POST',
signal,
});
// 将响应转换为通行密钥选项JSON对象
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
// 触发浏览器尝试使用提供的选项登录通行密钥凭据并返回凭据
return await navigator.credentials.get({ publicKey: options, mediation, signal });
}
// 用于发送HTTP请求并返回响应的辅助函数
async function fetchWithErrorHandling(url, options = {}) {
const response = await fetch(url, {
credentials: 'include',
...options
});
if (!response.ok) {
const text = await response.text();
console.error(text);
throw new Error(`The server responded with status ${response.status}.`);
}
return response;
}
|
这些函数调用了IdentityComponentsEndpointRouteBuilderExtensions中公开的2个API端点。第一个是/Account/PasskeyCreationOptions,在向现有登录用户的账户添加通行密钥时调用:
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
|
internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
{
var accountGroup = endpoints.MapGroup("/Account");
// ...
accountGroup.MapPost("/PasskeyCreationOptions", async (
HttpContext context,
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] SignInManager<ApplicationUser> signInManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
}
// 收集当前用户的详细信息以创建PasskeyCreationArgs对象
var userId = await userManager.GetUserIdAsync(user);
var userName = await userManager.GetUserNameAsync(user) ?? "User";
var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName);
var passkeyCreationArgs = new PasskeyCreationArgs(userEntity);
// 使用参数创建通行密钥选项对象并将其作为JSON返回,供客户端使用
var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(passkeyCreationArgs);
return TypedResults.Content(options.AsJson(), contentType: "application/json");
});
//...
}
}
|
SignInManager.ConfigurePasskeyCreationOptionsAsync()方法是所有实际工作发生的地方(在未来的.NET 10版本中重命名为MakePasskeyCreationOptionsAsync)。此方法负责生成通行密钥选项,将它们存储在身份验证cookie中,并返回JSON。
另一个API端点PasskeyRequestOptions几乎相同,尽管这是用于登录的,此时不需要经过身份验证的用户(但如果您先输入用户名,它可以用于改善选择通行密钥的用户体验)。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
accountGroup.MapPost("/PasskeyRequestOptions", async (
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromQuery] string? username) =>
{
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
var passkeyRequestArgs = new PasskeyRequestArgs<ApplicationUser>
{
User = user,
};
var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs);
return TypedResults.Content(options.AsJson(), contentType: "application/json");
});
|
请注意,这些选项在通行密钥创建和使用PasskeySubmit组件登录期间都会使用,但该操作的结果(即从浏览器创建或检索的凭据)仅存储在表单字段中并提交。该数据的处理发生在Passkeys和Login组件中。
Passkeys组件包含类似于以下的标记,将PasskeySubmit组件放置在表单内,并连接AddPasskey()处理程序:
1
2
3
4
|
<form @formname="add-passkey" @onsubmit="AddPasskey" method="post">
<AntiforgeryToken />
<PasskeySubmit Operation="PasskeyOperation.Create" Name="Input" class="btn btn-primary">Add a new passkey</PasskeySubmit>
</form>
|
如您所见,PasskeySubmit组件处理客户端通行密钥的注册,然后将有关通行密钥的详细信息存储在周围的表单中。AddPasskey()方法必须使用此表单数据实际保存和持久化通行密钥详细信息。
UserManager.SetPasskeyAsync()方法是通行密钥实际保存到数据库的地方,保存在名为AspNetUserPasskeys的表中。此表的初始定义在最近的更新中发生了变化,如下所示。与预览版6中使用的版本相比,这是一个更简单的定义,其中Data列包含通行密钥凭据详细信息的JSON表示。
迁移代码显示数据库仅包含2个ID列和Data列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
migrationBuilder.CreateTable(
name: "AspNetUserPasskeys",
columns: table => new
{
CredentialId = table.Column<byte[]>(type: "BLOB", maxLength: 1024, nullable: false),
UserId = table.Column<string>(type: "TEXT", nullable: false),
Data = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId);
table.ForeignKey(
name: "FK_AspNetUserPasskeys_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
|
总结
在本文中,我简要概述了通行密钥,并展示了基本通行密钥支持如何添加到ASP.NET Core身份认证和Blazor Web应用程序模板中。我逐步介绍了向账户添加新通行密钥、重命名它以及使用它登录的用户过程。最后,我详细介绍了在.NET 10预览版6中添加到模板的新代码。这些代码中的大部分已经在较新的预览版中发生了变化,但整体流程和组件之间的交互保持不变。