在 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