Passkeys trong ASP.NET Core 10: Xác thực không mật khẩu với Identity

Mỗi luồng đặt lại mật khẩu bạn từng xây dựng đều là một lời thừa nhận thất bại nhỏ. Người dùng đã quên một bí mật mà họ không bao giờ có thể nhớ được, vì vậy bây giờ bạn đang gửi cho họ một liên kết có giới hạn thời gian và hy vọng không ai chặn được nó. Nhân số đó lên với một cơ sở dữ liệu đầy mật khẩu được băm tồn tại như một trách nhiệm vi phạm vĩnh viễn, và không có gì ngạc nhiên khi “chỉ cần loại bỏ mật khẩu” đã là mục tiêu được ngành công nghiệp tuyên bố gần một thập kỷ.

.NET 10 là phiên bản đầu tiên mà ASP.NET Core Identity thực sự có thể làm điều đó một cách gốc. Không cần gói WebAuthn bên thứ ba, không cần thủ công thực hiện FIDO2 — SignInManager và UserManager đã có các phương thức mới xử lý đăng ký và xác thực passkey trực tiếp. Nó có phạm vi, có các cạnh sắc, và hoàn toàn không phải là thư viện WebAuthn đa dụng. Nhưng đối với kịch bản mà hầu hết ứng dụng thực sự có — “để người dùng của chúng tôi đăng nhập bằng Windows Hello / Face ID / khóa bảo mật thay vì mật khẩu” — giờ đây nó đã được tích hợp sẵn.

Attestation và Assertion, theo ngôn ngữ đơn giản

WebAuthn có từ vựng riêng của nó, và đáng để xác định trước khi xem mã:

  • Attestation là đăng ký. Trình duyệt yêu cầu bộ xác thực (một bộ xác thực nền tảng như Windows Hello, hoặc khóa phần cứng) tạo cặp khóa công khai/riêng tư mới. Khóa riêng tư không bao giờ rời khỏi thiết bị. Server nhận khóa công khai và lưu trữ nó.
  • Assertion là đăng nhập. Server gửi một thách thức mới, bộ xác thực ký nó bằng khóa riêng tư đã tạo trong quá trình attest, và server xác minh chữ ký đó bằng khóa công khai đã lưu trữ.

Không có bí mật nào đi qua mạng. Không có cơ sở dữ liệu mật khẩu để vi phạm, vì không có bí mật chung nào cả — chỉ có khóa công khai vô dụng với kẻ tấn công nếu không có khóa riêng tư tương ứng bị khóa trong thiết bị của người dùng.

ASP.NET Core Identity thực sự hỗ trợ những gì

Tài liệu rõ ràng về phạm vi: “Việc triển khai passkey trong ASP.NET Core Identity được有意 giới hạn trong các kịch bản xác thực. Nó không được dự định làm thư viện WebAuthn đa dụng.” Cụ thể, điều đó có nghĩa là ba kịch bản được hỗ trợ:

  • Thêm passkey vào tài khoản hiện có — người dùng có tài khoản dựa trên mật khẩu đăng ký passkey như phương thức đăng nhập bổ sung.
  • Tạo tài khoản không mật khẩu — tài khoản mới được tạo với passkey và không có mật khẩu.
  • Đăng nhập không mật khẩu — xác thực chỉ với passkey, không có lời nhắc mật khẩu.

Và một số thứ nó cố tình không làm:

  • Không xác minh tuyên bố attestation theo mặc định (thêm chi tiết bên dưới).
  • Không tích hợp 2FA — passkey được xử lý như yếu tố chính, không phải yếu tố thứ hai.
  • Chỉ template Blazor Web App có giao diện người dùng được kết nối sẵn; các dự án MVC/Razor Pages/minimal API cần xây dựng luồng phía client tự.

Nếu bạn cần tuân thủ WebAuthn/FIDO2 đầy đủ — chính sách attestation doanh nghiệp, danh sách cho phép phần cứng, thực hiện đa nền tảng ngoài xác thực — hãy xem fido2-net-lib. Những gì có trong Identity dành cho 90% trường hợp: thay thế hoặc bổ sung mật khẩu cho một ứng dụng web bình thường.

Cấu hình hành vi Passkey

Mọi thứ được điều khiển bởi IdentityPasskeyOptions:

builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    options.ServerDomain = "contoso.com";                 // Relying Party ID
    options.AuthenticatorTimeout = TimeSpan.FromMinutes(3);
    options.ChallengeSize = 64;                            // bytes, mặc định là 32
    options.UserVerificationRequirement = "required";       // bắt buộc sinh trắc học/PIN
    options.ResidentKeyRequirement = "preferred";           // credential có thể khám phá
});

ServerDomain quan trọng hơn vẻ ngoài. Nếu bạn để nó là null, Identity suy ra Relying Party ID từ header Host của request — điều đó có nghĩa là môi trường hosting của bạn (Kestrel, IIS, reverse proxy) phải xác thực header đó, nếu không bạn đã mở cửa cho các cuộc tấn công giới hạn credential. Đặt nó rõ ràng trong sản xuất thay vì tin tưởng vào việc suy luận mặc định.

Passkey được đăng ký trên app.contoso.com cũng hoạt động trên bất kỳ subdomain *.app.contoso.com nào — trình duyệt thực thi điều này, không phải mã của bạn. Nếu bạn không thể đảm bảo rằng mọi subdomain dưới ServerDomain của bạn đều đáng tin cậy, bạn cần xác thực nguồn gốc tùy chỉnh (xem bên dưới).

Luồng Đăng ký

Đăng ký là một vòng luân chuyển: server của bạn cung cấp cho trình duyệt một tập các tùy chọn tạo, trình duyệt chuyển chúng cho bộ xác thực thông qua navigator.credentials.create(), và kết quả được ký quay lại để server xác minh và lưu trữ.

Server: tạo tùy chọn

app.MapPost("/account/passkey-options", async (
    HttpContext context,
    UserManager<ApplicationUser> userManager) =>
{
    var user = await userManager.GetUserAsync(context.User);
    if (user is null)
        return Results.NotFound();

    var userId = await userManager.GetUserIdAsync(user);
    var userName = await userManager.GetUserNameAsync(user) ?? "User";

    var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
    {
        Id = userId,
        Name = userName,
        DisplayName = userName
    });

    return TypedResults.Content(optionsJson, contentType: "application/json");
});

MakePasskeyCreationOptionsAsync không chỉ xây dựng một khối JSON — nó cất giữ trạng thái tạm thời trong cookie xác thực để server sau đó có thể xác nhận rằng phản hồi của trình duyệt tương ứng với thách thức cụ thể này, không phải một bản sao.

Cookie đó là lý do luồng này giả định mối quan hệ cùng nguồn gốc, server-rendered giữa ứng dụng phát hành thách thức và ứng dụng hoàn thành nó. Nếu frontend của bạn là SPA tách rời (React, Vue, etc.) được host trên domain riêng gọi API này — app.contoso.com nói chuyện với api.contoso.com, hoặc tệ hơn, một domain hoàn toàn khác — cookie đó cần SameSite=None; Secure để tồn tại qua request cross-site, và API của bạn cần chính sách CORS cho phép rõ ràng credentials (AllowCredentials(), không có wildcard origin). Nếu thiếu cả hai, trình duyệt sẽ âm thầm bỏ cookie, PerformPasskeyAttestationAsync không thể tìm thấy trạng thái thách thức tương ứng, và attestation thất bại với lỗi không liên quan gì đến bản thân WebAuthn.

Client: chuyển tùy chọn cho bộ xác thực

async function createPasskey(headers, signal) {
  const response = await fetch('/account/passkey-options', {
    method: 'POST',
    headers,
    signal,
    credentials: 'include', // bắt buộc khi API sống trên nguồn gốc khác
  });
  const optionsJson = await response.json();
  const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);

  try {
    // Yêu cầu Windows Hello / Touch ID / khóa bảo mật — hoàn toàn do trình duyệt xử lý
    return await navigator.credentials.create({ publicKey: options, signal });
  } catch (err) {
    // NotAllowedError: người dùng hủy lời nhắc hệ thống/sinh trắc học, hoặc hết thời gian chờ
    // InvalidStateError: bộ xác thực cho tài khoản này đã có credential được đăng ký
    // SecurityError: nguồn gốc không khớp với ServerDomain / RP ID được cấu hình
    console.error('Tạo passkey thất bại:', err.name, err.message);
    throw err;
  }
}

Server: xác minh và lưu trữ

app.MapPost("/account/passkey-register", async (
    HttpContext context,
    UserManager<ApplicationUser> userManager,
    [FromBody] string credentialJson) =>
{
    var user = await userManager.GetUserAsync(context.User);

    var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson);
    if (!attestationResult.Succeeded)
        return Results.BadRequest(attestationResult.Failure.Message);

    var addResult = await userManager.AddOrUpdatePasskeyAsync(user!, attestationResult.Passkey);
    return addResult.Succeeded ? Results.Ok() : Results.BadRequest("Không thể lưu trữ passkey");
});

PerformPasskeyAttestationAsync kiểm tra loại credential, xác minh dữ liệu client (nguồn gốc, thách thức), kiểm tra các cờ bộ xác thực về sự hiện diện/xác minh người dùng, và trích xuất khóa công khai. AddOrUpdatePasskeyAsync cũng là phương thức bạn gọi sau này nếu chỉ muốn đổi tên credential — đặt passkey.Name và chuyển nó qua cùng phương thức.

Dưới cùng, dữ liệu này được lưu vào bảng AspNetUserPasskeys mới: ID credential, người dùng sở hữu, và một khối JSON chứa khóa công khai, bộ đếm chữ ký, và các cờ trạng thái sao lưu. Một migration trên IdentityDbContext mới sẽ tự động nhận diện bảng này.

Luồng Đăng nhập

Đăng nhập phản chiếu đăng ký, nhưng kết thúc trong phiên xác thực thay vì credential được lưu trữ.

app.MapPost("/account/passkey-request-options", async (
    UserManager<ApplicationUser> userManager,
    string? username) =>
{
    var user = string.IsNullOrEmpty(username)
        ? null
        : await userManager.FindByNameAsync(username);

    // Truyền null cho đăng nhập không tên người dùng / giao diện có điều kiện
    var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
    return TypedResults.Content(optionsJson, contentType: "application/json");
});

app.MapPost("/account/passkey-signin", async ([FromBody] string credentialJson) =>
{
    var result = await signInManager.PasskeySignInAsync(credentialJson);
    return result.Succeeded ? Results.Ok() : Results.Unauthorized();
});
async function signInWithPasskey(username, headers, signal) {
  const response = await fetch(`/account/passkey-request-options?username=${username}`, {
    method: 'POST', headers, signal, credentials: 'include'
  });
  const optionsJson = await response.json();
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);

  try {
    return await navigator.credentials.get({
      publicKey: options,
      // 'conditional' hiển thị các passkey đã lưu như gợi ý autofill gốc trên
      // input tên người dùng/email thay vì hiển thị hộp thoại ngay — kết hợp
      // với autocomplete="username webauthn" trên input đó và gọi hàm này khi
      // tải trang mà không có tên người dùng, trước khi người dùng nhập gì
      mediation: 'conditional',
      signal,
    });
  } catch (err) {
    // NotAllowedError: người dùng đóng lời nhắc, hoặc request hết thời gian chờ
    // AbortError: request bị hủy qua AbortSignal (ví dụ: request mới hơn thay thế)
    console.error('Đăng nhập passkey thất bại:', err.name, err.message);
    throw err;
  }
}

mediation: 'conditional' dành riêng cho trường hợp autofill — gọi signInWithPasskey với tên người dùng trống ngay khi trang đăng nhập tải. Đối với nút “Đăng nhập bằng passkey” thông thường mà người dùng nhấp rõ ràng, bỏ mediation (hoặc đặt nó thành 'optional', mặc định) để trình duyệt hiển thị hộp thoại chọn bình thường ngay lập tức thay vì chờ autofill.

PasskeySignInAsync là phương thức tiện lợi một lệnh gọi — nó xác minh chữ ký bằng khóa công khai đã lưu trữ, kiểm tra thách thức khớp, cập nhật bộ đếm bảo vệ tái sử dụng, và đăng nhập người dùng. Nếu bạn cần kiểm tra kết quả trước khi cam kết đăng nhập (ví dụ: chạy kiểm tra bổ sung trước), hãy dùng trực tiếp PerformPasskeyAssertionAsync — chỉ cần nhớ gọi AddOrUpdatePasskeyAsync sau đó, vì bộ đếm chữ ký được cập nhật không tự động lưu ở tầng thấp hơn này.

Những khoảng trống bạn cần biết

Không có xác minh attestation theo mặc định. Identity chấp nhận bất kỳ tuyên bố attestation nào mà bộ xác thực cung cấp mà không xác minh nó bằng store tin cậy. Ổn cho kịch bản người tiêu dùng; không ổn nếu bạn cần nói “chỉ cho phép YubiKey 5 series” trong môi trường doanh nghiệp. Bạn có thể gắn kiểm tra tùy chỉnh:

builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    options.VerifyAttestationStatement = async context =>
    {
        // Kiểm tra context, so sánh AAGUID với danh sách cho phép, v.v.
        return true;
    };
});

Phục hồi tài khoản là trách nhiệm của bạn. Nếu phương thức xác thực duy nhất của người dùng là passkey gắn liền với điện thoại họ mất, họ bị khóa ngoài trừ khi bạn đã lên kế hoạch. Template Blazor Web App tránh điều này bằng cách yêu cầu phương thức dự phòng (mật khẩu hoặc đăng nhập bên ngoài) khi tạo tài khoản. Nếu bạn xây dựng xác thực chỉ passkey, hãy bố trí mã phục hồi hoặc luồng phục hồi dựa trên email trước — đừng gắn nó sau khi có yêu cầu hỗ trợ đầu tiên.

HTTPS là bắt buộc, không phải khuyến nghị. Trạng thái thực hiện passkey được lưu trong cookie được mã hóa, ký, nhưng chúng vẫn là cookie — qua HTTP thuần chúng có thể bị chặn. Không có cách nào làm điều này hoạt động đúng mà không có TLS trong sản xuất.

Khi nào nên dùng giải pháp khác

Nếu yêu cầu của bạn là “để người dùng đăng nhập không mật khẩu, sử dụng bộ xác thực nền tảng” — đây giờ là câu trả lời mặc định cho dự án ASP.NET Core mới, không cần phụ thuộc bổ sung. Nếu yêu cầu của bạn bao gồm chính sách attestation doanh nghiệp, danh sách cho phép mô hình phần cứng, hoặc các kịch bản WebAuthn vượt ngoài xác thực vào ứng dụng của bạn (ký tài liệu, xác thực nâng cao cho giao dịch giá trị cao với thực hiện tùy chỉnh), hỗ trợ tích hợp sẵn sẽ nhanh chóng hết đường, và fido2-net-lib hoặc nhà cung cấp danh tính chuyên dụng là điểm khởi đầu tốt hơn.

Kết luận

Passkeys trong .NET 10 không phải là thư viện gắn thêm — chúng là các phương thức của SignInManager và UserManager nằm ngay bên cạnh các API mật khẩu bạn đã biết, được hỗ trợ bởi một bảng mới trong schema Identity. MakePasskeyCreationOptionsAsync / PerformPasskeyAttestationAsync xử lý đăng ký, MakePasskeyRequestOptionsAsync / PasskeySignInAsync xử lý đăng nhập, và API navigator.credentials của trình duyệt thực hiện nghi lễ mật mã thực sự mà cả hai phía đều không bao giờ thấy bí mật chung. Phạm vi được有意 thu hẹp — không có xác minh attestation, không vai trò 2FA, không hỗ trợ template ngoài Blazor — nhưng đối với kịch bản đăng nhập không mật khẩu mà hầu hết ứng dụng thực sự cần, phạm vi thu hẹp đó chính xác là đúng.

Mã nguồn đầy đủ: github.com/AdrianBailador/PasskeysAspNetCoreIdentityDotnet

Câu hỏi hoặc đề xuất? Mở vấn đề trên GitHub.