freeCodeCamp.org

How to Build Passkey Authentication with WebAuthn, Next.js and NestJS

7.1内容质量
How to Build Passkey Authentication with WebAuthn, Next.js and NestJS

TL;DR · AI 摘要

How to Build Passkey Authentication with WebAuthn, Next.js and NestJS October 27, 2026 / authentication Roland Mweze Pas...

核心要点

  • 主题聚焦:How to Build Passkey Authentication with WebAuth
  • 来源:freeCodeCamp.org,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#前端#后端#云计算
打开原文

如何使用 WebAuthn、Next.js 和 NestJS 构建通行密钥认证

2026年10月27日

/

#authentication

Roland Mweze

密码对用户和服务提供商来说都是一个麻烦。用户经常难以记住密码,而跨多个网站的钓鱼攻击和密码重复使用风险很高。

开发者需要对密码进行哈希处理、加盐、限制登录尝试次数,并构建“忘记密码”流程,这些流程没人喜欢使用。

幸运的是,通行密钥解决了许多这些问题。用户无需提供密码,而是可以通过在其他设备上使用的不同类型的认证器(如指纹、面部识别或 PIN 码)确认身份。认证器使用设备上存储的加密密钥对挑战进行签名,且永远不会与他人共享。

本教程将展示如何构建一个不使用传统密码的应用。后端是一个 NestJS API,用于在 PostgreSQL 中存储用户及其通行密钥。前端是一个 Next.js 应用,用户仅需使用电子邮件地址和通行密钥即可注册和登录。

你将使用 SimpleWebAuthn,这是一个维护良好的 TypeScript 库,用于简化与 WebAuthn 加密和数据解析相关的任务。

在本文结束时,你应该具备在网站上实现通行密钥注册和认证所需的知识,并能够安全地在服务器上验证通行密钥。你还将了解通行密钥可能带来的潜在风险和问题,这些风险可能会影响你的应用或用户账户,从而采取措施防止这些问题。你可以在 GitHub 上找到完整的源代码。

目录

  • 基于密码的认证存在的问题
  • 通行密钥如何解决这些问题
  • 先决条件
  • 什么是通行密钥?
  • 通行密钥认证的工作原理
  • 项目结构
  • 如何设置 NestJS 后端 创建数据库 添加环境变量 连接到 PostgreSQL
  • 如何定义数据库实体
  • 如何在服务器上注册通行密钥 生成注册选项 验证注册响应
  • 如何通过通行密钥在服务器上登录 生成认证选项 验证认证响应
  • 如何暴露认证端点 如何使用 JWT 保护路由 如何注册认证模块
  • 如何设置 Next.js 前端
  • 如何构建注册页面
  • 如何构建登录页面
  • 如何显示有用的错误信息
  • 如何测试你的应用
  • 需要避免的安全错误 对凭证 ID 进行两次编码 用 undefined 清除挑战 在未检查所有者的情况下查找凭证 允许注册将通行密钥添加到现有账户 在未强制要求的情况下请求验证
  • 接下来可以去哪里
  • 结论

基于密码的认证存在的问题

在编写第一行代码之前,我们需要明确我们要替换的内容。

几十年来,基于密码的认证一直是默认的登录方式。它已被证明是有效的,这也是它成为标准的原因。但它所依赖的模型(用户必须记住、输入并保密的密钥)存在显著限制。这个模型的每个方面都有问题,接下来我们将看到这些具体问题。

1. 钓鱼攻击

密码可以在任何输入的地方使用。如果攻击者构建了一个与你银行登录页面完全相同的页面,那么该密码在该页面上同样可以正常工作。唯一能阻止用户被攻击者利用的因素,仅仅是用户可能注意到网址看起来有些奇怪。这要求太高了。钓鱼页面往往几乎完美无缺,它们通常以紧急邮件或短信的形式出现,目的是迫使你立即采取行动。即使是非常细心且技术背景强的用户,也可能被欺骗。

通过短信或认证应用发送的一次性密码当然很有用,但人们仍然可能被诱骗在伪造的页面上输入这些密码。

2. 密码重复使用

大多数人拥有数十个,甚至数百个在线账户。你不可能记住每个账户的唯一且强密码,因此不可避免地会在不同网站之间重复使用相同的几个密码。攻击者清楚这一点。如果一个网站遭到入侵,他们将自动利用泄露的电子邮件和密码组合,尝试登录其他网站。这就是所谓的凭证填充攻击。一个你早已遗忘的小型论坛的数据泄露,可能导致攻击者进入你的电子邮件账户或银行账户。

3. 数据泄露

每个使用密码的网站都必须存储某种可用于验证密码的信息。理想情况下,网站应存储加盐哈希值而非原始密码。但并非所有网站都做得很好,一旦攻击者获取了数据库副本,弱密码的哈希值仍可能被离线破解。这显然是开发者的责任。你必须选择合适的哈希算法,调整参数,保护数据库,并始终将数据泄露视为对用户的重大灾难。

4. 隐私缺失

使用密码时,用户会将共享密钥传递给每个注册的网站。他们必须信任该网站能够保护这个密钥,且在密钥泄露后无法控制其后续使用。

随着人们重复使用相同的电子邮件和密码组合,泄露的凭证可能在不同数据泄露事件中被匹配。这使得跨服务关联账户变得更容易,从而能够构建出用户在线行为的完整画像。

5. 凭证疲劳

还有日常使用中的挫败感。你必须不断应对密码策略(至少包含一个符号、不超过16个字符等),每几个月被迫重置密码,回答安全问题,输入验证码……清单还在继续。每个新账户的创建只会让这些负担越来越重。

这种疲劳感直接导致人们采取让密码变得不安全的行为:使用简短密码、对同一密码进行微小修改、将密码写在便签纸上。这也会给企业带来损失。用户在注册过程中可能中途放弃,被锁定账户后需要联系客服重置遗忘的密码。

服务器仅存储公钥。即使数据库泄露,攻击者也无法获取可用于登录的凭据。

隐私保护

网站无法获取任何秘密信息,且每个网站使用的密钥都不同。指纹和面部数据不会离开设备。

凭证疲劳

用户无需创建或记忆任何内容。用户只需使用每天多次使用的相同指纹、面部识别或屏幕锁定进行验证。

根本性的转变在于信任的归属位置。使用密码时,安全性依赖于用户识别欺诈网站和选择强凭据的能力。使用通行密钥时,浏览器和操作系统负责执行规则,用户只需确认身份。

前提条件

要跟随本文内容,您需要熟悉 TypeScript 并具备一些 React 经验。您不需要深入了解 NestJS,但了解控制器和服务的概念会有所帮助。

您还需要安装以下工具:

  • Node.js 22 或更高版本
  • PostgreSQL。在 macOS 上,Postgres.app 是最简单的选项。如果您更喜欢图形工具,pgAdmin 非常实用。
  • 支持通行密钥的设备。几乎所有现代笔记本电脑或手机都适用:配备 Touch ID 的 Mac、配备 Windows Hello 的 Windows 电脑,或 Android 手机或 iPhone。

什么是通行密钥?

通行密钥是一种基于公钥密码学的凭证。创建时,您的设备会生成一组密钥。私钥保留在设备上,并通过指纹、面部识别或屏幕锁定进行保护。公钥发送到网站,并与您的账户一起存储。

通行密钥基于 Web 认证 API(WebAuthn),这是所有主流浏览器都支持的 W3C 标准。"通行密钥"是行业为可在设备间同步的 WebAuthn 凭证选定的友好名称。如果您需要更深入的介绍,passkeys.dev 是一个优秀的资源。

一个常让开发者感到意外的细节是:您的应用无法选择指纹识别、面部识别或 PIN 码。操作系统会根据设备的功能和用户配置的设置做出决定。

用户通常会看到以下内容:

设备

验证方式

Mac

触控 ID 或 Mac 密码

iPhone 或 iPad

面容 ID、触控 ID 或密码

Android

指纹、面部解锁或 PIN、图案或密码

Windows

Windows Hello 面部识别、指纹或 PIN

其他任何电脑

扫描二维码并在手机上确认

您的服务器能做的只是强制要求完成某些本地验证。稍后您将看到具体实现方式。

通行密钥认证的工作原理

注册和登录都遵循相同的三步流程:

  • 服务器生成一个随机挑战,并连同一些选项发送到浏览器。
  • 浏览器要求设备验证用户并签署挑战。
  • 浏览器将签署后的响应返回,服务器进行验证。

在注册阶段(WebAuthn 称为“注册”),设备会创建新的密钥对并返回公钥。服务器验证响应后存储公钥。

在登录阶段(认证),设备使用现有私钥签署新挑战。服务器使用之前存储的公钥验证签名。

代码中还会涉及两个概念:

依赖方ID(RP ID)是你的通行密钥所属的域名,例如 example.com。在开发环境中,RP ID 是 localhost。浏览器会直接拒绝在其他域名上使用通行密钥,这正是通行密钥具备防钓鱼能力的原因。

用户验证(UV)标志是签名响应中的一个比特位。该标志仅在用户实际完成指纹、面部或屏幕锁定验证时由设备设置。你的服务器会检查此标志,以确保不是随机用户点击了按键或点击了按钮。

项目结构

该项目在一个仓库中包含两个独立的应用:

code
passwordless-auth/
├── client/   # Next.js前端,运行在3000端口
└── server/   # NestJS API,运行在4000端口

你将先构建服务器,因为前端依赖于它的接口。

如何设置NestJS后端

首先使用Nest CLI创建一个新的NestJS项目:

code
npx @nestjs/cli new server
cd server

然后安装WebAuthn、数据库、配置和JSON Web Tokens(JWT)的依赖项:

code
npm install @simplewebauthn/server @nestjs/typeorm typeorm pg @nestjs/config @nestjs/jwt @nestjs/passport passport passport-jwt

创建数据库

创建一个名为 auth 的空数据库。在终端中运行:

code
createdb -h localhost -U postgres auth

如果你更喜欢使用pgAdmin,右键点击 Databases,选择 Create > Database,然后将其命名为 auth。

如果跳过此步骤,NestJS在启动时会报错:database "auth" does not exist。TypeORM可以为你创建表,但不会自动创建数据库本身。

添加环境变量

在 server 文件夹中创建 .env 文件:

code
JWT_SECRET=replace-with-a-long-random-string

WEBAUTHN_RP_ID=localhost
WEBAUTHN_ORIGIN=http://localhost:3000

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your-database-password
DB_NAME=auth

WEBAUTHN_RP_ID 仅包含域名,不带协议或端口。WEBAUTHN_ORIGIN 是前端的精确URL。服务器将拒绝来自其他源的响应。

浏览器仅允许在 HTTPS 网站或 localhost 上使用 WebAuthn,因此此设置在开发环境中无需证书即可正常工作。

连接到 PostgreSQL

创建 src/auth/config/database.config.ts 文件以集中管理数据库设置:

code
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: [__dirname + '/../**/*.entity{.ts,.js}'],
  synchronize: process.env.NODE_ENV !== 'production',
}));

在 config 文件夹中,添加 src/auth/config/jwt.config.ts 用于令牌设置:

code
import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET,
  expiresIn: '1h',
}));

同步选项告诉 TypeORM 根据你的实体创建和更新表。这在开发中很方便,但在生产环境中应切换到迁移功能。

然后在 src/app.module.ts 中完成所有配置的连接:

code
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import databaseConfig from './auth/config/database.config';
import jwtConfig from './auth/config/jwt.config';
import { User, Authenticator } from './auth/entities/auth.entity';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig, jwtConfig],
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('database.host'),
        port: configService.get('database.port'),
        username: configService.get('database.username'),
        password: configService.get('database.password'),
        database: configService.get('database.database'),
        entities: [User, Authenticator],
        synchronize: configService.get('database.synchronize'),
      }),
      inject: [ConfigService],
    }),
    AuthModule,
  ],
})
export class AppModule {}

最后,在 src/main.ts 中启用 CORS,使端口 3000 的前端能够调用端口 4000 的 API:

code
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.enableCors({
    origin: true,
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization'],
  });
  await app.listen(4000);
  console.log('App running on port 4000');
}
bootstrap();

origin: true 会接受来自任何来源的请求,这使本地开发更加简单。在部署前,请将其替换为前端的 URL。

如何定义数据库实体

这里需要两个表。User 表包含账户信息,Authenticator 表包含通行密钥。一个用户可以拥有多个通行密钥(例如,一台笔记本电脑和一部手机各一个)。

创建 src/auth/entities/auth.entity.ts:

code
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  OneToMany,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
import type {
  CredentialDeviceType,
  Base64URLString,
} from '@simplewebauthn/server';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column({ unique: true })
  webauthnId: Base64URLString;

  @Column({ type: 'varchar', nullable: true })
  currentChallenge?: string | null;

  @OneToMany(() => Authenticator, (authenticator) => authenticator.user)
  authenticators: Authenticator[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

@Entity()
export class Authenticator {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  credentialId: Base64URLString;

  @Column({ type: 'bytea' })
  credentialPublicKey: Uint8Array;

  @ManyToOne(() => User, (user) => user.authenticators, { onDelete: 'CASCADE' })
  user: User;

  @Column()
  webauthnUserId: Base64URLString;

  @Column({ type: 'bigint' })
  counter: number;

  @Column({
    type: 'varchar',
    length: 32,
  })
  deviceType: CredentialDeviceType;

  @Column()
  backedUp: boolean;

  @Column('simple-array', { nullable: true })
  transports?: string[];

  @CreateDateColumn()
  createdAt: Date;
code
@UpdateDateColumn()
updatedAt: Date;
}

让我们更详细地看一下其中几个字段。

首先,webauthnId 是与 passkey 一起存储在设备上的用户句柄。根据 WebAuthn 规范,它不应包含个人信息,因此你会用随机字节而不是电子邮件来填充它。

接下来,currentChallenge 保存服务器正在等待的挑战。在生成选项时会设置该值,成功验证后会清除。注意其字符串 | null 类型,这在安全章节中会有重要解释。

counter 是签名计数器。某些认证器会在每次使用时递增该值,这使服务器能够检测克隆设备。同步的 passkey 通常会将计数器保持为零,而 SimpleWebAuthn 支持这两种场景。

deviceType 和 backedUp 告诉你 passkey 是否可以跨设备同步,以及它当前是否已备份(例如备份到 iCloud Keychain)。

最后,transports 记录浏览器如何连接到认证器(设备内置、通过 USB、通过蓝牙或通过手机)。

如何在服务器上注册 Passkey

创建 src/auth/auth.service.ts。从类定义开始:

code
import {
  Injectable,
  UnauthorizedException,
  ConflictException,
  Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User, Authenticator } from './entities/auth.entity';
import {
  generateRegistrationOptions,
  generateAuthenticationOptions,
  verifyRegistrationResponse,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  AuthenticationResponseJSON,
  PublicKeyCredentialCreationOptionsJSON,
  PublicKeyCredentialRequestOptionsJSON,
  RegistrationResponseJSON,
} from '@simplewebauthn/server';

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  private readonly rpName = 'Passwordless Auth';
  private readonly rpID: string;
  private readonly origin: string;

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(Authenticator)
    private readonly authenticatorRepository: Repository<Authenticator>,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {
    this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID');
    this.origin = this.configService.get<string>('WEBAUTHN_ORIGIN');
  }

rpName 是你应用的可读名称。用户会在操作系统提示的 passkey 界面以及之后的密码管理器中看到这个名称。

生成注册选项

第一个方法用于查找或创建用户,然后向 SimpleWebAuthn 请求注册选项。在类内部添加该方法:

code
async generateRegistrationOptions(
    email: string,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
  let user = await this.userRepository.findOne({ where: { email } });

  if (!user) {
    user = this.userRepository.create({
      email,
      webauthnId: randomBytes(32).toString('base64url'),
    });
    await this.userRepository.save(user);
  }

  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });
ts
if (authenticators.length > 0) {
  throw new ConflictException(
    'An account with this email already exists. Please sign in.',
  );
}

const options = await generateRegistrationOptions({
  rpName: this.rpName,
  rpID: this.rpID,
  userID: Buffer.from(user.webauthnId, 'base64url'),
  userName: user.email,
  attestationType: 'none',
  authenticatorSelection: {
    residentKey: 'required',
    userVerification: 'required',
  },
  excludeCredentials: authenticators.map((authenticator) => ({
    id: authenticator.credentialId,
    transports: authenticator.transports,
  })),
});

// Save the current challenge
user.currentChallenge = options.challenge;
await this.userRepository.save(user);

return options;
}

authenticatorSelection 块是决定你要创建哪种类型通行密钥的地方。

residentKey: 'required' 要求创建可发现凭证。正是这个设置让通行密钥成为真正的"密钥"(凭证存储在设备或用户的密码管理器中,并能同步到其他设备)。

userVerification: 'required' 告诉设备需要验证用户身份(即检查指纹、面部或屏幕锁)。如果没有这个设置,部分认证器只会执行存在性检查而不会实际验证用户。

缺失的是认证器附件选项。如果你设置为 'platform',这会将用户限制在当前设备的传感器上。如果省略这个选项,用户可以通过二维码或硬件安全密钥使用手机(这也是 passkeys.dev 的推荐方式)。

attestationType: 'none' 表示你不要求设备证明其型号和制造商。大多数消费级应用根本不需要这个功能,要求这个功能反而可能触发额外的隐私提示。

excludeCredentials 列出用户已有的通行密钥,防止设备在已注册的认证器上创建重复凭证。由于上方的检查逻辑,这个列表在此场景下总是为空。当允许已登录用户添加更多通行密钥时,这个功能才会变得有用。

对现有认证器的检查同样重要。由于这个接口不需要用户登录,不能简单地允许它将通行密钥添加到已有账户。否则,任何知道你邮箱地址的人都可以将自己的通行密钥附加到你的账户上。

验证注册响应

当用户在设备上确认后,浏览器会返回响应。第二个方法会验证这个响应并存储新的通行密钥:

ts
async verifyRegistration(email: string, response: RegistrationResponseJSON) {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'currentChallenge', 'webauthnId'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: this.origin,
    expectedRPID: this.rpID,
    requireUserVerification: true,
  });

  const { verified, registrationInfo } = verification;

  if (verified && registrationInfo) {
    const { credential, credentialDeviceType, credentialBackedUp } =
      registrationInfo;

const newAuthenticator = this.authenticatorRepository.create({ credentialId: credential.id, credentialPublicKey: credential.publicKey, counter: credential.counter, user: user, webauthnUserId: user.webauthnId, deviceType: credentialDeviceType, backedUp: credentialBackedUp, transports: response.response.transports, });

await this.authenticatorRepository.save(newAuthenticator);

user.currentChallenge = null; await this.userRepository.save(user);

return { verified, user: { id: user.id, email: user.email }, }; }

throw new UnauthorizedException('Registration verification failed'); }

code

verifyRegistrationResponse 是这段代码的核心部分。它会验证挑战值是否与你保存的一致,确认响应是否来自你的源和 RP ID,并验证签名是否有效。当 requireUserVerification 设置为 true 时,如果 UV 标志未设置,它也会拒绝响应。该标志证明已进行了指纹、面部识别或屏幕锁定验证。

如果一切正常,你将保存凭证 ID 和公钥,然后清除挑战值以防止重复使用。

## 服务器端使用 Passkey 登录的实现方式

登录流程与注册验证类似:生成选项,然后验证响应。

### 生成认证选项

async generateAuthenticationOptions( email: string, ): Promise<PublicKeyCredentialRequestOptionsJSON> { const user = await this.userRepository.findOne({ where: { email } });

if (!user) { throw new UnauthorizedException('User not found'); }

const authenticators = await this.authenticatorRepository.find({ where: { user: { id: user.id } }, });

if (authenticators.length === 0) { throw new UnauthorizedException( 'No passkey registered for this account. Please sign up.', ); }

const options = await generateAuthenticationOptions({ rpID: this.rpID, allowCredentials: authenticators.map((authenticator) => ({ id: authenticator.credentialId, transports: authenticator.transports, })), userVerification: 'required', });

// 保存当前挑战值 user.currentChallenge = options.challenge; await this.userRepository.save(user);

return options; }

code

allowCredentials 列出了用户拥有的所有 passkey。浏览器会使用这个列表在设备上找到正确的凭证,而 transports 提示信息会帮助浏览器决定是本地查找、在安全密钥上查找还是在手机上查找。

### 验证认证响应

async verifyAuthentication( email: string, response: AuthenticationResponseJSON, ) { const user = await this.userRepository.findOne({ where: { email }, select: ['id', 'email', 'currentChallenge'], });

if (!user) { throw new UnauthorizedException('User not found'); }

const authenticator = await this.authenticatorRepository.findOne({ where: { credentialId: response.id, user: { id: user.id } }, });

if (!authenticator) { throw new UnauthorizedException('Authenticator not found'); }

ts
const verification = await verifyAuthenticationResponse({
  response,
  expectedChallenge: user.currentChallenge,
  expectedOrigin: this.origin,
  expectedRPID: this.rpID,
  credential: {
    id: authenticator.credentialId,
    publicKey: new Uint8Array(authenticator.credentialPublicKey),
    counter: Number(authenticator.counter),
    transports: authenticator.transports,
  },
  requireUserVerification: true,
});

const { verified } = verification;

if (verified) {
  authenticator.counter = verification.authenticationInfo.newCounter;
  authenticator.backedUp =
    verification.authenticationInfo.credentialBackedUp;
  await this.authenticatorRepository.save(authenticator);

  user.currentChallenge = null;
  await this.userRepository.save(user);

  // 生成 JWT 令牌
  const payload = { sub: user.id, email: user.email };
  return {
    accessToken: this.jwtService.sign(payload),
    user: { id: user.id, email: user.email },
  };
}

throw new UnauthorizedException('身份验证失败');
}
ts
该查询同时使用了凭证 ID 和用户 ID。不要忽略用户条件。在安全章节我会解释原因。

对 counter 使用 Number() 是因为 pg 驱动会将大整数列作为字符串返回。SimpleWebAuthn 期望的是数字类型。

一旦签名验证通过,就需要增加计数器、清除挑战并发放 JWT。从这一刻起,应用会将该用户视为与任何使用密码登录的用户完全相同。唯一的区别是未使用密码。

## 如何暴露认证接口

创建 src/auth/auth.controller.ts 文件。它包含注册和登录流程每个步骤的路由,以及一个受保护的路由用于列出用户的通行密钥:

import { Controller, Post, Body, Get, Query, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {}

@Post('register/options') async getRegistrationOptions( @Body() body: { email: string }, ): Promise<PublicKeyCredentialCreationOptionsJSON> { return this.authService.generateRegistrationOptions(body.email); }

@Post('register/verify') async verifyRegistration( @Body() body: { email: string; response: any }, ) { return this.authService.verifyRegistration(body.email, body.response); }

@Post('login/options') async getAuthenticationOptions( @Body() body: { email: string }, ): Promise<PublicKeyCredentialRequestOptionsJSON> { return this.authService.generateAuthenticationOptions(body.email); }

@Post('login/verify') async verifyAuthentication( @Body() body: { email: string; response: any }, ) { return this.authService.verifyAuthentication(body.email, body.response); }

@Get('authenticators') @UseGuards(JwtAuthGuard) async getAuthenticators(@Query('email') email: string) { return this.authService.getAuthenticators(email); } }

code

此处将响应体声明为 any 类型是因为服务方法已经声明了它们期望的 SimpleWebAuthn 类型。

### 如何使用 JWT 保护路由

最后一个路由使用了 JwtAuthGuard,因此只有已登录用户才能调用它。它背后有一个小的服务方法。将其添加到 AuthService 中:
code
async getAuthenticators(email: string) {
  const user = await this.userRepository.findOne({ where: { email } });
  if (!user) {
    throw new UnauthorizedException('User not found');
  }
  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });
  return authenticators;
}

接下来在 src/auth/strategies/jwt.strategy.ts 中创建 Passport 策略:

code
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}

然后在 src/auth/guards/jwt-auth.guard.ts 中创建守卫。它封装了 Passport 的内置守卫并返回更清晰的错误信息:

code
import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }

  handleRequest(err: any, user: any, info: any) {
    if (info instanceof TokenExpiredError) {
      throw new UnauthorizedException('Token has expired');
    }

    if (info instanceof JsonWebTokenError) {
      throw new UnauthorizedException('Invalid token');
    }

    if (err || !user) {
      throw new UnauthorizedException('Authentication failed');
    }

    return user;
  }

  // 可选:如果需要在 JWT 验证前添加自定义逻辑,可以重写 canActivate 方法
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const authHeader = request.headers.authorization;
    if (!authHeader) {
      throw new UnauthorizedException('No token provided');
    }

    const [type] = authHeader.split(' ');
    if (type !== 'Bearer') {
      throw new UnauthorizedException('Invalid token format');
    }
    return super.canActivate(context) as Promise<boolean>;
  }
}

如何注册认证模块

最后在 src/auth/auth.module.ts 中整合所有内容:

code
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User, Authenticator } from './entities/auth.entity';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, Authenticator]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: {
          expiresIn: '1d',
        },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

signOptions 控制服务颁发的令牌,因此每次登录的有效期为一天。

启动服务器:

code
npm run start:dev

TypeORM 会在你的认证数据库中创建用户表和认证器表。你可以打开 pgAdmin 并刷新 Tables 节点查看这些表。

如何设置 Next.js 前端

从项目根目录创建一个包含 TypeScript、Tailwind CSS、App Router 和 src 文件夹的 Next.js 应用:

code
npx create-next-app@latest client --typescript --tailwind --app --src-dir
cd client
npm install @simplewebauthn/browser axios

在 client 文件夹中添加一个指向 API 的 .env 文件:

code
NEXT_PUBLIC_API_URL=http://localhost:4000

然后在 client/src/services/api.ts 中创建一个小型 API 客户端:

code
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

export const webAuthnApi = {
  getRegistrationOptions: async (email: string) => {
    const response = await api.post('/auth/register/options', { email });
    return response.data;
  },

  verifyRegistration: async (email: string, response: any) => {
    const res = await api.post('/auth/register/verify', { email, response });
    return res.data;
  },

  getAuthenticationOptions: async (email: string) => {
    const response = await api.post('/auth/login/options', { email });
    return response.data;
  },

  verifyAuthentication: async (email: string, response: any) => {
    const res = await api.post('/auth/login/verify', { email, response });
    return res.data;
  },
};

// 添加请求拦截器以包含 JWT 令牌
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default api;

底部的拦截器会在用户登录后将 JWT 添加到每个请求中,这是受保护的 /auth/authenticators 路由所期望的。

如何构建注册页面

WebAuthn 前端实现令人惊讶地简洁。SimpleWebAuthn 的 startRegistration 函数会从服务器获取选项,调用浏览器的 WebAuthn API,并以服务器能理解的 JSON 格式返回响应。

创建 client/src/app/signup/page.tsx:

code
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';

import React, { useState } from 'react';
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { webAuthnApi } from '@/services/api';
import { getPasskeyErrorMessage } from '@/services/passkey';
import Link from 'next/link';
import { useRouter } from 'next/navigation';

export default function SignupPage() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const router = useRouter();

  const handleRegistration = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    if (!browserSupportsWebAuthn()) {
      setError('This browser does not support passkeys.');
      setLoading(false);
      return;
    }

    try {
      const options = await webAuthnApi.getRegistrationOptions(email);

      const regResult = await startRegistration({ optionsJSON: options });

      const verificationResult = await webAuthnApi.verifyRegistration(email, regResult);
tsx
if (verificationResult.verified) {
  router.push('/welcome');
}
} catch (err: any) {
  console.error('Registration error:', err);
  setError(getPasskeyErrorMessage(err, 'Registration failed. Please try again.'));
} finally {
  setLoading(false);
}
};

return (
  <main className="min-h-screen flex items-center justify-center bg-gray-300">
    <div className="bg-white p-8 rounded-lg shadow-sm w-full max-w-md">
      <div className="space-y-6">
        <div className="text-center">
          <h1 className="text-3xl font-bold tracking-tight">Sign up</h1>
          <p className="text-sm text-gray-600 mt-2">Create your account</p>
        </div>

        <form onSubmit={handleRegistration} className="space-y-4">
          <div className="space-y-2">
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              id="email"
              type="email"
              required
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="Type your email"
            />
          </div>

          {error && (
            <div className="text-sm text-red-600">
              {error}
            </div>
          )}

          <button
            type="submit"
            disabled={loading}
            className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#3B97B6] hover:bg-[#2d7591] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
          >
            {loading ? 'Creating passkey...' : 'Create a passkey'}
          </button>
          <p className="text-xs text-center text-gray-500">
            You'll confirm with your fingerprint, face, or screen lock.
          </p>
        </form>

        <div className="text-center text-sm">
          <span className="text-gray-600">Already have an account?</span>{' '}
          <Link
            href="/signin"
            className="text-[#3B97B6] hover:text-[#2d7591] font-medium transition-colors"
          >
            Sign in
          </Link>
        </div>
      </div>
    </div>
  </main>
);
}

handleRegistration 函数遵循了前面提到的三个步骤。您从服务器获取选项,将其传递给 startRegistration,然后将结果发送回服务器进行验证。

对 startRegistration 的调用是关键所在。浏览器会打开操作系统提供的通行密钥对话框,用户通过 Touch ID、Face ID、Windows Hello 或手机进行确认。您的代码只需等待 Promise 解析即可。

注意 { optionsJSON: options } 参数。早期版本的 SimpleWebAuthn 直接接受选项参数,许多教程至今仍采用这种方式。第 13 版仍支持旧版格式但会记录警告,因此建议使用对象形式。

如何构建登录页面

登录页面与注册页面的结构相同。创建 client/src/app/signin/page.tsx 文件,将注册页面的代码复制到该文件中,然后修改页面标题和底部链接,使其指向 /signup 路径。接着导入 startAuthentication 函数替代 startRegistration,并将提交处理函数替换为以下代码:

code
const handleAuthentication = async (e: React.FormEvent) => {
  e.preventDefault();
  setLoading(true);
  setError('');

  if (!browserSupportsWebAuthn()) {
    setError('This browser does not support passkeys.');
    setLoading(false);
    return;
  }

  try {
    const options = await webAuthnApi.getAuthenticationOptions(email);

    const authResult = await startAuthentication({ optionsJSON: options });

    const verificationResult = await webAuthnApi.verifyAuthentication(email, authResult);

    if (verificationResult.accessToken) {
      localStorage.setItem('token', verificationResult.accessToken);
      router.push('/welcome');
    }
  } catch (err: any) {
    console.error('Authentication error:', err);
    setError(getPasskeyErrorMessage(err, 'Authentication failed. Please try again.'));
  } finally {
    setLoading(false);
  }
};

请记得将 handleAuthentication 函数传递给表单的 onSubmit 属性。你可以在 GitHub 仓库中查看完整的页面实现。

验证成功后,服务器会返回一个 JWT,页面在重定向前会先存储该令牌。本示例使用 localStorage 存储令牌是为了简化演示。在生产环境中,建议使用服务器设置的 httpOnly Cookie,因为页面上的 JavaScript 无法读取这种 Cookie。

如何显示有用的错误提示

在 passkey 流程出现错误时,浏览器会抛出包含错误名称的 DOMException 对象。原始错误信息不够友好,因此建议对常见错误进行翻译处理。

创建 client/src/services/passkey.ts 文件:

code
/* eslint-disable @typescript-eslint/no-explicit-any */

// 将浏览器 WebAuthn API 和后端的错误转换为可读信息
export function getPasskeyErrorMessage(err: any, fallback: string): string {
  if (err?.response?.data?.message) {
    return err.response.data.message;
  }
  switch (err?.name) {
    case 'NotAllowedError':
      // 用户取消了指纹/面部/屏幕锁定提示,或提示超时
      return 'Passkey 提示已被取消或超时。请重试。';
    case 'InvalidStateError':
      // 该账户在该认证器上已存在 passkey
      return '该设备已为该账户存储了 passkey。请直接登录。';
    case 'NotSupportedError':
      return '该设备不支持 passkey。';
    case 'SecurityError':
      return '该域名不允许使用 passkey。请检查 WebAuthn RP ID 和源设置。';
    default:
      return fallback;
  }
}

NotAllowedError 是最常见的错误类型。当用户关闭提示、等待超时或生物识别验证失败时会触发该错误。SecurityError 通常表示你的 RP ID 与页面运行的域名不匹配,这属于配置问题而非用户问题。

如何测试你的应用

在两个终端中分别启动应用:

code
# 终端 1
cd server
npm run start:dev
code
# 终端 2
cd client
npm run dev

访问 http://localhost:3000/signup,输入邮箱地址并点击按钮。设备会显示关于密钥的提示。在配备Touch ID的Mac上,系统会提示你触碰传感器。其他设备则需要你填写MacBook用户密码。在Windows系统上,Windows Hello会要求你进行人脸识别、指纹识别或输入PIN码。如果选择使用其他设备,浏览器会显示一个二维码,可以用手机扫描。

确认后,你会跳转到欢迎页面。现在前往/signin,输入相同的邮箱地址并再次确认。这次服务器会返回一个JWT。

如果没有可用的密钥启用设备,Chrome内置了虚拟认证器。打开开发者工具,点击右上角菜单,选择更多工具并选择WebAuthn。启用虚拟认证器环境并勾选"支持用户验证",Chrome将为你模拟一个密钥设备。

你也可以打开pgAdmin查看认证器表。你会看到凭证ID、以字节形式存储的公钥以及设备类型。这里没有任何密码或敏感信息。

需要避免的安全错误

密钥设计上是安全的,但围绕它们的服务器代码仍需正确实现。我在构建这个项目时遇到了以下每个问题。其中一些问题不会抛出任何错误,这正是它们危险之处。

两次编码凭证ID

在最新版SimpleWebAuthn(包括此处使用的14版)中,credential.id已经是base64url字符串。我最初版本的代码这样处理:

code
// 错误:对已编码的字符串再次编码
credentialId: Buffer.from(credential.id).toString('base64url'),

Buffer.from将字符串视为UTF-8文本,导致结果是对base64url字符串的二次base64url编码。注册会成功,但登录时永远找不到凭证,因为浏览器发送的是原始ID。应直接存储credential.id本身。

用undefined清除挑战

每个挑战只能使用一次。这行代码看似清除了挑战:

code
user.currentChallenge = undefined;

但TypeORM在保存实体时会跳过undefined属性,导致旧挑战仍保留在数据库中。攻击者可以捕获有效响应并重放。应将其设为null,并确保列类型允许该值。

未验证凭证所有者就查找凭证

仅凭凭证ID查找认证器很诱人:

code
// 错误:任何用户的密钥都可能匹配
where: { credentialId: response.id }

但问题在于:攻击者可以请求你邮箱的登录选项,用他们自己的密钥签署你的挑战,服务器会看到他们的密钥,用他们的公钥验证签名,然后为你账户发放令牌。始终如前面示例所示,在查询中包含用户信息。

允许向已有账户添加密钥

注册端点是公开的。如果它要向匹配邮箱的账户添加密钥,那么知道你邮箱的任何人都可以注册自己的密钥并冒充你登录。

如果账户已有密钥,不应允许注册。如果需要让用户添加更多密钥,这应作为需要登录的独立功能实现。

如果你请求 userVerification: 'preferred',设备可以跳过指纹或PIN码验证。如果你之后使用 requireUserVerification: true 进行验证(这是SimpleWebAuthn的默认设置),那么使用这些设备的用户将遇到令人困惑的验证失败。因此,应在两端都选择 'required',以确保选项和验证设置一致。

下一步该怎么做

你构建的应用已经涵盖了密码钥匙的基本流程。但如果你想进一步扩展,可以添加一些增强功能。

密码钥匙自动填充(也称为条件式界面)会使浏览器直接在邮箱字段中建议已保存的密码钥匙,就像建议已保存的密码一样。SimpleWebAuthn 通过 startAuthentication 的 useBrowserAutofill 选项支持这一功能。

用户还可能希望为其他设备添加密码钥匙并删除旧设备的凭据。你可以通过在 JWT 防护后的端点中构建一对接口来实现这一功能。

最后,挑战应该有过期时间。一个简单的实现方式是将时间戳与 currentChallenge 一起存储,然后拒绝所有超过几分钟的旧请求。在大型应用中,你可能会将挑战存储在会话或缓存(例如 Redis)中,而不是用户记录里。

结论

在本教程中,你使用密码钥匙构建了一个完整的无密码认证系统。你已经创建了一个完整的 NestJS API,可以生成 WebAuthn 挑战,使用 SimpleWebAuthn 验证签名响应,并将公钥存储在 PostgreSQL 中。你还构建了一个 Next.js 前端,允许用户通过指纹、面部识别或设备屏幕锁进行注册和登录。你现在了解了挑战和签名流程的工作原理,明白了用户验证标志的重要性,以及为什么服务器不能决定用户将看到哪种生物识别方法。你还了解了一些可能导致登录失败甚至账户被接管的微妙错误,以及如何避免这些问题。密码短期内不会消失,但密码钥匙几乎已经支持用户所拥有的一切设备。你现在知道如何将密码钥匙添加到自己的应用中。

完整的源代码可在 GitHub 上获取。去克隆它,尝试修改它,让它成为你自己的项目。如果你觉得本教程对你有帮助,请把它分享给那些即将遇到相同问题的人。

我是一名充满活力且以结果为导向的软件工程师,热衷于构建可扩展、生产就绪的系统来解决实际问题。我在干净架构、分布式系统和人工智能驱动的创新交汇点上表现出色。

如果你读到了这里,请感谢作者,以表明你对他们的关心。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始

ADVERTISEMENT