WebAuthn 策略 - Remix Auth

使用 Web Authentication 密碼金鑰和實體憑證來驗證使用者。它使用 SimpleWebAuthn 實作,並支援使用密碼金鑰進行使用者驗證和使用者註冊。

這個套件應被視為不穩定。它在我的有限測試中運作,但我沒有涵蓋所有情況或編寫自動化測試。買者自負

支援的執行環境

執行環境 是否支援
Node.js
Cloudflare

我還沒有在 Cloudflare 環境中測試過。如果你測試了,請告訴我結果如何!

這個套件也僅支援 ESM,因為 package.json 很可怕,我不太確定如何設定必要的建置步驟。你可能需要在 remix.config.js 檔案中將其添加到 serverDependenciesToBundle

關於 Web Authentication

Web Authentication 允許使用者將裝置註冊為密碼金鑰。該裝置可以是 USB 裝置(例如 Yubikey)、執行網頁的電腦,或單獨的藍牙連線裝置(例如智慧型手機)。此頁面有對優點的良好總結,你可以在這裡親自試試

WebAuthn 遵循兩個步驟的過程。首先,裝置被註冊為密碼金鑰。瀏覽器產生一對私鑰/公鑰,將其與使用者 ID 和使用者名稱相關聯,並將公鑰傳送到伺服器以進行驗證。此時,伺服器可以使用該密碼金鑰建立新使用者,或者如果使用者已登入,伺服器可以將該密碼金鑰與現有使用者關聯。

驗證步驟中,瀏覽器使用密碼金鑰的私鑰來簽署伺服器發送的挑戰,伺服器在驗證步驟中使用其儲存的公鑰來檢查該挑戰。

此策略處理產生挑戰、將其儲存在會話儲存中、將 WebAuthn 選項傳遞給客戶端、產生密碼金鑰以及驗證密碼金鑰。由於此策略需要資料庫持久性和基於瀏覽器的 API,因此需要更多的工作才能設定。

注意:此策略還需要在瀏覽器上產生字串使用者 ID。如果你的設定需要產生 ID,你可能必須透過建立驗證器使用者 ID 和你的實際使用者 ID 的映射來解決此限制。

設定

安裝

此專案取決於 remix-auth。安裝它並遵循設定說明

npm install remix-auth remix-auth-webauthn

會話儲存

你需要將密碼金鑰挑戰儲存在某種會話儲存中,以避免重播攻擊。你可以使用儲存使用者物件的相同會話儲存。

import { createCookieSessionStorage } from "react-router";
import { User } from "~/utils/db.server";

type SessionData = {
  user: User;
  challenge?: string;
};

type SessionFlashData = {
  error: string;
};

export const userSession = createCookieSessionStorage<
  SessionData,
  SessionFlashData
>({
  cookie: {
    name: "__session",
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 30, // One month
    path: "/",
    sameSite: "lax",
    secrets: ["s3cret1"],
    secure: process.env.NODE_ENV === "production",
  },
});

資料庫

此策略需要資料庫存取來儲存使用者驗證器。資料庫的種類並不重要,但該策略期望驗證器符合此介面(由 @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 async function getAuthenticatorById(id: string) {
  return authenticators.get(id) || null;
}
export async function getAuthenticators(user: User | null | undefined) {
  if (!user) return [];

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

  return userAuthenticators;
}
export async function getUserByUsername(username: string) {
  users.forEach((user) => {
    if (user.username === username) {
      return user;
    }
  });
  return null;
}
export async function getUserById(id: string) {
  return users.get(id) || null;
}
export async function createAuthenticator(
  authenticator: Omit<Authenticator, "userId">,
  userId: string
) {
  authenticators.set(authenticator.id, { ...authenticator, userId });
}
export async 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 { Authenticator } from "remix-auth";
import {
  WebAuthnStrategy,
  Authenticator as WebAuthnAuthenticator,
} from "remix-auth-webauthn";
import {
  createAuthenticator,
  createUser,
  getAuthenticatorById,
  getAuthenticators,
  getUserById,
  getUserByUsername,
  User,
} from "~/utils/db.server";

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

export const webAuthnStrategy = new WebAuthnStrategy<User>(
  {
    // The React Router session storage where the "challenge" key is stored
    sessionStorage: userSession,
    // 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 }) {
    // ...Implement later
  }
);

authenticator.use(webAuthnStrategy);

編寫你的驗證函式

驗證函式處理註冊驗證步驟,並期望你返回 user 物件,如果驗證失敗則拋出錯誤。

驗證函式將接收驗證器物件(不包含 userId)、提供的使用者名稱和驗證類型 - registrationauthentication

注意:透過檢查使用者是否已登入,應該可以擴展此功能以支援為單個使用者提供多個密碼金鑰。

const webAuthnStrategy = new WebAuthnStrategy(
  {
    // Options here...
  },
  async function verify({ authenticator, type, username }) {
    let user: User | null = null;
    const savedAuthenticator = await getAuthenticatorById(authenticator.id);
    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
import type { Route } from "./+types/home";

export async function loader({ request }: Route.LoaderArgs) {
  const session = await userSession.getSession(request.headers.get("cookie"));
  const user = session.get("user");
  const options = await 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
  return data(
    { options, user },
    {
      headers: {
        "Set-Cookie": await userSession.commitSession(session),
        "Cache-Control": "no-store",
      },
    }
  );
}

export async function action({ request }: Route.ActionArgs) {
  const session = await userSession.getSession(request.headers.get("cookie"));

  try {
    const user = await authenticator.authenticate("webauthn", request);
    session.set("user", user);

    // Redirect to the logged-in page.
    throw redirect("/", {
      headers: {
        "Set-Cookie": await userSession.commitSession(session),
      },
    });
  } catch (error) {
    // This allows us to return errors to the page without triggering the error boundary.
    if (error instanceof Error) {
      return { error, user: null };
    }
    // Throw other errors, such as responses that need to redirect the browser.
    throw error;
  }
}

請確保你用於設定挑戰的會話儲存與你傳遞給 WebAuthnStrategy 類別的儲存相同

設定表單

為了易於使用,此策略提供一個 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 Home({ loaderData, actionData }: Route.ComponentProps) {
  return (
    <Form
      onSubmit={handleFormSubmit(loaderData.options)}
      method="POST"
      className="flex flex-col gap-2 m-8 w-64"
    >
      <label>Username</label>
      <input
        type="text"
        name="username"
        placeholder="alexanderson1993"
        className="p-2 rounded"
      />
      <button formMethod="GET" className="px-2 py-1 bg-blue-500 rounded">
        Check Username
      </button>
      <button
        name="intent"
        value="registration"
        disabled={loaderData.options.usernameAvailable !== true}
        className="px-2 py-1 bg-orange-500 rounded disabled:opacity-50"
      >
        Register
      </button>
      <button
        name="intent"
        value="authentication"
        className="px-2 py-1 bg-green-500 rounded"
      >
        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 的 密碼金鑰使用者旅程指南。

待辦事項