WebAuthn 策略 - Remix Auth

使用 Web 身份验证 密钥和物理令牌验证用户。它是使用 SimpleWebAuthn 实现的,并支持使用密钥进行用户身份验证和用户注册。

此包应被视为不稳定。它在我的有限测试中有效,但我没有涵盖所有情况或编写自动化测试。买者自负

支持的运行时

运行时 有支持
Node.js
Cloudflare

我还没有在 Cloudflare 环境中测试它。如果你这样做,请告诉我结果!

此包也只支持 ESM,因为 package.json 很吓人,我不确定如何设置必要的构建步骤。你可能需要将此添加到 remix.config.js 文件中的 serverDependenciesToBundle 中。

关于 Web 身份验证

Web 身份验证允许用户将设备注册为密钥。该设备可以是 USB 设备(如 Yubikey)、运行网页的计算机或单独的蓝牙连接设备(如智能手机)。此页面很好地总结了优势,你也可以在这里亲身体验

WebAuthn 遵循两步过程。首先,将设备注册为密钥。浏览器生成一对私钥/公钥,将其与用户 ID 和用户名关联,并将公钥发送到服务器进行验证。此时,服务器可以创建具有该密钥的新用户,或者如果用户已登录,服务器可以将该密钥与现有用户关联。

身份验证步骤中,浏览器使用密钥的私钥对服务器发送的挑战进行签名,服务器在验证步骤中使用其存储的公钥检查签名。

此策略处理生成挑战、将其存储在会话存储中、将 WebAuthn 选项传递给客户端、生成密钥以及验证密钥。由于此策略需要数据库持久性和基于浏览器的 API,因此需要更多工作才能设置。

注意:此策略还需要在浏览器上生成字符串用户 ID。如果你的设置需要生成 ID,你可能需要通过创建身份验证用户 ID 和实际用户 ID 之间的映射来解决此限制。

安装

安装

此项目依赖于 remix-auth。安装它并按照设置说明进行操作

npm install remix-auth remix-auth-webauthn

数据库

此策略需要数据库访问权限才能存储用户身份验证器。数据库类型无关紧要,但该策略期望身份验证器与以下接口匹配(如 @simplewebauthn/server 提供的那样)。

interface Authenticator {
  // SQL: Encode to base64url then store as `TEXT` or a large `VARCHAR(511)`. Index this column
  credentialID: string;
  // Some reference to the user object. Consider indexing this column too
  userId: string;
  // SQL: Encode to base64url and store as `TEXT`
  credentialPublicKey: string;
  // SQL: Consider `BIGINT` since some authenticators return atomic timestamps as counters
  counter: number;
  // SQL: `VARCHAR(32)` or similar, longest possible value is currently 12 characters
  // Ex: 'singleDevice' | 'multiDevice'
  credentialDeviceType: string;
  // SQL: `BOOL` or whatever similar type is supported
  credentialBackedUp: boolean;
  // SQL: `VARCHAR(255)` and store string array or a CSV string
  // Ex: ['usb' | 'ble' | 'nfc' | 'internal']
  transports: string;
  // SQL: `VARCHAR(36)` or similar, since AAGUIDs are 36 characters in length
  aaguid: string;
}

如果你只是玩玩,可以使用此存根内存数据库。

显示代码
// /app/db.server.ts
import { type Authenticator } from "remix-auth-webauthn/server";

export type User = { id: string; username: string };

const authenticators = new Map<string, Authenticator>();
const users = new Map<string, User>();
export function getAuthenticatorById(id: string) {
  return authenticators.get(id) || null;
}
export function getAuthenticators(user: User | null) {
  if (!user) return [];

  const userAuthenticators: Authenticator[] = [];
  authenticators.forEach((authenticator) => {
    if (authenticator.userId === user.id) {
      userAuthenticators.push(authenticator);
    }
  });

  return userAuthenticators;
}
export function getUserByUsername(username: string) {
  users.forEach((user) => {
    if (user.username === username) {
      return user;
    }
  });
  return null;
}
export function getUserById(id: string) {
  return users.get(id) || null;
}
export function createAuthenticator(
  authenticator: Omit<Authenticator, "userId">,
  userId: string
) {
  authenticators.set(authenticator.credentialID, { ...authenticator, userId });
}
export function createUser(username: string) {
  const user = { id: Math.random().toString(36), username };
  users.set(user.id, user);
  return user;
}

请注意,此数据库将在你的服务器每次重启时重置,但你生成的任何密钥都将保留在你的设备上。你必须手动删除它们。

创建策略实例

此策略试图不对你的数据库结构进行假设,因此它需要几个配置选项。此外,为了让你访问 WebAuthnStrategy 实例上的方法,请在将其传递给 authenticator.use 之前创建并导出它。

// /app/authenticator.server.ts
import { WebAuthnStrategy } from "remix-auth-webauthn/server";
import {
  getAuthenticators,
  getUserByUsername,
  getAuthenticatorById,
  type User,
  createUser,
  createAuthenticator,
  getUserById,
} from "./db";
import { Authenticator } from "remix-auth";
import { sessionStorage } from "./session.server";

export let authenticator = new Authenticator<User>(sessionStorage);

export const webAuthnStrategy = new WebAuthnStrategy<User>(
  {
    // The human-readable name of your app
    // Type: string | (response:Response) => Promise<string> | string
    rpName: "Remix Auth WebAuthn",
    // The hostname of the website, determines where passkeys can be used
    // See https://www.w3.org/TR/webauthn-2/#relying-party-identifier
    // Type: string | (response:Response) => Promise<string> | string
    rpID: (request) => new URL(request.url).hostname,
    // Website URL (or array of URLs) where the registration can occur
    origin: (request) => new URL(request.url).origin,
    // Return the list of authenticators associated with this user. You might
    // need to transform a CSV string into a list of strings at this step.
    getUserAuthenticators: async (user) => {
      const authenticators = await getAuthenticators(user);

      return authenticators.map((authenticator) => ({
        ...authenticator,
        transports: authenticator.transports.split(","),
      }));
    },
    // Transform the user object into the shape expected by the strategy.
    // You can use a regular username, the users email address, or something else.
    getUserDetails: (user) =>
      user ? { id: user.id, username: user.username } : null,
    // Find a user in the database with their username/email.
    getUserByUsername: (username) => getUserByUsername(username),
    getAuthenticatorById: (id) => getAuthenticatorById(id),
  },
  async function verify({ authenticator, type, username }) {
    // Verify Implementation Here
  }
);

authenticator.use(webAuthnStrategy);

编写你的验证函数

验证函数处理注册身份验证步骤,并期望你返回一个 user 对象,或者如果验证失败则抛出错误。

验证函数将接收一个身份验证器对象(不含用户 ID)、提供的用户名以及验证类型 - registrationauthentication

注意:应该可以通过检查用户是否已登录来扩展此功能,以支持为单个用户提供多个密钥。

const webAuthnStrategy = new WebAuthnStrategy(
  {
    // Options here...
  },
  async function verify({ authenticator, type, username }) {
    let user: User | null = null;
    const savedAuthenticator = await getAuthenticatorById(
      authenticator.credentialID
    );
    if (type === "registration") {
      // Check if the authenticator exists in the database
      if (savedAuthenticator) {
        throw new Error("Authenticator has already been registered.");
      } else {
        // Username is null for authentication verification,
        // but required for registration verification.
        // It is unlikely this error will ever be thrown,
        // but it helps with the TypeScript checking
        if (!username) throw new Error("Username is required.");
        user = await getUserByUsername(username);

        // Don't allow someone to register a passkey for
        // someone elses account.
        if (user) throw new Error("User already exists.");

        // Create a new user and authenticator
        user = await createUser(username);
        await createAuthenticator(authenticator, user.id);
      }
    } else if (type === "authentication") {
      if (!savedAuthenticator) throw new Error("Authenticator not found");
      user = await getUserById(savedAuthenticator.userId);
    }

    if (!user) throw new Error("User not found");
    return user;
  }
);

设置你的登录页面加载器和操作

登录页面将需要一个加载器来从服务器提供 WebAuthn 选项,以及一个操作来将密钥传递回服务器。

// /app/routes/_auth.login.ts
export async function loader({ request, response }: LoaderFunctionArgs) {
  const user = await authenticator.isAuthenticated(request);
  let session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );

  const options = webAuthnStrategy.generateOptions(request, user);

  // Set the challenge in a session cookie so it can be accessed later.
  session.set("challenge", options.challenge)

  // Update the cookie
  response.headers.append("Set-Cookie", await sessionStorage.commitSession(session))
  response.headers.set("Cache-Control":"no-store")

  return options;
}

export async function action({ request }: ActionFunctionArgs) {
  try {
    await authenticator.authenticate("webauthn", request, {
      successRedirect: "/",
    });
    return { error: null };
  } catch (error) {
    // This allows us to return errors to the page without triggering the error boundary.
    if (error instanceof Response && error.status >= 400) {
      return { error: (await error.json()) as { message: string } };
    }
    throw error;
  }
}

如果你选择将挑战存储在会话存储以外的地方(例如数据库中),则可以将其作为上下文传递到操作中的 authenticate 函数。

export async function action({ request }: ActionFunctionArgs) {
  const challenge = await getChallenge(request);
  try {
    await authenticator.authenticate("webauthn", request, {
      successRedirect: "/",
      context: { challenge },
    });
    return { error: null };
  } catch (error) {
    // This allows us to return errors to the page without triggering the error boundary.
    if (error instanceof Response && error.status >= 400) {
      return { error: (await error.json()) as { message: string } };
    }
    throw error;
  }
}

设置表单

为了便于使用,此策略提供了一个 onSubmit 处理程序,该处理程序执行必要的浏览器端操作来生成密钥。onSubmit 处理程序是通过传递来自上面加载器的选项对象生成的。根据你的设置,你可能需要为注册和身份验证实现单独的表单。

注册时,该过程遵循以下几个步骤

  1. 首次访问登录页面时,服务器将提供一个选项对象,该对象可用于注册和身份验证。
  2. 用户通过输入他们想要使用的用户名并按下“检查用户名”按钮来请求注册,该按钮提交一个 GET 请求以获取更新的选项。
  3. 服务器会回复用户名是否已被占用以及用户是否已注册密钥,这样浏览器就不会生成重复项。
  4. 表单必须再次提交,这次是 POST,并附带用于注册的实际密钥。
  5. 服务器验证密钥、创建新用户并登录用户。

你的注册表单应包含一个必需的 username 字段和 <button name="intent" value="registration"> 用于触发注册。你可以在提交按钮上使用 formMethod="GET" 来提交 username 字段的值到加载器以检查用户名是否可用。registration 按钮应根据加载器中的选项是否指示用户名可用而改变状态和行为。这将在下面演示。

身份验证是一个更简单的过程,只需要按下一次按钮即可

  1. 用户请求身份验证,浏览器会显示该域的可用密钥。
  2. 用户选择一个密钥,并生成并提交表单到服务器。
  3. 服务器通过将密钥与数据库进行比较来验证密钥,并登录用户。

由于用户名与浏览器中的密钥一起存储,因此身份验证表单不需要 username 字段,但你应该包含一个提交按钮,如下所示:<button name="intent" value="authentication"> 以触发身份验证流程。

以下是表单在实践中的样子

// /app/routes/_auth.login.ts
import { handleFormSubmit } from "remix-auth-webauthn/browser";

export default function Login() {
  const options = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  return (
    <Form onSubmit={handleFormSubmit(options)} method="POST">
      <label>
        Username
        <input type="text" name="username" />
      </label>
      <button formMethod="GET">Check Username</button>
      <button
        name="intent"
        value="registration"
        disabled={options.usernameAvailable !== true}
      >
        Register
      </button>
      <button name="intent" value="authentication">
        Authenticate
      </button>
      {actionData?.error ? <div>{actionData.error.message}</div> : null}
    </Form>
  );
}

你可以在 handleFormSubmit 的第二个参数中设置 attestationType。如果省略,则默认为 none

onSubmit={handleFormSubmit(options, { attestationType: "direct" })}

向用户显示密钥

在你的应用中支持密钥的重要部分是允许你的用户在设置页面或类似页面上管理他们的密钥。用户应该能够查看他们的密钥列表、从你的数据库中删除密钥以及注册新的密钥。

你可以使用策略实例上的 getUserAuthenticators 函数来获取与用户关联的密钥列表

// /app/routes/settings.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await authenticator.isAuthenticated(request);
  if (!user) {
    return redirect("/login");
  }

  const authenticators = await webAuthnStrategy.getUserAuthenticators(user);

  return json({ authenticators });
};

export default function Settings() {
  const data = useLoaderData();

  return (
    <ul>
      {data.authenticators.map((authenticator) => (
        ...
      ))}
    </ul>
  );
}

列出密钥时,显示注册密钥的设备名称也很有用,这样用户就可以区分它们(尤其是在他们注册了多个密钥的情况下)。为了实现这一点,你可以使用 passkey-authenticator-aaguids 存储库中提供的社区来源列表,将每个身份验证器的 aaguid 与其注册设备匹配,并将名称(甚至品牌图标)显示给用户。

要了解有关密钥管理最佳实践的更多信息,请参阅 Google 的 密钥用户旅程 指南。

待办事项