在 Web 应用中使用表单自动填充功能实现通行密钥

在 Web 应用中使用表单自动填充功能实现通行密钥

О практической работе

subjectПоследнее обновление: мар. 29, 2023
account_circleАвторы: Eiji Kitamura

1. 准备工作

网站通过使用通行密钥代替密码,可提高用户帐号的安全性并简化用户帐号的管理和使用。借助通行密钥,用户可以使用设备的屏幕锁定功能(例如指纹锁、人脸识别锁或设备 PIN 码)来登录网站或应用。必须先创建通行密钥、将其与用户帐号关联,并将其公钥存储在服务器上,之后用户才能使用该通行密钥进行登录。

在此 Codelab 中,您需要将一个基于表单的基本用户名和密码登录方式转换为支持通行密钥登录,具体而言,您需要实现以下组件:

  • 有一个按钮,以便在用户登录后创建通行密钥。
  • 有一个界面,用来显示已注册通行密钥的列表。
  • 有一个现有登录表单,让用户能够通过表单自动填充功能,使用已注册的通行密钥进行登录。

前提条件

学习内容

  • 如何创建通行密钥。
  • 如何使用通行密钥对用户进行身份验证。
  • 如何在表单中显示通行密钥作为一个登录选项。

所需条件

以下任一设备组合:

  • Google Chrome 及搭载 Android 9 或更高版本的 Android 设备,最好带有生物识别传感器。
  • Chrome 及搭载 Windows 10 或更高版本的 Windows 设备。
  • Safari 16 或更高版本,及搭载 iOS 16 或更高版本的 iPhone 或搭载 iPadOS 16 或更高版本的 iPad。
  • Safari 16 或更高版本或 Chrome,及搭载 macOS Ventura 或更高版本的 Apple 桌面设备。

2. 进行设置

在此 Codelab 中,您将使用一项名为 Glitch 的服务。借助该服务,您可以使用 JavaScript 修改客户端和服务器端代码,并且通过浏览器即可部署这些代码。

打开项目

  1. 在 Glitch 中打开项目
  2. 点击 Remix 复刻 Glitch 项目。
  3. 在 Glitch 底部的导航菜单中,依次点击 Preview(预览)> Preview in a new window(在新窗口中预览)。您的浏览器会打开另一个标签页。

Glitch 底部导航菜单中的“Preview in a new window”(在新窗口中预览)按钮

检查网站的初始状态

  1. 在“预览”标签页中,输入一个随机用户名,然后点击 Next(下一步)。
  2. 输入一个随机密码,然后点击 Sign-in(登录)。虽然该密码会被忽略,但您仍然会通过身份验证并登录首页。
  3. 您可以在此修改显示名称;这也是您在该初始状态下唯一可以执行的操作。
  4. 点击 Sign out(退出)。

在此状态下,用户每次登录时都必须输入密码。您可以在此表单中添加通行密钥支持,以便用户可以使用设备的屏幕锁定功能进行登录。您可以访问 https://passkeys-codelab.glitch.me/,尝试一下结束状态的效果。

如需详细了解通行密钥的工作原理,请参阅通行密钥的工作原理

3. 添加用以创建通行密钥的功能

若要让用户能够使用通行密钥进行身份验证,您需要先提供一项功能,让用户能够创建和注册通行密钥,并将其公钥存储在服务器上。

创建通行密钥时,系统会显示一个通行密钥用户验证对话框。

您需要允许用户在使用密码登录后创建通行密钥,并添加一个界面,让用户能够在该界面上创建通行密钥并通过 /home 页面查看所有已注册的通行密钥。在下一部分中,您需要创建一个用于创建和注册通行密钥的函数。

创建 registerCredential() 函数

  1. 在 Glitch 中,打开 public/client.js 文件,然后滚动到文件末尾。
  2. 在相关注释后面,添加下面的 registerCredential() 函数:

public/client. js

// TODO: Add an ability to create a passkey: Create the registerCredential() function.
export async function registerCredential() {

  // TODO: Add an ability to create a passkey: Obtain the challenge and other options from the server endpoint.

  // TODO: Add an ability to create a passkey: Create a credential.

  // TODO: Add an ability to create a passkey: Register the credential to the server endpoint.

};

此函数将在服务器上创建并注册通行密钥。

从服务器端点获取质询和其他选项

创建通行密钥之前,您需要从服务器获取要传入 WebAuthn 的参数(包括质询)。WebAuthn 是一款浏览器 API,可让用户创建通行密钥并使用该通行密钥对用户进行身份验证。现在,我们已在此 Codelab 中为您提供了可对此类参数做出响应的服务器端点。

  • 如需从服务器端点获取质询和其他选项,请将下面的代码添加到 registerCredential() 函数正文的相关注释后面:

public/client.js

// TODO: Add an ability to create a passkey: Obtain the challenge and other options from the server endpoint.
const options = await _fetch('/auth/registerRequest');

以下代码段包含您从服务器收到的选项示例:

{
  challenge: *****,
  rp: {
    id: "example.com",
  },
  user: {
    id: *****,
    name: "john78",
    displayName: "John",
  },
  pubKeyCredParams: [{
    alg: -7, type: "public-key"
  },{
    alg: -257, type: "public-key"
  }],
  excludeCredentials: [{
    id: *****,
    type: 'public-key',
    transports: ['internal', 'hybrid'],
  }],
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: true,
  }
}

服务器与客户端之间的协议不在 WebAuthn 规范范畴内。不过,此 Codelab 的服务器会返回一个 JSON,该 JSON 与传递给 WebAuthn navigator.credentials.create() API 的 PublicKeyCredentialCreationOptions 字典极其相似。

下表列出了 PublicKeyCredentialCreationOptions 字典中的一些重要参数,但并非详尽无遗:

参数

说明

challenge

服务器在 ArrayBuffer 对象中为此项注册生成的质询。这是一项必需的操作,不过在注册期间用不到该质询,除非要使用证明流程(此 Codelab 未涵盖该高级主题)。

user.id

用户的唯一 ID。该值必须是一个 ArrayBuffer 对象,并且不得包含个人身份信息(例如电子邮件地址或用户名)。使用系统为每个帐号生成的 16 字节随机值即可。

user.name

此字段应包含用户可以识别的帐号唯一标识符,例如电子邮件地址或用户名。它会显示在帐号选择器中。(如果要使用用户名,请使用您在密码身份验证登录方法中使用的用户名。)

user.displayName

此字段是帐号的易记名称,这是一个可选参数。该名称不必是唯一的,可以是用户选择的名称。如果您的网站没有适合此参数的值,传递一个空字符串即可。此参数可能会显示在帐号选择器中,具体取决于浏览器。

rp.id

依赖方 (RP) ID 是一个网域。网站可以指定自己的网域,也可以指定一个可注册后缀。例如,如果 RP 的来源为 https://login.example.com:1337,则 RP ID 可以是 login.example.comexample.com。如果将 RP ID 指定为 example.com,则用户可以在 login.example.com 上或是在 example.com 的任何其他子网域上进行身份验证。

pubKeyCredParams

此字段用于指定 RP 支持的公钥算法。我们建议将其设置为 [{alg: -7, type: "public-key"},{alg: -257, type: "public-key"}]。因为这样设置后,即可支持采用 P-256 和 RSA PKCS#1 的 ECDSA,继而实现全面覆盖。

excludeCredentials

提供已注册凭据 ID 的列表,以防止同一设备重复注册两次。如果该参数包含 transports 成员,该成员应包含每个凭据注册期间 getTransports() 函数的调用结果。

authenticatorSelection.authenticatorAttachment

设为 "platform" 值;表示您要在平台设备中嵌入身份验证器,这样系统便不会提示用户插入 USB 安全密钥等装置。

authenticatorSelection.requireResidentKey

设为布尔值 true。这样无需服务器提供凭据 ID,即可使用可检测到的凭据(即常驻密钥),这样亦可支持自动填充功能。

authenticatorSelection.userVerification

设为 "preferred" 值或省略,因为这是默认值。该参数指示是必须 ("required")、首选 ("preferred") 还是不建议 ("discouraged") 使用设备屏幕锁定功能进行用户验证。若设为 "preferred" 值,系统会在设备条件允许的情况下请求用户验证。

创建凭据

  1. registerCredential() 函数正文相关注释后面,将一些使用 Base64URL 编码的参数转换回二进制数据,具体而言就是 user.idchallenge 字符串以及 excludeCredentials 数组中包含的 id 字符串实例:

public/client.js

// TODO: Add an ability to create a passkey: Create a credential.
// Base64URL decode some values.
options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);

if (options.excludeCredentials) {
  for (let cred of options.excludeCredentials) {
    cred.id = base64url.decode(cred.id);
  }
}
  1. 在下一行代码中,将 authenticatorSelection.authenticatorAttachment 设为 "platform",将 authenticatorSelection.requireResidentKey 设为 true。这样系统将仅允许使用平台身份验证器(设备本身),并允许使用可检测到的凭据。

public/client.js

// Use platform authenticator and discoverable credential.
options.authenticatorSelection = {
  authenticatorAttachment: 'platform',
  requireResidentKey: true
}
  1. 在下一行代码中,调用 navigator.credentials.create() 方法来创建凭据。

public/client.js

// Invoke the WebAuthn create() method.
const cred = await navigator.credentials.create({
  publicKey: options,
});

调用此方法后,浏览器会尝试通过设备的屏幕锁定功能来验证用户的身份。

向服务器端点注册凭据

用户验证身份后,系统会创建并存储通行密钥。网站会接收一个包含公钥的凭据对象,您可以将该公钥发送给服务器以注册通行密钥。

以下代码段包含一个示例凭据对象:

{
  "id": *****,
  "rawId": *****,
  "type": "public-key",
  "response": {
    "clientDataJSON": *****,
    "attestationObject": *****,
    "transports": ["internal", "hybrid"]
  },
  "authenticatorAttachment": "platform"
}

下表列出了 PublicKeyCredential 对象中的一些重要参数,但并非详尽无遗:

参数

说明

id

所创建通行密钥的 Base64URL 编码 ID。此 ID 有助于浏览器在进行身份验证时确定设备中是否存在匹配的通行密钥。此值必须存储在后端的数据库中。

rawId

ArrayBuffer 对象版本的凭据 ID。

response.clientDataJSON

ArrayBuffer 对象编码的客户端数据。

response.attestationObject

ArrayBuffer 编码的证明对象;其中包含一些重要信息,例如 RP ID、标志和公钥。

response.transports

设备支持的传输列表:"internal" 表示设备支持通行密钥。"hybrid" 表示设备还支持在其他设备上进行身份验证

authenticatorAttachment

如果是在支持通行密钥的设备上创建此凭据,此参数的值会是 "platform"

如要将凭据对象发送给服务器,请按以下步骤操作:

  1. 将凭据的二进制参数编码为 Base64URL 格式,以便以字符串的形式将其传递给服务器:

public/client.js

// TODO: Add an ability to create a passkey: Register the credential to the server endpoint.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;

// The authenticatorAttachment string in the PublicKeyCredential object is a new addition in WebAuthn L3.
if (cred.authenticatorAttachment) {
  credential.authenticatorAttachment = cred.authenticatorAttachment;
}

// Base64URL encode some values.
const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
const attestationObject = base64url.encode(cred.response.attestationObject);

// Obtain transports.
const transports = cred.response.getTransports ? cred.response.getTransports() : [];

credential.response = {
  clientDataJSON,
  attestationObject,
  transports
};
  1. 在下一行代码中,将对象发送给服务器:

public/client.js

return await _fetch('/auth/registerResponse', credential);

当您运行该程序时,服务器会返回 HTTP code 200,这表示该凭据已注册完毕。

现在,您已拥有完整的 registerCredential() 函数!

查看本部分的解决方案代码

public/client.js

// TODO: Add an ability to create a passkey: Create the registerCredential() function.
export async function registerCredential() {

  // TODO: Add an ability to create a passkey: Obtain the challenge and other options from server endpoint.
  const options = await _fetch('/auth/registerRequest');

  // TODO: Add an ability to create a passkey: Create a credential.
  // Base64URL decode some values.

  options.user.id = base64url.decode(options.user.id);
  options.challenge = base64url.decode(options.challenge);

  if (options.excludeCredentials) {
    for (let cred of options.excludeCredentials) {
      cred.id = base64url.decode(cred.id);
    }
  }

  // Use platform authenticator and discoverable credential.
  options.authenticatorSelection = {
    authenticatorAttachment: 'platform',
    requireResidentKey: true
  }

  // Invoke the WebAuthn create() method.
  const cred = await navigator.credentials.create({
    publicKey: options,
  });

  // TODO: Add an ability to create a passkey: Register the credential to the server endpoint.
  const credential = {};
  credential.id = cred.id;
  credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
  credential.type = cred.type;

  // The authenticatorAttachment string in the PublicKeyCredential object is a new addition in WebAuthn L3.
  if (cred.authenticatorAttachment) {
    credential.authenticatorAttachment = cred.authenticatorAttachment;
  }

  // Base64URL encode some values.
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const attestationObject =
  base64url.encode(cred.response.attestationObject);

  // Obtain transports.
  const transports = cred.response.getTransports ?
  cred.response.getTransports() : [];

  credential.response = {
    clientDataJSON,
    attestationObject,
    transports
  };

  return await _fetch('/auth/registerResponse', credential);
};

4. 构建一个界面来注册和管理通行密钥凭据

现在,既然 registerCredential() 函数已可供使用,接下来您需要一个按钮来调用它。此外,您还需要显示已注册通行密钥的列表。

/home 页面上列出的已注册通行密钥

添加占位符 HTML

  1. 在 Glitch 中,打开 views/home.html 文件。
  2. 在相关注释后面,添加一个界面占位符,包括一个用于注册通行密钥的按钮和一个通行密钥列表:

views/home.html

​​<!-- TODO: Add an ability to create a passkey: Add placeholder HTML. -->
<section>
  <h3 class="mdc-typography mdc-typography--headline6"> Your registered
  passkeys:</h3>
  <div id="list"></div>
</section>
<
p id="message" class="instructions"></p>
<mwc-button id="create-passkey" class="hidden" icon="fingerprint" raised>Create a passkey</mwc-button>

div#list 元素是列表的占位符。

检查通行密钥是否受支持

若要仅向其设备支持通行密钥的用户显示通行密钥创建选项,您首先需要检查 WebAuthn 是否可用。若可用,您需要移除 hidden 类以显示创建通行密钥按钮。

若要检查环境是否支持通行密钥,请按以下步骤操作:

  1. views/home.html 文件末尾相关注释后面,编写一个条件,在 window.PublicKeyCredentialPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailablePublicKeyCredential.isConditionalMediationAvailable 都为 true 时执行。

views/home.html

// TODO: Add an ability to create a passkey: Check for passkey support.
const createPasskey = $('#create-passkey');
// Feature detections
if (window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  1. 在条件正文中,检查设备是否可以创建通行密钥,然后检查是否可以在表单中自动填充通行密钥。

views/home.html

try {
  const results = await Promise.all([

    // Is platform authenticator available in this browser?
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),

    // Is conditional UI available in this browser?
    PublicKeyCredential.isConditionalMediationAvailable()
  ]);
  1. 如果满足所有条件,则显示用以创建通行密钥的按钮。否则,显示一则警告消息。

views/home.html

    if (results.every(r => r === true)) {

     
// If conditional UI is available, reveal the Create a passkey button.
      createPasskey
.classList.remove('hidden');
   
} else {

     
// If conditional UI isn't available, show a message.
      $
('#message').innerText = 'This device does not support passkeys.';
   
}
 
} catch (e) {
    console
.error(e);
 
}
} else {

 
// If WebAuthn isn't available, show a message.
  $
('#message').innerText = 'This device does not support passkeys.';
}

以列表形式呈现已注册的通行密钥

  1. 定义一个 renderCredentials() 函数,用以从服务器提取已注册的通行密钥并将其以列表形式呈现。现在,我们已为您提供了 /auth/getKeys 服务器端点来提取登录用户的已注册通行密钥。

views/home.html

// TODO: Add an ability to create a passkey: Render registered passkeys in a list.
async function renderCredentials() {
  const res = await _fetch('/auth/getKeys');
  const list = $('#list');
  const creds = html`${res.length > 0 ? html`
    <mwc-list>
      ${res.map(cred => html`
        <mwc-list-item>
          <div class="list-item">
            <div class="entity-name">
              <span>${cred.name || 'Unnamed' }</span>
          </div>
          <div class="buttons">
            <mwc-icon-button data-cred-id="${cred.id}"
            data-name="${cred.name || 'Unnamed' }" @click="${rename}"
            icon="edit"></mwc-icon-button>
            <mwc-icon-button data-cred-id="${cred.id}" @click="${remove}"
            icon="delete"></mwc-icon-button>
          </div>
         </div>
      </mwc-list-item>`)}
  </mwc-list>` : html`
  <mwc-list>
    <mwc-list-item>No credentials found.</mwc-list-item>
  </mwc-list>`}`;
  render(creds, list);
};
  1. 在下一行代码中,调用 renderCredentials() 函数,以便在用户进入 /home 页面时立即显示已注册的通行密钥(即作为初始化流程显示)。

views/home.html

renderCredentials();

创建并注册通行密钥

若要创建并注册通行密钥,您需要调用在前面实现的 registerCredential() 函数。

若要在点击创建通行密钥按钮时触发 registerCredential() 函数,请按以下步骤操作:

  1. 在文件中占位符 HTML 后面,找到下面的 import 语句:

views/home.html

import {
 
$,
 
_fetch,
 
loading,
 
updateCredential,
 
unregisterCredential,
} from '/client.js';
  1. import 语句正文的末尾,添加 registerCredential() 函数。

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
import {
 
$,
 
_fetch,
 
loading,
 
updateCredential,
 
unregisterCredential,
 
registerCredential
} from '/client.js';
  1. 在文件末尾相关注释后面,定义一个 register() 函数来调用 registerCredential() 函数和加载界面,并在注册完成后调用 renderCredentials()。这表明浏览器会创建一个通行密钥,并会在出现问题时显示错误消息。

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
async function register() {
  try {

    // Start the loading UI.
    loading.start();

    // Start creating a passkey.
    await registerCredential();

    // Stop the loading UI.
    loading.stop();

    // Render the updated passkey list.
    renderCredentials();
  1. register() 函数的正文中,捕获异常。如果设备上已存在通行密钥,navigator.credentials.create() 方法会抛出一个 InvalidStateError 错误。可通过 excludeCredentials 数组对此进行检查。在此示例中,您还会向用户显示一条相关消息。此外,如果用户取消身份验证对话框,则会抛出 NotAllowedError 错误。在此示例中,您将以静默方式忽略该错误。

views/home.html

  } catch (e) {

    // Stop the loading UI.
    loading.stop();

    // An InvalidStateError indicates that a passkey already exists on the device.
    if (e.name === 'InvalidStateError') {
      alert('A passkey already exists for this device.');

    // A NotAllowedError indicates that the user canceled the operation.
    } else if (e.name === 'NotAllowedError') {
      Return;

    // Show other errors in an alert.
    } else {
      alert(e.message);
      console.error(e);
    }
  }
};
  1. register() 函数后面的一行代码中,将 register() 函数关联到创建通行密钥按钮的 click 事件。

views/home.html

createPasskey.addEventListener('click', register);

查看本部分的解决方案代码

views/home.html

​​<!-- TODO: Add an ability to create a passkey: Add placeholder HTML. -->
<section>
  <h3 class="mdc-typography mdc-typography--headline6"> Your registered
  passkeys:</h3>
  <div id="list"></div>
</section>
<
p id="message" class="instructions"></p>
<mwc-button id="create-passkey" class="hidden" icon="fingerprint" raised>Create a passkey</mwc-button>

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
import {
 
$,
 
_fetch,
 
loading,
 
updateCredential,
 
unregisterCredential,
 
registerCredential
} from '/client.js';

views/home.html

// TODO: Add an ability to create a passkey: Check for passkey support.
const createPasskey = $('#create-passkey');

// Feature detections
if (window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  try {
    const results = await Promise.all([

      // Is platform authenticator available in this browser?
      PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),

      // Is conditional UI available in this browser?
      PublicKeyCredential.isConditionalMediationAvailable()
    ]);
    if (results.every(r => r === true)) {

      // If conditional UI is available, reveal the Create a passkey button.
      createPasskey.classList.remove('hidden');
    } else {

      // If conditional UI isn't available, show a message.
      $('#message').innerText = 'This device does not support passkeys.';
    }
  } catch (e) {
    console.error(e);
  }
} else {

  // If WebAuthn isn't available, show a message.
  $('#message').innerText = 'This device does not support passkeys.';
}

// TODO: Add an ability to create a passkey: Render registered passkeys in a list.
async function renderCredentials() {
  const res = await _fetch('/auth/getKeys');
  const list = $('#list');
  const creds = html`${res.length > 0 ? html`
  <mwc-list>
    ${res.map(cred => html`
      <mwc-list-item>
        <div class="list-item">
          <div class="entity-name">
            <span>${cred.name || 'Unnamed' }</span>
          </div>
          <div class="buttons">
            <mwc-icon-button data-cred-id="${cred.id}" data-name="${cred.name || 'Unnamed' }" @click="${rename}" icon="edit"></mwc-icon-button>
            <mwc-icon-button data-cred-id="${cred.id}" @click="${remove}" icon="delete"></mwc-icon-button>
          </div>
        </div>
      </mwc-list-item>`)}
  </mwc-list>` : html`
  <mwc-list>
    <mwc-list-item>No credentials found.</mwc-list-item>
  </mwc-list>`}`;
  render(creds, list);
};

renderCredentials();

// TODO: Add an ability to create a passkey: Create and register a passkey.
async function register() {
  try {

    // Start the loading UI.
    loading.start();

    // Start creating a passkey.
    await registerCredential();

    // Stop the loading UI.
    loading.stop();

    // Render the updated passkey list.
    renderCredentials();
  } catch (e) {

    // Stop the loading UI.
    loading.stop();

    // An InvalidStateError indicates that a passkey already exists on the device.
    if (e.name === 'InvalidStateError') {
      alert('A passkey already exists for this device.');

    // A NotAllowedError indicates that the user canceled the operation.
    } else if (e.name === 'NotAllowedError') {
      Return;

    // Show other errors in an alert.
    } else {
      alert(e.message);
      console.error(e);
    }
  }
};

createPasskey.addEventListener('click', register);

试用一下

如果您完成了上述所有步骤,即表示您已实现了在网站上创建、注册和显示通行密钥的功能!

如要试用其效果,请按以下步骤操作:

  1. 在“预览”标签页中,使用随机用户名和密码登录。
  2. 点击创建通行密钥
  3. 使用设备的屏幕锁定功能来验证您的身份。
  4. 确认您的通行密钥已注册并显示在网页的已注册的通行密钥部分下。

/home 页面上列出的已注册通行密钥。

重命名和移除已注册的通行密钥

您应该可以重命名或删除列表中的已注册通行密钥。您可在 Codelab 附带的代码中查看其运作原理。

对于 Chrome,您可在桌面版的 chrome://settings/passkeys 页面或在 Android 设备设置中的密码管理工具页面移除已注册的通行密钥。

如需了解如何重命名和移除在其他平台上注册的通行密钥,请参阅相应平台的支持页面。

5. 添加使用通行密钥进行身份验证的功能

用户现在已能够创建并注册通行密钥,下一步便是用该通行密钥来安全地向您的网站进行身份验证。现在,您需要为自己的网站添加通行密钥身份验证功能。

创建 authenticate() 函数

  • public/client.js 文件相关注释后面,创建一个名为 authenticate() 的函数,用于在本地验证用户身份,然后向服务器完成验证:

public/client.js

// TODO: Add an ability to authenticate with a passkey: Create the authenticate() function.
export async function authenticate() {

  // TODO: Add an ability to authenticate with a passkey: Obtain the challenge and other options from the server endpoint.

  // TODO: Add an ability to authenticate with a passkey: Locally verify the user and get a credential.

  // TODO: Add an ability to authenticate with a passkey: Verify the credential.

};

从服务器端点获取质询和其他选项

在要求用户进行身份验证之前,您需要从服务器获取要传入 WebAuthn 的参数(包括质询)。

  • authenticate() 函数正文相关注释后面,调用 _fetch() 函数以向服务器发送一个 POST 请求:

public/client.js

// TODO: Add an ability to authenticate with a passkey: Obtain the challenge and other options from the server endpoint.
const options = await _fetch('/auth/signinRequest');

此 Codelab 的服务器会返回一个 JSON,该 JSON 与传递给 WebAuthn navigator.credentials.get() API 的 PublicKeyCredentialRequestOptions 字典极其相似。以下代码段包含您应会收到的选项示例:

{
 
"challenge": *****,
 
"rpId": "passkeys-codelab.glitch.me",
 
"allowCredentials": []
}

下表列出了 PublicKeyCredentialRequestOptions 字典中的一些重要参数,但并非详尽无遗:

参数

说明

challenge

服务器在 ArrayBuffer 对象中生成的质询。该质询对于防范重放攻击至关重要。切勿在同一响应中接受同一质询两次。可将质询视为 CSRF 令牌

rpId

RP ID 是一个网域,网站可以指定自己的网域,也可以指定一个可注册后缀。此值必须与创建通行密钥时使用的 rp.id 参数一致。

allowCredentials

此属性用于查找符合此身份验证条件的身份验证器。传递空数组或不指定数组,即可让浏览器显示帐号选择器。

userVerification

设为 "preferred" 值或省略,因为这是默认值。该参数指示是必须 ("required")、首选 ("preferred") 还是不建议 ("discouraged") 使用设备屏幕锁定功能进行用户验证。若设为 "preferred" 值,系统会在设备条件允许的情况下请求用户验证。

在本地验证用户身份并获取凭据

  1. authenticate() 函数正文相关注释后面,将 challenge 参数转换回二进制数据:

public/client.js

// TODO: Add an ability to authenticate with a passkey: Locally verify the user and get a credential.
// Base64URL decode the challenge.
options.challenge = base64url.decode(options.challenge);
  1. 将一个空数组传递给 allowCredentials 参数,以便在用户进行身份验证时打开帐号选择器:

public/client.js

// An empty allowCredentials array invokes an account selector by discoverable credentials.
options.allowCredentials = [];

帐号选择器会使用随通行密钥一起存储的用户信息。

  1. 调用 navigator.credentials.get() 方法并提供 mediation: 'conditional' 选项:

public/client.js

// Invoke the WebAuthn get() method.
const cred = await navigator.credentials.get({
  publicKey: options,

  // Request a conditional UI.
  mediation: 'conditional'
});

此选项指示浏览器在表单自动填充项中有条件地建议通行密钥。

验证凭据

用户在本地验证自己的身份后,您应该会收到一个包含签名的凭据对象,您可以在服务器上验证该签名。

以下代码段包含一个示例 PublicKeyCredential 对象:

{
  "id": *****,
  "rawId": *****,
  "type": "public-key",
  "response": {
    "clientDataJSON": *****,
    "authenticatorData": *****,
    "signature": *****,
    "userHandle": *****
  },
  authenticatorAttachment: "platform"
}

下表列出了 PublicKeyCredential 对象中的一些重要参数,但并非详尽无遗:

参数

说明

id

经过身份验证的通行密钥凭据的 Base64URL 编码 ID。

rawId

ArrayBuffer 对象版本的凭据 ID。

response.clientDataJSON

客户端数据的 ArrayBuffer 对象。此字段包含质询以及 RP 服务器需要验证的来源等信息。

response.authenticatorData

身份验证器数据的 ArrayBuffer 对象。此字段包含 RP ID 等信息。

response.signature

签名的 ArrayBuffer 对象。此值是凭据的核心,必须在服务器上进行验证。

response.userHandle

ArrayBuffer 对象,该对象包含系统在创建用户时设置的用户 ID。如果服务器需要选择其使用的 ID 值,或者后端希望避免为凭据 ID 创建索引,则可以使用此值来代替凭据 ID。

authenticatorAttachment

当此凭据来自本地设备时,则返回一个 "platform" 字符串;否则返回 "cross-platform" 字符串,特别是在用户是使用手机进行登录的情况下。如果用户需要使用手机登录,请提示用户在本地设备上创建通行密钥。

如要将凭据对象发送给服务器,请按以下步骤操作:

  1. authenticate() 函数正文相关注释后面,对凭据的二进制参数进行编码,以便以字符串的形式将其传递给服务器:

public/client.js

// TODO: Add an ability to authenticate with a passkey: Verify the credential.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;

// Base64URL encode some values.
const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
const authenticatorData = base64url.encode(cred.response.authenticatorData);
const signature = base64url.encode(cred.response.signature);
const userHandle = base64url.encode(cred.response.userHandle);

credential.response = {
  clientDataJSON,
  authenticatorData,
  signature,
  userHandle,
};
  1. 将对象发送给服务器:

public/client.js

return await _fetch(`/auth/signinResponse`, credential);

当您运行该程序时,服务器会返回 HTTP code 200,这表示该凭据已验证完毕。

现在,您已拥有完整的 authentication() 函数!

查看本部分的解决方案代码

public/client.js

// TODO: Add an ability to authenticate with a passkey: Create the authenticate() function.
export async function authenticate() {

  // TODO: Add an ability to authenticate with a passkey: Obtain the
  challenge and other options from the server endpoint.
  const options = await _fetch('/auth/signinRequest');

  // TODO: Add an ability to authenticate with a passkey: Locally verify
  the user and get a credential.
  // Base64URL decode the challenge.
  options.challenge = base64url.decode(options.challenge);

  // The empty allowCredentials array invokes an account selector
  by discoverable credentials.
  options.allowCredentials = [];

  // Invoke the WebAuthn get() function.
  const cred = await navigator.credentials.get({
    publicKey: options,

    // Request a conditional UI.
    mediation: 'conditional'
  });

  // TODO: Add an ability to authenticate with a passkey: Verify the credential.
  const credential = {};
  credential.id = cred.id;
  credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
  credential.type = cred.type;

  // Base64URL encode some values.
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const authenticatorData =
  base64url.encode(cred.response.authenticatorData);
  const signature = base64url.encode(cred.response.signature);
  const userHandle = base64url.encode(cred.response.userHandle);

  credential.response = {
    clientDataJSON,
    authenticatorData,
    signature,
    userHandle,
  };

  return await _fetch(`/auth/signinResponse`, credential);
};

6. 向浏览器自动填充项添加通行密钥

若用户在初始登录之后再次返回您的网站,您肯定希望让用户尽可能轻松、安全地登录。如果您向登录页面添加了使用通行密钥登录按钮,用户可以点击此按钮,在浏览器的帐号选择器中选择一个通行密钥,然后使用屏幕锁定功能验证自己的身份。

不过,并非所有用户都能够立即从密码改换为通行密钥。这意味着,您暂时还不能移除密码登录选项,直到所有用户都改用通行密钥之后,您才能移除基于密码的登录表单。不过,如果您同时呈现密码表单和通行密钥按钮,则用户将必须费心选择到底要使用哪一种登录方式,这是没有必要的。理想情况下,您应该提供一个简单直接的登录流程。

这正是“条件界面”的用武之地。条件界面是 WebAuthn 的一项功能,借助该功能,您可以使表单输入字段自动填充密码,也可以自动填充通行密钥。如果用户在自动填充建议中点按通行密钥,系统就会要求用户使用设备的屏幕锁定功能在本地验证自己的身份。这是一种无缝衔接的用户体验,因为用户操作与基于密码的登录几乎完全相同。

表单自动填充项中建议的通行密钥。

启用条件界面

如要启用条件界面,您只需在输入字段的 autocomplete 属性中添加一个 webauthn 令牌即可。设置该令牌后,您可以调用 navigator.credentials.get() 方法并使用 mediation: 'conditional' 字符串,以便有条件地触发屏幕锁定功能界面。

  • 如要启用条件界面,请在 view/index.html 文件相关注释后面,将现有的用户名输入字段替换为以下 HTML 内容:

view/index.html

<!-- TODO: Add passkeys to the browser autofill: Enable conditional UI. -->
<input
  type="text"
  id="username"
  class="mdc-text-field__input"
  aria-labelledby="username-label"
  name="username"
  autocomplete="username webauthn"
  autofocus />

检测功能、调用 WebAuthn 并启用条件界面

  1. view/index.html 文件相关注释后面,将现有 import 语句替换为以下代码:

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
import {
 
$,
 
_fetch,
 
loading,
 
authenticate
} from "/client.js";

此代码会导入您在前面实现的 authenticate() 函数。

  1. 确认 window.PulicKeyCredential 对象可用,并且 PublicKeyCredential.isConditionalMediationAvailable() 方法会返回 true 值,然后调用 authenticate() 函数:

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
if (
  window.PublicKeyCredential &&
  PublicKeyCredential.isConditionalMediationAvailable
) {
  try {

    // Is conditional UI available in this browser?
    const cma =
      await PublicKeyCredential.isConditionalMediationAvailable();
    if (cma) {

      // If conditional UI is available, invoke the authenticate() function.
      const user = await authenticate();
      if (user) {

        // Proceed only when authentication succeeds.
        $("#username").value = user.username;
        loading.start();
        location.href = "/home";
      } else {
        throw new Error("User not found.");
      }
    }
  } catch (e) {
    loading.stop();

    // A NotAllowedError indicates that the user canceled the operation.
    if (e.name !== "NotAllowedError") {
      console.error(e);
      alert(e.message);
    }
  }
}

查看本部分的解决方案代码

view/index.html

<!-- TODO: Add passkeys to the browser autofill: Enable conditional UI. -->
<input
  type="text"
  id="username"
  class="mdc-text-field__input"
  aria-labelledby="username-label"
  name="username"
  autocomplete="username webauthn"
  autofocus
/>

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
import {
 
$,
 
_fetch,
 
loading,
 
authenticate
} from '/client.js';

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
// Is WebAuthn avaiable in this browser?
if (window.PublicKeyCredential &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  try {

    // Is a conditional UI available in this browser?
    const cma= await PublicKeyCredential.isConditionalMediationAvailable();
    if (cma) {

      // If a conditional UI is available, invoke the authenticate() function.
      const user = await authenticate();
      if (user) {

        // Proceed only when authentication succeeds.
        $('#username').value = user.username;
        loading.start();
        location.href = '/home';
      } else {
        throw new Error('User not found.');
      }
    }
  } catch (e) {
    loading.stop();

    // A NotAllowedError indicates that the user canceled the operation.
    if (e.name !== 'NotAllowedError') {
      console.error(e);
      alert(e.message);
    }
  }
}

试用一下

您现已在网站上实现了通行密钥的创建、注册、显示和身份验证。

如要试用其效果,请按以下步骤操作:

  1. 前往“预览”标签页。
  2. 如有必要,请退出帐号。
  3. 点击用户名文本框。随即会出现一个对话框。
  4. 选择您要登录的帐号。
  5. 使用设备的屏幕锁定功能来验证您的身份。系统会将您重定向至 /home 页面并登录。

提示您使用已保存的密码或通行密钥验证您的身份的对话框。