JWT认证机制在表面上看起来非常可靠,但问题在于:如果攻击者获得了被窃取的令牌,那么该令牌在你的服务器眼中仍然被视为有效的。这才是真正的问题所在——承载令牌虽然能够证明持有者拥有该令牌,但却无法证明持有者使用的是可信设备。一旦攻击者得到了可重复使用的令牌,他们的重放攻击行为就会看起来像是一次普通的登录尝试。

WebAuthn改变了这一认证机制的运作方式。私钥始终保存在用户的设备上,而服务器则存储公钥、凭证ID以及计数器。每次用户进行注册或登录时,系统都会生成新的挑战信息。浏览器、认证服务器以及后端系统都会参与这个认证流程。

本指南将引导你完整地了解如何在Node.js环境中实现WebAuthn。你需要配置后端服务,连接注册和登录功能,正确存储密码密钥,用短期的会话机制取代长期有效的承载令牌,支持备用设备,并为高风险操作添加额外的验证步骤。

警告:WebAuthn仅适用于安全的环境中。在进行本地开发时,请使用`localhost`;而在其他情况下,则必须使用HTTPS协议。

目录

先决条件

为了顺利学习并充分利用本指南,你需要具备以下条件:

  • 具备JavaScript和Node.js的基础知识。

  • 了解TypeScript和Express的基本用法。

  • 熟悉前端与后端之间的交互流程,能够理解浏览器发送的`fetch()`请求以及它们与服务器之间的交互过程。

  • 拥有一台现代浏览器,以及支持密码密钥认证的功能设备,例如Touch ID、Face ID、Windows Hello、Android生物识别功能或安全钥匙。

  • 能够在`localhost`上进行本地测试。在开发过程中,演示示例会使用`localhost`作为依赖方ID和请求来源地址。

  • 无需事先掌握WebAuthn的相关知识,本文会逐步讲解整个认证流程。

为什么单独使用JWT存在局限性

JWT本身并不是问题所在。

问题在于人们通常采用的一些JWT部署方式。开发团队常常将有效期较长的令牌放置在攻击者容易获取的地方,然后过度依赖这些令牌来保障安全性。

这种漏洞产生的典型流程如下:

  • 你的服务器生成一个可重复使用的令牌。

  • 浏览器会保存这个令牌。

  • 恶意软件、XSS攻击、会话盗取或伪造的登录流程都会获取到这个令牌。

  • 攻击者随后会使用这个令牌发起请求。

  • 你的后端系统会识别出这个有效的令牌,从而认为该请求是真实的。

在高风险场景中,这种机制很快就会失效。对于管理员操作、资金转移、支付审批、邮箱地址更改、API密钥生成这类敏感操作来说,需要更强大的身份验证方式。

WebAuthn能够提供更强的安全保障,因为它的认证信息永远不会离开用户的设备。

JWT重放攻击与WebAuthn信任机制的对比

在上图中,左侧路径(使用可重复使用的JWT)展示了这种机制的风险:服务器在用户登录后生成令牌,浏览器保存该令牌,攻击者获取令牌后可以发起请求,而后端系统会在令牌有效期内接受这些请求,因此重放攻击成为可能。

而右侧路径(使用WebAuthn)则完全不同:每次登录时,服务器都会发送一个新的认证挑战码,用户的设备会使用存储在其中的私钥对挑战码进行签名,后端系统在验证签名成功后才会创建会话。

关键在于:JWT依赖的是存储在设备上的秘密信息,而WebAuthn则依靠设备生成的加密签名来进行身份验证。

WebAuthn带来了哪些变化

WebAuthn采用了非对称加密技术。

认证设备会生成一对密钥,其中私钥始终保存在设备上,后端系统只存储公钥,并用它来验证签名。登录时,服务器会发送新的挑战码,设备会对挑战码进行签名处理,后端系统则会用存储的公钥来验证签名结果。

这种机制同时改变了三件事:

  • 浏览器永远不会收到可重复使用的密码信息。

  • 即使攻击者获取了公钥,也无法用于登录。

  • 每次登录都需要使用服务器发送的新鲜挑战码。

在网页环境中,Passkey可以作为WebAuthn的补充机制。Passkey可以存储在本地设备上、同步到平台账户中,或者以物理安全密钥的形式存在。实际上,应用程序处理的依然是那些核心数据:凭证ID、公钥、传输方式、计数器、设备类型以及备份状态。

开始项目实施

到目前为止,我们已经了解了为什么WebAuthn如此重要,以及它如何改变登录体验。现在,是时候创建一个小项目来实际演示整个流程的运作方式了。

在这个演示中,您将创建一个简单的 Node.js 应用程序。在该应用程序中,用户可以注册一个密码密钥,使用该密钥登录,然后通过临时生成的会话来访问受保护的页面。我们的目的并不是要开发出一个功能完备的全栈产品,而是要清晰地展示后端处理流程的核心部分,这样您就能理解注册、登录、会话机制以及高级验证功能是如何相互关联的。

在开始之前,请确保您的机器上已经安装了Node.js和npm。您可以从官方网站下载Node.js,或者如果需要管理多个版本的Node.js,也可以使用nvm。

node -v
npm -v

预期的输出应该是Node LTS版本号以及npm的版本号。

接下来,创建一个新项目文件夹并初始化其基本结构:

mkdir webauthn-node-demo
cd webauthn-node-demo
npm init -y
npx tsc --init
mkdir src

安装依赖项

现在项目已经初始化完毕,接下来需要安装所需的包。

  1. TypeScript和tsx:TypeScript用于为后端代码添加类型定义,而tsx则在开发过程中用于编译TypeScript文件。
npm install -D typescript tsx @types/node
npx tsc -v
npx tsx --version
  1. Express和会话管理:Express负责处理HTTP请求路由,而express-session则用于存储临时性的服务器会话状态。
npm install express express-session @types/express @types/express-session
  1. SimpleWebAuthn:@simplewebauthn/server用于生成注册所需的选项并验证用户提交的响应,而@simplewebauthn/browser则负责启动浏览器端的认证流程。
npm install @simplewebauthn/server @simplewebauthn/browser

打开您的package.json文件,将“scripts”部分更新为包含以下命令:

{
    "scripts": {
        "dev": "tsx watch src/app.ts",
        "build": "tsc",
        "start": "node dist/app.js"
    }
}

定义数据模型

在编写路由逻辑之前,我们首先需要确定这个应用程序应该如何存储passkeys。

对于基于密码的登录方式,通常会存储密码的哈希值;而使用WebAuthn时,则需要存储其他类型的数据。当用户注册了一个passkey之后,服务器必须保留这些数据,以便在未来进行登录验证时使用——这些数据包括凭证ID、公钥、计数器以及一些关于认证器的元信息。

正因为如此,数据模型的设计从一开始就非常重要。无论是注册流程还是认证过程,都依赖于这些存储的数据,因此在深入开发之前明确数据结构是非常有必要的。

可以这样理解:一个用户可以拥有多个passkeys,每个passkey都应该作为与该用户相关联的独立记录被存储起来。

创建一个名为src/app.ts的新文件。我们将在这个文件中构建后端逻辑,首先从定义数据模型开始。

// src/app.ts
type Passkey = {
    id: string;
    publicKey: Uint8Array;
    counter: number;
    deviceType: "singleDevice" | "multiDevice";
    backedUp: boolean;
    transports?: string[];
};

type User = {
    id: string;
    email: string;
    webAuthnUserID: Uint8Array;
    passkeys: Passkey[];
};

const users = new Map();

function findUserByEmail(email: string) {
    return [...users.values()).find((user) => user.email === email);
}

这里需要重点关注的内容包括:

  • id用于后续识别该凭证。

  • publicKey用于验证未来的签名。

  • counter有助于检测被克隆或出现异常行为的认证设备。

  • deviceTypebackedUp能够提供有用的恢复信息。

  • webAuthnUserID应该是一个稳定的二进制值,每位用户只存储一次。

提示:如果数据库返回的是Buffer或其他形式的publicKey》封装对象,在进行验证之前,请将其转换回Uint8Array》格式。

构建服务器基础架构

接下来,将核心的Express应用程序及相关配置添加到src/app.ts文件中:

// src/app.ts
import express from "express";
import session from "express-session";
import { randomBytes, randomUUID } from "node:crypto";
import {
    generateAuthenticationOptions,
    generateRegistrationOptions,
    verifyAuthenticationResponse,
    verifyRegistrationResponse,
    type WebAuthnCredential,
} from "@simplewebauthn/server";

const rpName = "Node Auth Lab";
const rpID = "localhost";
const origin = "http://localhost:3000";

declare module "express-session" {
    interface SessionData {
        currentChallenge?: string;
        pendingUserId?: string;
        userId?: string;
        stepUpUntil?: number;
    }
}

const app = express();

app.use(express.json());

app.use(
    session({
        secret: "replace-this-in-production",
        resave: false,
        saveUninitialized: false,
        cookie: {
            httpOnly: true,
            sameSite: "lax",
            secure: false,
            maxAge: 10 * 60 * 1000,
        },
    }),
);

这样就可以获得以下功能所需的共享状态:

  • 用于跟踪注册过程中的挑战信息。

  • 用于跟踪认证过程中的挑战信息。

  • 保存用户的登录会话状态。

  • 为高风险操作提供短暂的验证窗口。

注册流程

注册环节是指用户的设备生成新的密钥对,并将其与自己的账户关联起来。

在WebAuthn的相关文档中,你经常会看到“ceremony”这个词。简单来说,它指的是在注册或登录过程中,服务器、浏览器以及认证设备之间进行的全部交互过程。因此,当我们提到“注册流程”时,实际上是指生成和验证新密钥对的全部步骤。

这个过程包含三个主要环节:

  • 后端负责准备注册所需的选项及挑战信息。

  • 浏览器发起WebAuthn请求。

  • 然后,诸如手机、笔记本电脑或安全密钥之类的认证设备会生成密钥对,并将结果返回给浏览器。

WebAuthn注册流程

在上述图中,注册流程会将新的密码密钥与您的账户关联起来。浏览器会向服务器请求注册相关选项,服务器会生成一个挑战码,并返回一份JSON格式的配置信息。随后,浏览器会启动WebAuthn认证流程。在经过生物特征识别或安全密钥验证后,您的身份验证设备会生成一对新的密钥,其中私钥会保留在设备内部。最后,浏览器会将认证结果发送回服务器。

接下来进行验证环节。服务器会检查挑战码、请求来源以及依赖方的ID。验证通过后,服务器会存储凭证ID、公钥、计数器值、设备类型以及备份状态。此时,您的账户就已经拥有一把密码密钥了。

既然整个流程已经清晰明了,那么我们就一步一步来实现它吧。首先,我们需要从后端返回注册选项。

1. 从后端返回注册选项

这个接口会在需要时创建新用户,生成注册选项,并将这些信息存储在服务器端。

在您的`src/app.ts`文件中添加以下代码:


// src/app.ts
app.post("/auth/register/options", async (req, res) => {
    const { email } = req.body;

    if (!email) {
        return res.status(400).json({ error: "必须提供电子邮件地址" });
    }

    let user = findUserByEmail(email);

    if (!user) {
        user = {
            id: randomUUID(),
            email,
            webAuthnUserID: randomBytes(32),
            passkeys: [],
        };

        users.set(user.id, user);
    }

    const options = await generateRegistrationOptions({
        rpName,
        rpID,
        userName: user.email,
        userDisplayName: user.email,
        userID: user.webAuthnUserID,
        attestationType: "none",
        excludeCredentials: user.passkeys.map((passkey) => ({
            id: passkey.id,
            transports: passkey.transports,
        }),
        authenticatorSelection: {
            residentKey: "preferred",
            userVerification: "preferred",
        },
    });

    req.session.currentChallenge = options.challenge;
    req.sessionpendingUserId = user.id;

    res.json(options);
});

在这里有几点需要注意:

  • attestationType: 'none'这种设置可以使认证流程更加简洁,除非您需要更详细的设备验证信息。
  • excludeCredentials这个选项可以防止使用相同的身份验证方式进行重复注册。
  • userVerification: 'preferred'这意味着浏览器会优先选择生物特征识别或本地设备解锁方式来进行认证。

2. 在浏览器中开始注册流程

在浏览器端,您需要向后端请求注册选项,然后将这些选项传递给`startRegistration()`函数。

现在,请在`src/browser.ts`文件中创建一个新的函数,用于处理客户端端的WebAuthn交互逻辑。请添加以下代码:

// src/browser.ts
import { startRegistration } from "@simplewebauthn/browser";

export async function registerPasskey(email: string) {
    const optionsResp = await fetch("/auth/register/options", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({ email }),
    });

    const optionsJSON = await optionsResp.json();

    const registrationResponse = await startRegistration({ optionsJSON });

    const verifyResp = await fetch("/auth/register/verify", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(registrationResponse),
    });

    return verifyResp.json();
}

注意: 浏览器本身无法直接运行 TypeScript 代码,也无法直接导入 npm 包。在实际应用中,你需要将 src/browser.ts 文件导入到你的前端框架(如 React、Vue 等)中,或者使用 Vite、Webpack、esbuild 等工具将其打包后再发送给客户端。

在底层,浏览器会与认证系统进行交互。这可能会触发 Face ID、Touch ID、Windows Hello、Android 生物识别功能,或者要求用户输入物理安全密钥。

3. 验证注册结果并保存密码键

当浏览器返回响应后,你需要将其与你之前存储的挑战信息以及相关详细数据进行比对。

将以下代码添加到 src/app.ts 文件中:

// src/app.ts
app.post("/auth/register/verify", async (req, res) => {
    const user = users.get(req.session.pendingUserId ?? "");

    if (!user || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    let verification;

    try {
        verification = await verifyRegistrationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error:
                error instanceof Error ? error.message : "注册失败",
        });
    }

    if (!verification.verified || !verification.registrationInfo) {
        return res.status(400).json({ verified: false });
    }

    const { credential, credentialDeviceType, credentialBackedUp } =
        verificationregistrationInfo;

    user.passkeys.push({
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter,
        transports: credential.transports,
        deviceType: credential_deviceType,
        backedUp: credential.BackedUp,
    });

    req.session.currentChallenge = undefined;
    req.session.pendingUserId = undefined;

    res.json({ verified: true });
});

在当前这个阶段,用户的密码哈希值并未被存储在快速访问路径中。现在,认证机构掌握了私钥;而你的服务器仅保存那些后续验证过程中所需的数据而已。

认证流程

认证环节的目的是让用户证明他们仍然拥有之前注册的密码密钥。与注册过程类似,在WebAuthn中,这一流程也被称作“认证仪式”。其目的并非创建新的凭证,而是以安全的方式验证用户已有的凭证。

这个认证流程包含四个步骤:

  • 服务器会生成一个新的挑战信息。

  • 浏览器会将这个挑战信息传递给认证器。

  • 认证器会使用设备中存储的私钥对挑战信息进行签名处理。

  • 随后,服务器会使用在注册时保存的公钥来验证认证器的响应结果。

WebAuthn认证流程

在上述流程中,登录过程是通过挑战-响应机制来完成的。浏览器首先向服务器请求认证选项,服务器会生成一个新的挑战信息并返回允许使用的凭证列表。之后,浏览器会触发认证器进程,认证器会使用设备中的私钥对挑战信息进行签名处理,最后浏览器会将签名后的响应结果发送给后端。

随后进行验证环节:服务器会检查签名、挑战信息、请求来源以及凭证ID是否合法,同时更新存储的计数器。验证通过后,系统会为用户生成一个临时会话令牌。登录过程依赖的是设备的身份验证机制,而非可重复使用的凭证。

了解了这个流程之后,我们接下来就来讨论具体的实现方法。首先,我们需要从后端返回认证选项。

1. 从后端返回认证选项

需要从数据库中获取用户信息、列出允许使用的凭证,并生成新的挑战信息。

// src/app.ts
app.post("/auth/login/options", async (req, res) => {
    const { email } = req.body;
    const user = findUserByEmail(email);

    if (!user) {
        return res.status(404).json({ error: "用户未找到" });
    }

    const options = await generateAuthenticationOptions({
        rpID,
        allowCredentials: user.passkeys.map((passkey) => ({
            id: passkey.id,
            transports: passkey.transports,
        }),
        userVerification: "preferred",
    });

    req.session.currentChallenge = options.challenge;
    req.sessionpendingUserId = user.id;

    res.json(options);
});

2. 在浏览器中启动认证流程

浏览器接收到这些认证选项后,就会开始执行认证仪式。请在src/browser.ts文件中添加登录功能:

// src/browser.ts
import { startAuthentication } from "@simplewebauthn/browser";

export async function loginWithPasskey(email: string) {
    const optionsResp = await fetch("/auth/login/options", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({ email }),
    });

    const optionsJSON = await optionsResp.json();

    const authenticationResponse = await startAuthentication({ optionsJSON });

    const verifyResp = await fetch("/auth/login/verify", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(authenticationResponse),
    });

    return verifyResp.json();
}

3. 验证身份信息并更新计数器

在这一环节,后端会判断登录操作是否真实有效。

// src/app.ts
app.post("/auth/login/verify", async (req, res) => {
    const user = users.get(req.sessionpendingUserId ?? "");

    if (!user || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    const passkey = user.passkeys.find((item) => item.id === req.body.id);

    if (!passkey) {
        return res
            .status(400)
            .json({ verified: false, error: "未找到对应的Passkey" });
    }

    const credential: WebAuthnCredential = {
        id: passkey.id,
        publicKey: passkey.publicKey,
        counter: passkey.counter,
        transports: passkey.transports,
    };

    let verification;

    try {
        verification = await verifyAuthenticationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
            credential,
            requireUserVerification: true,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error:
                error instanceof Error
                    ? error.message
                    : "认证失败",
        });
    }

    if (!verification.verified) {
        return res.status(400).json({ verified: false });
    }

    passkey.counter = verification.authenticationInfo.newCounter;

    req.session.userId = user.id;
    req.session.currentChallenge = undefined;
    req.sessionpendingUserId = undefined;

    res.json({ verified: true });
});

这里有两条细节值得特别注意:

  • requireUserVerification: true这一设置会强制执行更严格的验证流程。

  • newCounter这个函数会在每次成功登录后更新存储的计数器值。

这种计数器的更新机制是识别被克隆或损坏的身份认证凭证的重要手段之一。

什么取代了长期有效的JWT令牌?

如果直接使用完整的WebAuthn认证流程,然后只发放有效期为一周的 bearer token,那就等于浪费了这种机制带来的诸多优势。

一个更合理的方案是:

  • 使用WebAuthn进行身份验证

  • 服务器创建一个短期的会话

  • 浏览器仅接收一个仅用于HTTP请求的会话cookie

  • 对于高风险操作,需要再次进行WebAuthn认证

下面这个简单的示例可以说明这一思路:

// src/app.ts
function requireSession(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
) {
    if (!req.session.userId) {
        return res.status(401).json({ error: "未经授权" });
    }

    next();
}

app.get("/me", requireSession, (req, res) => {
    const user = users.get(req.session.userId ?? "");

    if (!user) {
        return res.status(404).json({ error: "用户未找到" });
    }

    res.json({
        id: user.id,
        email: user.email,
        passkeys: user.passkeys.length,
    });
});

这种设计使得登录后的浏览器状态更加简洁,且不易被重复利用。浏览器中仅保存会话cookie,而会话状态及其生命周期则由服务器负责管理。

提示:对于那些风险较高的操作来说,使用短期的会话以及新鲜的WebAuthn认证方式,要比使用一个范围较广的长期 bearer token 更为安全。

多设备与恢复逻辑

如果第一台丢失的手机导致用户无法再次登录,那么强身份验证机制就会迅速失效。因此,从系统设计的最初阶段开始,就必须为用户提供有效的备份方案。

正确的实现方式如下:

  • 在用户的常用设备上注册一个主密码。

  • 再注册另一种认证方式,例如安全密钥或其他可信任的设备。

  • 在账户设置过程中验证用户的联系方式。

  • 在账户设置页面中提供密码管理功能。

  • 存储设备的元数据,以便用户能够查看自己注册了哪些认证方式。

多设备恢复与升级认证

在上图中,左侧展示了恢复机制的运作流程:用户首先在常用设备上使用主密码登录,然后可以添加第二台设备或硬件安全密钥等其他可信任的认证方式。同时,系统必须提供恢复通道,以便用户在设备丢失时能够及时恢复账户访问权限。设备清单有助于追踪当前正在使用的认证方式,而丢失的设备也应尽快被取消授权。

右侧内容则重点介绍了升级认证机制。对于那些敏感操作来说,需要再次进行身份验证。例如支付、修改电子邮件地址、生成API密钥或执行破坏性操作等,这些操作开始时,服务器会发送新的验证请求,用户需要使用另一种认证方式重新进行验证。这种临时性的认证窗口只会持续很短的时间,之后就会再次要求用户进行新的验证。

这里有一个简单的设计原则:不要将第二种密码注册流程隐藏在复杂的设置页面中,而应该在第一次成功注册后立即提供“添加另一种密码”的选项。

主密码还可以在各大平台生态系统中实现同步。这虽然能提升用户体验,但后端系统仍然应该将每一条注册的认证信息视为独立的记录,为每条记录分配唯一的ID、公钥、计数器、设备类型以及备份状态等信息。

对于账户恢复功能来说,必须设置严格的标准。恢复机制绝不能成为整个系统中最薄弱的环节。通过电子邮件发送的链接、恢复码以及由客服协助进行的账户恢复操作,都需要受到速率限制、审计追踪和严格的检查。

敏感操作的升级认证

用户只需登录一次,就不应该被允许执行所有危险的操作。

升级认证的具体机制如下:

  • 用户已经成功登录。

  • 用户尝试执行某些敏感操作。

  • 服务器会要求用户再次进行WebAuthn验证。

  • 应用程序只会为这类操作提供短暂的时间窗口。

你可以将升级认证机制应用于以下场景:

  • 支付审批

  • 凭证管理

  • 修改电子邮件地址或电话号码

  • 生成API密钥

  • 删除组织账户

  • 提升用户权限

  • 访问计费相关功能

首先,提供新的认证选项,并要求用户进行严格的身份验证。

// src/app.ts
app.post("/auth/step-up/options", requireSession, async (req, res) => {
    const user = users.get(req.session.userId ?? "");

    if (!user) {
        return res.status(404).json({ error: "用户未找到" });
    }

    const options = await generateAuthenticationOptions({
        rpID,
        allowCredentials: user.passkeys.map((passkey) => ({
            id: passkey.id,
            transports: passkey.transports,
        }),
        userVerification: "required",
    });

    req.session.currentChallenge = options.challenge;

    res.json(options);
});

然后,验证用户的响应,并提供短暂的升级认证窗口。

// src/app.ts
app.post("/auth/step-up/verify", requireSession, async (req, res) => {
    const user = users.get(req.session.userId ?? "");
    const passkey = user?.passkeys.find((item) => item.id === req.body.id);

    if (!user || !passkey || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    let verification;
    try {
        verification = await verifyAuthenticationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
            credential: {
                id: passkey.id,
                publicKey: passkey.publicKey,
                counter: passkey.counter,
                transports: passkey.transports,
            },
            requireUserVerification: true,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error: error instanceof Error ? error.message : "升级认证失败",
        });
    }

    if (!verification.verified) {
        return res.status(400).json({ verified: false });
    }

    passkey.counter = verification.authenticationInfo.newCounter;
    req.session.stepUpUntil = Date.now() + 5 * 60 * 1000;
    req.session.currentChallenge = undefined;

    res.json({ verified: true });
});

其余的部分由一些简单的防护机制来处理。

// src/app.ts
function requireRecentStepUp(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
) {
    if (!req.session.stepUpUntil || req.session.stepUpUntil < Date.now()) {
        return res.status(403).json({ error: "需要重新进行身份验证" });
    }

    next();
}

app.post("/billing/payout", requireSession, requireRecentStepUp, (req, res) => {
    res.json({ ok: true });
});

在这里,WebAuthn不再仅仅是一个登录功能,而是成为了你的认证体系的重要组成部分。

现在所有的路由和防护机制都已经设置完成,只需在src/app.ts文件的末尾添加服务器启动命令,就可以让后端服务开始运行了:

// src/app.ts
const PORT = 3000;
app.listen(PORT, () => {
    console.log(`服务器正在监听地址 http://localhost:${PORT}`);
});

总结

你最初使用的是一种常见的、存在安全风险的模式:即长期使用同一份可重复使用的令牌。后来,你对核心认证机制进行了调整:

  • 设备负责保存私钥

  • 服务器则存储公钥及计数器信息

  • 每次认证时都会生成新的挑战数据

  • 验证通过后,应用程序会使用短暂的会话连接

  • 遇到风险情况时,系统会触发更严格的身份验证流程

这才是真正的变革所在。

WebAuthn并非仅仅是一次外观上的登录机制升级,它从根本上改变了信任的传递方式。当你从使用可重复使用的令牌转向依赖设备生成的加密凭证进行认证时,你的Node.js认证系统就会真正具备现代安全系统的特性,而不再只是一个简单的会话管理工具。

亲自尝试

完整的源代码可以在GitHub上找到。请克隆该仓库,然后按照README中的说明进行配置,便可以在本地测试这种生物特征认证流程。

结语

如果你觉得这些信息有用,欢迎将其分享给那些可能会从中受益的人。

非常希望你能留下你的反馈——你可以在X平台上@sumit_analyzen或Facebook上@sumit_analyzen》与我联系,也可以观看我的编程教程,或者直接通过在LinkedIn上与我建立联系。

你还可以访问我的官方网站www.sumitsaha.me,了解更多关于我的信息。

Comments are closed.