程序员 在 Next.JS 上实施 WebAuthn(Passkeys)

goll · July 11, 2023 · 24 hits

Implementing Apple Passkeys using Next.JS and MongoDB

在 WWDC 2022 中,我们看到 Apple 展示了“Passkeys”,这是一种彻底改变了无密码身份验证的全新身份验证流程。这个新的协议被称为 webauthn。在本文中,我将在 NextJS 和 mongoose(mongoDB)中实现这项新技术。

项目的起步:

首先,让我们从创建一个新的 NextJS 项目开始,运行npx create-next-app并回答问题。我将把项目称为learningwebauthn,你可以自由地给它任何你喜欢的名字。我在这个项目中不使用 TypeScript。如果你打算使用 TypeScript,你需要自行进行必要的更改。我使用 TailwindCSS 进行样式。

npx create-next-app

安装依赖项:

进行依赖项安装之前,记得先切换到项目的目录。

cd learningwebauthn

我们现在将安装项目的依赖项,在之前提到的,我们将使用 mongoose 作为数据库。由于 NextJS 不支持 session,我们还将使用 iron-session 进行会话管理。我们还将使用@github/webauthn-json 来实现 webauthn。运行以下命令来下载和安装这些包。

npm install mongoose iron-session @github/webauthn-json

我们还需要一个包——@simplewebauthn/server,我们将用其进行挑战验证。

npm install @simplewebauthn/server

设置数据库:

让我们开始设置 mongoose。

在项目的根目录中创建一个名为 lib 的新文件夹。

mkdir lib

在 lib 文件夹中创建一个名为 dbConnect.js 的文件。该文件将包含连接到 MongoDB 的所有必要代码。

import mongoose from 'mongoose'

const MONGODB_URI = process.env.MONGODB_URI
if (!MONGODB_URI) {
  throw new Error(
    'Please define the MONGODB_URI environment variable inside .env.local'
  )
}

let cached = global.mongoose
if (!cached) {
  cached = global.mongoose = { conn: null, promise: null }
}

async function dbConnect() {
  if (cached.conn) {
    return cached.conn
  }

  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
    }

    cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
      return mongoose
    })
  }

  try {
    cached.conn = await cached.promise
  } catch (e) {
    cached.promise = null
    throw e
  }

  return cached.conn
}

export default dbConnect

上述代码中,我们引入了 mongoose,然后我们检查存储在 MONGODB_URI 环境变量中的 MongoDB URI。然后,我们定义了一个名为 cached 的新对象,该对象尝试从全局变量中获取 mongoose 实例。在下一行中,如果实例不存在于全局变量中,我们创建一个对象,该对象表示连接和承诺都为 null。现在我们定义一个函数,该函数返回数据库连接。如果连接已经存在于缓存中,我们只需返回相同的连接。如果未定义承诺,则运行 mongoose.connect(...) 以使用 URI 建立与数据库的连接,在获取到连接后,将其保存在缓存中。之后,我们尝试等待承诺以获取连接。如果发生错误,我们抛出错误。之后,连接可在 cached.conn 处使用,我们返回它。最后一行只是默认导出该函数。

定义数据模型:

现在让我们定义用于存储用户和用户信息的模型。

在 models 中创建一个名为 models 的新文件夹。

mkdir models

在该文件夹中,创建一个名为 User.js 的新文件。此文件将包含用户模型的代码,你可以根据你的项目存储其他用户信息。

import mongoose from "mongoose";

const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    unique: true,
  },
  username: {
    type: String,
    unique: true,
  },
  credentials: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Credentials",
    },
  ],
});

export default mongoose.models.User || mongoose.model("User", UserSchema);

在同一个文件夹中,创建一个名为 Credential.js 的新文件。此文件将包含与用户关联的凭据模型。

import mongoose from "mongoose";

const CredentialsSchema = new mongoose.Schema({
  name: {
    type: String,
  },
  externalId: {
    type: String,
    unique: true,
  },
  publicKey: {
    type: String,
  },
  dateAdded: {
    type: Date,
    default: Date.now(),
  },
});

export default mongoose.models.Credentials ||  mongoose.model("Credentials", CredentialsSchema);

会话设置:

接下来设置会话 cookie。

在 lib 文件夹中创建一个名为 session.js 的文件,在文件中添加以下代码:

import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";

export const sessionOptions = {
  cookieName: "webauthn-token",
  password: process.env.COOKIE_SECRET,
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
  },
};

export function withSessionAPI(handler) {
  return withIronSessionApiRoute(handler, sessionOptions);
}

export function withSession(handler) {
  return withIronSessionSsr(handler, sessionOptions);
}

上述代码导出了一个具有会话选项的对象,它具有一个存储在名为 COOKIE_SECRET 的环境变量中的密码,这可以确保 cookie 的安全存储。cookieName 用于在浏览器上识别 cookie。选项设置为在生产环境下加密 cookie。我们还导出了两个函数,我们稍后将在后面的代码中使用它们来获取会话。

注册页面:

现在让我们开始编写实际面向用户的页面,在注册页面中,我们将接受用户的信息并设置 passkey。

在 pages 中创建一个名为 register.js 的文件,并添加以下代码:

import { useEffect, useState } from "react";
import { supported } from "@github/webauthn-json";

export default function Register() {
  const [email, setEmail] = useState("");
  const [username, setUsername] = useState("");
  const [support, setSupport] = useState(false);

  useEffect(() => {
    const checkAvailability = async () => {
      const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
      setSupport(available && supported());
    };

    checkAvailability();
  }, []);

  const handleRegister = () => {};

  return (
    <div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
      <h1 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight">
        Register
      </h1>
      <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
        {support ? (
          <form method="POST" action="/api/register" onSubmit={handleRegister}>
            <div className="p-3">
              <label
                htmlFor="email"
                className="block text-sm font-medium leading-6"
              >
                Email
              </label>
              <input
                type="email"
                id="email"
                name="email"
                className="block w-full rounded-md border-0 py-1.5 text-black"
                onChange={(e) => setEmail(e.target.value)}
              />
            </div>
            <div className="p-3">
              <label
                htmlFor="username"
                className="block text-sm font-medium leading-6"
              >
                Username
              </label>
              <input
                type="text"
                id="username"
                name="username"
                className="block w-full rounded-md border-0 py-1.5 text-black"
                onChange={(e) => setUsername(e.target.value)}
              />
            </div>
            <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
              <button
                type="submit"
                className="w-full bg-blue-600 p-3 rounded-md py-1.5 block"
              >
                Register
              </button>
            </div>
          </form>
        ) : (
          <div>Sorry, your browser does not support WebAuthn.</div>
        )}
      </div>
    </div>
  );
}

以上代码是一个 React 元素,它定义了注册页面和两个字段(电子邮件和用户名)。只有在用户的浏览器支持 webauthn 时,表单才会渲染。表单将其提交到 API,API 将返回相关信息以继续该过程。表单的 onSubmit 事件链接到后面将要编写的函数。在这里,我们需要处理用户信息。到目前为止,该项目应该是可运行的。

npm run dev

运行上面的命令,然后使用浏览器打开http://localhost:3000/register。这里你应该会看到我们设计的页面。

生成挑战:

根据 webauthn 协议,我们需要生成一个挑战来实现 webauthn。在 lib/auth.js 文件中添加新代码。

import crypto from "node:crypto";

function clean(str) {
  return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

export function generateChallenge() {
  return clean(crypto.randomBytes(32).toString("base64"));
}

上述代码包含两个函数,clean 函数用于删除可能破坏身份验证过程的符号,第二个函数 generateChallenge 用于生成实际的挑战,并返回适用于身份验证过程的清理版本的挑战。

运行挑战:

现在我们回到 pages/register.js 并添加 getServerSideProps 函数以及会话代码。

import { useEffect, useState } from "react";
import { supported } from "@github/webauthn-json";
import { generateChallenge } from "@/lib/auth";
export default function Register({ challenge }) {
  // Same as before
}
export const getServerSideProps = withSession(async function ({ req, res }) {
  const challenge = generateChallenge();
  req.session.challenge = challenge;
  await req.session.save();

  return { props: { challenge } };
});

上述代码允许在服务器端呈现页面,我们生成挑战并使其在元素中可用。我们还将其保存在使用 iron-session 库的会话中。

来自:https://medium.com/@vachanmn123/implementing-webauthn-passkeys-on-next-js-baa36ecd59cb

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.