.NET 10中的ASP.NET Core身份认证通行密钥支持详解

本文深入探讨了.NET 10预览版6中为ASP.NET Core身份认证新增的通行密钥支持功能,详细介绍了通行密钥的技术实现原理、Blazor模板的具体代码变更,以及WebAuthn与浏览器的交互机制。

通行密钥支持在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中添加到模板的新代码。这些代码中的大部分已经在较新的预览版中发生了变化,但整体流程和组件之间的交互保持不变。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计