← 返回蜂巢洞察

如何使用 WebAuthn、Next.js 以及 NestJS 来构建基于通行密钥的认证系统

密码对用户和服务提供商来说都是一件麻烦事。用户往往难以记住密码,而且在多个网站上重复使用密码或遭遇网络钓鱼攻击的风险也很大。 开发人员不得不对密码进行加密处理、添加盐值、限制登录尝试的频率,还要设计“忘记密码”的功能流程,而这些过程往往令用户感到不便。 幸运的是,passkey技术解决了其中的许多问题。用户无需使用传统密码,而是可以通过其他设备上已有的认证方式(如指纹识别、面部识别或PIN码)来验证自己的身份。这些认证方式会使用存储在设备上的加密密钥来生成相应的验证码,而这些密钥永远不会被分享给他人。 本教程将向您展示如何构建一个不使用传统密码的应用程序。后端采用NestJS API,用户信息

密码对用户和服务提供商来说都是一件麻烦事。用户往往难以记住密码,而且在多个网站上重复使用密码或遭遇网络钓鱼攻击的风险也很大。

开发人员不得不对密码进行加密处理、添加盐值、限制登录尝试的频率,还要设计“忘记密码”的功能流程,而这些过程往往令用户感到不便。

幸运的是,passkey技术解决了其中的许多问题。用户无需使用传统密码,而是可以通过其他设备上已有的认证方式(如指纹识别、面部识别或PIN码)来验证自己的身份。这些认证方式会使用存储在设备上的加密密钥来生成相应的验证码,而这些密钥永远不会被分享给他人。

本教程将向您展示如何构建一个不使用传统密码的应用程序。后端采用NestJS API,用户信息及passkey数据存储在PostgreSQL数据库中;前端则是一个Next.js应用程序,用户只需提供电子邮件地址和passkey即可完成注册和登录操作。

您会使用SimpleWebAuthn这个维护良好的TypeScript库,它能够简化与WebAuthn加密技术及数据解析相关的开发工作。

读完本文后,您应该已经掌握了实现网站passkey注册和认证功能的方法,也知道了如何在服务器上安全地验证passkey的有效性。同时,您还会了解到passkey可能带来的风险及问题,从而能够采取相应的措施来避免这些问题的发生。



完整的源代码可以在GitHub上找到。

目录

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

在编写任何一行代码之前,我们首先需要明确自己所要替代的是什么。

几十年来,基于密码的认证方式一直是默认的登录方法。它的有效性已经得到了证明,因此才成为了行业标准。但这种认证模式所依赖的核心机制——用户必须记住、输入并保密的秘密——存在诸多局限性。接下来我们将详细分析这一模式的各个问题。

1. 网络钓鱼攻击

只要用户输入了正确的密码,该密码在任何地方都能正常使用。如果攻击者制作了一个与你的银行登录页面一模一样的假冒页面,那么用户在该页面上输入密码同样会成功登录。唯一能阻止用户信息被泄露的因素,就是用户可能会注意到网址有些异常。



然而这种要求其实很高。网络钓鱼页面往往做得非常逼真,它们通常会以紧急邮件或短信的形式出现,目的是诱使用户立即采取行动。即使是非常谨慎、具备技术常识的用户,也有可能上当受骗。

通过SMS发送的一次性密码或使用认证应用程序生成的临时密码确实很有用,但人们仍然可能被诱使在假冒页面上输入这些密码。

数据泄露事件

任何使用密码的网站都必须存储一些可用于验证密码的信息。理想情况下,网站应该存储经过加密处理的哈希值,而不是原始密码本身。但并不是所有网站都能妥善处理这一问题。如果攻击者获得了数据库副本,那些强度较低的密码哈希值仍然有可能被破解。



这显然属于开发者的责任范畴。他们必须选择合适的哈希算法,对其进行优化配置,并保护好数据库。同时,一旦发生数据泄露事件,他们也必须将此事视为对用户的严重威胁。

5. 认证信息疲劳

此外,日常生活中也会遇到诸多令人沮丧的事情。你不得不不断遵守各种密码规则(至少包含一个特殊字符,长度不得超过16个字符等等),每隔几个月就要重新设置密码,还要回答安全问题、输入验证码……这样的麻烦不胜枚举。每当你创建一个新的账户,这些麻烦只会越来越多。

这种疲劳感会促使人们采取一些让密码变得极其不安全的做法:使用过短的密码、对同一密码进行微小的修改,或者把密码写在便签纸上。而这些行为也会给企业带来损失——人们在注册过程中半途而废,账号被锁,之后又不得不联系客服来重置自己忘记的密码。

Passkeys如何解决这些问题

Passkeys彻底消除了对共享密钥的需求。passkeys.dev将Passkeys描述为使用起来直观、唯一性高、且能够有效防止数据泄露和网络钓鱼攻击的工具。

以下是Passkeys如何解决上述各种问题的具体说明:

问题 Passkeys的解决方法
网络钓鱼攻击 浏览器和操作系统只会在使用Passkey的网站上使用该密钥。无论假冒网站看起来多么逼真,它们都无法获取这个密钥。
密码重复使用 每个Passkey都是为特定网站专门创建的一对密钥,因此不存在可以重复使用的密码。
数据泄露 服务器仅存储公钥。即使数据库被泄露,攻击者也无法用这些信息登录网站。
隐私问题 用户无需与网站共享任何敏感信息,而且每个网站的密钥都是不同的。指纹或面部识别数据也不会离开用户的设备。
认证信息疲劳 用户根本不需要创建或记住任何复杂的密码,只需使用每天都要使用的同一指纹、面部识别方式或屏幕锁来验证身份即可。

这种根本性的变化在于信任的归属对象发生了改变。使用密码时,安全保障依赖于用户自己辨别虚假网站并选择强密码的能力;而使用Passkeys时,则是浏览器和操作系统来执行这些安全规则,用户只需完成身份验证即可。

先决条件

要想顺利学习这个内容,你需要熟悉TypeScript,并具备一定的React开发经验。虽然不需要深入了解NestJS,但了解控制器和服务的基本概念会很有帮助。

此外,你还需要安装以下工具:

  • Node.js 22版或更高版本
  • PostgreSQL。在macOS系统上,Postgres.app是最佳选择;如果你更喜欢图形化工具,pgAdmin也会非常有用。
  • 支持Passkeys的设备。几乎任何现代笔记本电脑或手机都适用:配备Touch ID的Mac电脑、支持Windows Hello的Windows电脑,或是Android手机或iPhone。

什么是Passkey?

Passkey是一种基于公钥加密技术的认证方式。当你创建一个Passkey时,你的设备会生成一对密钥——私钥会保存在设备上,并通过指纹识别、面部识别或屏幕锁进行保护;而公钥则会发送到网站,与该网站的账户信息一起存储。

Passkey是基于Web Authentication API (WebAuthn)实现的,这是一种被所有主流浏览器支持的W3C标准。“Passkey”这个名称是业界为那些可以在不同设备之间同步使用的WebAuthn认证方式所指定的。如果你想了解更多相关信息,passkeys.dev是一个非常不错的资源。

有一个细节常常会让开发者感到意外:你的应用程序无法自行选择使用指纹识别、面部识别还是PIN码进行验证——实际上是由操作系统根据设备的功能以及用户设置的来决定使用哪种方式的。

以下是用户通常会看到的界面示例:

设备 验证方式
Mac Touch ID或Mac密码
iPhone或iPad Face ID、Touch ID或密码
Android 指纹识别、面部解锁或PIN码/图案/密码
Windows Windows Hello面部识别、指纹识别或PIN码
其他计算机 扫描二维码并在手机上确认验证信息

服务器可以要求进行某种本地验证操作,具体细节稍后会说明。

Passkey认证的工作原理

无论是注册还是登录,整个流程都遵循相同的三个步骤:

  1. 服务器生成一个随机挑战码,并将其连同一些选项一起发送给浏览器。
  2. 浏览器会要求设备对用户身份进行验证,并在收到挑战码后对其进行签名处理。
  3. 浏览器将经过签名的响应信息发回服务器,服务器会对这些信息进行验证。

在注册过程中,设备会生成一对新的密钥,并将公钥发送给服务器;服务器会验证这个公钥的有效性并将其保存下来。

在登录过程中,设备会使用现有的私钥对挑战码进行签名处理;服务器则会利用之前保存的公钥来验证这个签名。

在代码实现中还会涉及到另外两个概念:

依赖方ID指的是Passkey所属的域名,例如example.com;在开发环境中,这个值通常是localhost。浏览器会拒绝使用任何其他域名的Passkey,正是这一机制使得Passkey具有很强的防钓鱼攻击能力。

用户验证标志是签名响应信息中包含的一个字段。只有当用户完成了指纹识别、面部识别或屏幕锁验证流程后,设备才会设置这个标志;服务器会通过检查这个标志来确认进行验证的用户确实是真实存在的,而不是随便哪个点击按钮的人。

项目结构

该项目在一个代码仓库中包含了两个独立的应用程序:

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

你需要先构建后端,因为前端应用程序依赖于后端的接口。

如何设置NestJS后端

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

npx @nestjs/cli new server
cd server

然后安装WebAuthn、数据库相关的依赖项,以及JSON Web Tokens相关模块:

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

创建数据库

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

createdb -h localhost -U postgres auth

如果你更喜欢使用pgAdmin,可以右键点击“数据库”,选择“创建”,然后输入名称auth。

如果跳过这一步,NestJS在启动时会出现错误:“error: database "auth" does not exist”。TypeORM可以帮助你创建表格,但它不会自动创建数据库本身。

添加环境变量

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

JWT_SECRET=替换为一段长随机字符串

WEBAUTHN_RP_ID=localhost
WEBAUTHN_origin=http://localhost:3000

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=你的数据库密码
DB_NAME=auth

WEBAUTHN_RP_ID仅代表域名,不包含协议或端口信息。WEBAUTHN_origin是指前端应用程序的实际URL。如果请求来自其他地址,服务器会拒绝接受这些请求。

浏览器只允许在HTTPS网站或localhost上使用WebAuthn技术,因此在这种开发环境下,即使不使用证书也可以正常运行。

连接PostgreSQL数据库

创建src/auth/config/database.config.ts文件来配置数据库相关设置:

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文件来配置JSON Web Tokens相关设置:

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

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

synchronize选项用于指示TypeORM根据你的实体模型来创建和更新数据库表结构。在开发阶段使用这个选项非常方便,但在部署到生产环境之前,你应该改用数据迁移机制。

接下来,在src/app.module.ts文件中完成所有的配置:

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了:

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('应用正在4000端口运行');
}
bootstrap();

origin: true这一设置允许来自任何来源的请求,这样本地开发环境就会更加方便。在正式部署之前,请将这个值替换为你前端应用的URL。

如何定义数据库实体模型

这里需要创建两个表:User表用于存储用户账户信息,Authenticator表用于存储密码密钥。一个用户可以拥有多个密码密钥(例如,在笔记本电脑和手机上各保存一份)。

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

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;

  @UpdateDateColumn()
 .updated: Date;
}

让我们仔细看看其中几个字段。

首先,webauthnId是存储在设备上的用户标识符,它会与密码密钥一起保存。根据WebAuthn规范,这个字段不应包含个人信息,因此你应该使用随机字节来填充它,而不是用户的电子邮件地址。

接下来,currentChallenge用于存储服务器正在等待的验证信息。在生成验证选项时,这个字段会被设置;而在验证成功后,它会被清空。注意它的类型是string | null——这一点非常重要,具体原因我们会在安全相关部分进行解释。

counter是一个计数器。某些认证机制会在每次使用该计数器时将其值递增,这样服务器就能识别出被克隆的设备。而同步后的密码密钥通常会使这个计数器的值为零,SimpleWebAuthn支持这两种情况。

deviceType和backedUp这两个字段可以告诉你该密码密钥是否可以在不同设备之间进行同步,以及它当前是否已经被备份(例如备份到了iCloud Keychain中)。

最后,transports记录了浏览器是如何与设备中的认证模块建立连接的——无论是通过内置的认证功能、USB接口、蓝牙还是手机来连接。

如何在服务器上注册密码密钥

创建文件src/auth/auth.service.ts,首先编写类的定义:

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,
    @InjectRepository(Authenticator)
    private readonly authenticatorRepository: Repository Authenticator>,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {
    this.rpID = this.configService.get('WEBAUTHN_RP_ID');
    this.origin = this.configService.get('WEBAUTHN_origin');
  }

rpName是你的应用程序在用户界面中显示的名称。用户在操作系统的密码密钥提示框中会看到这个名称,之后也会在他们的密码管理器中看到它。

生成注册选项

第一种方法是先查找或创建用户对象,然后再向 SimpleWebAuthn 请求注册相关选项。请将相关代码添加到类中:

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 } },
  });

  if (authenticators.length > 0) {
    throw new ConflictException(
      '已存在使用该电子邮件的账户。请先登录。'
    );
  }

  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,
    }),
  });

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

  return options;
}

authenticatorSelection 部分用于指定您希望使用哪种类型的密钥。

residentKey: 'required' 表示需要生成一种可以在设备上存储的密钥;这种密钥可以保存在设备的本地存储空间中,或用户的密码管理器中,并且可以同步到其他设备上。

userVerification: 'required' 表示设备需要对用户进行身份验证(例如通过指纹识别、面部识别或屏幕锁等方式)。如果不设置这一选项,某些认证方式只会检查用户是否正在使用设备,而不会真正进行身份验证。

目前还缺少一个选项,用于指定用户已经拥有的密钥是否应该被排除在外。如果将这个选项设置为 'platform',那么用户就只能使用当前设备上的传感器来生成密钥;如果不设置这一选项,用户就可以通过 QR 码或硬件安全钥匙来生成密钥(passkeys.dev 推荐使用这种方式)。

attestationType: 'none' 表示不需要设备提供关于其型号和制造商的信息。大多数消费者应用其实并不需要这一信息,而且要求设备提供这些信息反而可能会引发额外的隐私问题。

excludeCredentials 用于列出用户已经拥有的密钥,这样设备就不会为已经注册过的密钥再次生成重复的密钥。由于在之前的步骤中已经进行了检查,因此这个列表在这种情况下总是为空的。不过,在以后允许已登录的用户添加新的密钥时,这个列表就会派上用场了。

检查是否存在现有的认证方式也是非常重要的。这个接口不需要用户先登录,因此不能允许它为已经拥有认证方式的账户再次添加密码键。否则,任何知道你的电子邮件地址的人都可以将自己的密码键添加到你的账户中。

验证注册响应

当用户在自己的设备上完成确认操作后,浏览器会返回一个响应。第二种方法就是验证这个响应并存储新的密码键:

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

  if (!user) {
    throw new UnauthorizedException('用户未找到');
  }

  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('注册验证失败');
}

verifyRegistrationResponse这段代码负责完成核心的验证工作。它会检查发送来的挑战码是否与你之前保存的那一个相匹配,确认响应确实来自你的服务器以及使用的是正确的RP ID,同时还会验证签名是否有效。当requireUserVerification: true被设置为true时,如果响应中没有包含表示用户已经完成了指纹识别、面部识别或屏幕锁验证的标志,该函数也会拒绝接受这个响应。这个标志就是证明用户已经完成了这些身份验证步骤的证据。

如果一切顺利,系统会保存密码键的ID和公钥,同时清除之前的挑战码,以防止其被再次使用。

如何在服务器上使用密码键进行登录

登录的过程与生成认证选项时的步骤相同:首先生成各种认证选项,然后验证用户发送回来的响应。

生成认证选项

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

  if (!user) {
    throw new UnauthorizedException('用户未找到');
  }

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

  if (authenticators.length === 0) {
    throw new UnauthorizedException(
      '该账户尚未注册任何密码键。请先进行注册。'
    );
  }

  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;
}

allowCredentials列出了该用户拥有的所有密码密钥。浏览器会使用这个列表在设备上查找合适的密钥,而transports参数则有助于浏览器决定是在本地查找、从安全密钥中获取信息,还是从手机中检索数据。

验证认证响应

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

  if (!user) {
    throw new UnauthorizedException('用户未找到');
  }

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

  if (!authenticator) {
    throw new UnauthorizedException('认证器未找到');
  }

  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 =
      verificationauthenticationInfo.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('认证验证失败');
}

在进行查找时,会同时使用凭证ID和用户ID。请不要忽略用户ID这一条件。我会在安全相关章节解释其中的原因。

之所以在计数器的值周围使用了Number()函数,是因为pg驱动程序会将bigint类型的列返回为字符串形式,而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);
  }
}

在这里,response 的数据类型被指定为 any,因为这些服务方法已经明确指出了它们所期望接收的 SimpleWebAuthn 数据类型。

如何使用 JWT 保护路由

最后一个路由使用了 JwtAuthGuard,因此只有已登录的用户才能调用它。这个功能是由一个简单的服务方法实现的。请将其添加到 AuthService 中:

async getAuthenticators(email: string) {
  const user = await this.userRepository.findOne({ where: { email } });
  if (!user) {
    throw new UnauthorizedException('用户未找到');
  }
  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });
  return authenticators;
}

接下来,在 src/auth/strategies/jwt.strategy.ts 文件中创建用于读取并验证令牌的 Passport 策略:

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 的内置守卫功能,并会返回更清晰的错误信息:

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestpassport';
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('令牌已过期');
    }

    if (info instanceof JsonWebTokenError) {
      throw new UnauthorizedException('无效的令牌');
    }

    if (err || !user) {
      throw new UnauthorizedException('身份验证失败');
    }

    return user;
  }

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

    const authHeader = request.headersauthorization;
    if (!authHeader) {
      throw new UnauthorizedException('未提供令牌');
    }

    const [type] = authHeader.split(' ');
    if (type !== 'Bearer') {
      throw new UnauthorizedException('令牌格式无效');
    }
    return super.canActivate(context) as Promise/;
  }
}

如何注册认证模块

最后,将所有内容整合到src/auth/auth.module.ts文件中:

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: {
          expires: '1d',
        },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

这里的signOptions用于控制该服务生成的令牌的有效期限,因此每次登录后的令牌都只有效一天。

启动服务器:

npm run start:dev

TypeORM会在你的auth数据库中创建user和authenticator这两个表。你可以打开pgAdmin,然后刷新Tables选项卡来查看这些表。

如何配置Next.js前端

在项目根目录下,创建一个使用TypeScript、Tailwind CSS以及App Router技术的Next.js应用,并设置一个src文件夹:

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

在client文件夹中添加一个.env文件,用于指定API的地址:

NEXT_PUBLIC_API_URL=http://localhost:4000

接下来,在client/src/services/api.ts文件中创建一个简单的API客户端:

/* 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.headersAuthorization = `Bearer ${token}`;
  }
  return config;
});

export default api;

一旦用户完成登录,位于底部的拦截器就会将JWT附加到每一个请求中,而这正是受保护的/auth/authenticators路由所期望的。

如何构建注册页面

WebAuthn的前端实现其实相当简洁。SimpleWebAuthn的startRegistration函数会从服务器获取相关配置选项,然后调用浏览器的WebAuthn API,并以JSON格式返回结果,以便服务器能够理解这些数据。

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

/* 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('此浏览器不支持passkey。');
      setLoading(false);
      return;
    }

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

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

      const verificationResult = await webAuthnApi.verifyRegistration(email, regResult);

      if (verificationResult.verified) {
        router.push('/welcome');
      }
    } catch (err: any) {
      console.error('注册失败:', err);
      setError(getPasskeyErrorMessage(err, '注册失败,请重试'));
    } finally {
      setLoading(false);
    }
  };

  return (
    

注册新账户

创建您的账户

{error && (
{error}
)}

您需要使用指纹、面部识别或屏幕锁进行身份验证。

已经拥有账户? 登录
); }

handleRegistration函数遵循之前提到的三个步骤:从服务器获取相关选项,将这些选项传递给startRegistration函数,然后将处理结果发送回服务器进行验证。

当调用startRegistration时,真正的魔法就发生了——浏览器会弹出操作系统的密码键输入框,用户可以通过Touch ID、Face ID、Windows Hello或手机来进行身份确认。你的代码只需等待这个操作完成即可。

请注意{ optionsJSON: options }这个参数。SimpleWebAuthn的早期版本是直接接受这些选项的,很多教程至今仍然按照这种方式来讲解。虽然Version 13仍然支持旧的方式,但会生成警告信息,因此建议使用对象形式的参数传递。

注册页面,包含电子邮件输入框和“创建密码键”按钮

如何构建登录页面

登录页面与注册页面的结构是相同的。创建文件client/src/app/signin/page.tsx,将注册页面的内容复制到其中,然后修改标题以及底部的链接地址,使其指向/signup。接下来,需要导入startAuthentication函数而不是startRegistration函数,并将表单提交处理逻辑替换为以下代码:

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

  if (!browserSupportsWebAuthn()) {
    setError('此浏览器不支持密码键功能。');
    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('认证出现错误:', err);
    setError(getPasskeyErrorMessage(err, '认证失败,请重试。'));
  } finally {
    setLoading(false);
  }
};

别忘了将handleAuthentication函数绑定到表单的onSubmit事件上。你可以在GitHub仓库中查看最终完成的页面效果。

当验证成功后,服务器会返回一个JWT令牌,页面会在重定向之前将其存储在localStorage中。在这个演示示例中,使用localStorage来存储令牌确实比较简单;但在实际的生产环境中,建议使用服务器生成的httpOnlycookie,因为页面上的JavaScript代码无法读取这些cookie。

操作系统弹出的密码键提示框,用户需通过Touch ID进行身份确认

如何显示有用的错误信息

当在生成passkey的过程中出现故障时,浏览器会抛出一个DOMException,其中的name字段会说明具体发生了什么问题。不过这些原始的错误信息并不容易理解,因此将一些常见的错误信息翻译成更易于用户理解的文字是个不错的做法。

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

/* 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':
      // 此设备不支持passkey功能
      return '此设备不支持passkey。'
    case 'SecurityError':
      // 此域名不允许使用passkey功能,请检查WebAuthn RP ID和来源设置。
    default:
      return fallback;
  }
}

NotAllowedError是您最常遇到的错误类型。当用户关闭验证窗口、等待时间过长,或者生物特征识别失败时,都会出现这种错误。SecurityError通常表示您的RP ID与页面运行的域名不匹配,这是配置问题而非用户操作失误造成的。

如何测试您的应用

在不同的终端中分别运行这两个应用程序:

# Terminal 1
cd server
npm run start:dev
# Terminal 2
cd client
npm run dev

访问http://localhost:3000/signup,输入您的电子邮件地址,然后点击按钮。您的设备会显示生成passkey的提示界面。在配备Touch ID功能的Mac上,系统会要求您触碰指纹识别传感器;否则,您需要使用MacBook的用户密码来完成注册流程。在Windows系统中,Windows Hello会请求您输入面部特征、指纹或PIN码。如果您选择使用其他设备,浏览器会显示一个二维码,您可以用车载手机扫描该二维码。

确认信息后,您会进入欢迎页面。接下来访问/signin,再次输入相同的电子邮件地址并完成验证流程。这次服务器会返回一个JWT令牌。

如果您没有可用的支持passkey功能的设备,Chrome浏览器内置了虚拟认证器。打开DevTools,点击三点菜单,选择更多工具,然后挑选WebAuthn选项。启用虚拟认证器环境后,再添加一个勾选了"支持用户验证"选项的认证器,Chrome浏览器就会模拟生成passkey的设备来进行测试。

你也可以打开pgAdmin,查看authenticator表。你会看到凭证ID、以字节形式存储的公钥以及设备类型。其中并没有密码或任何保密信息。

应避免的安全隐患

Passkey在设计上本来就是安全的,但与之相关的服务器代码也必须正确无误。在开发这个项目的过程中,我遇到了以下这些问题。其中一些问题并不会引发任何错误,而这恰恰使它们变得危险。

将凭证ID编码两次

在最新版本的SimpleWebAuthn中,包括这里使用的14版本,credential.id本身就已经是base64url格式的字符串了。我最初编写的代码就是这样处理的:

// 错误做法:对已经编码过的字符串再次进行编码
credentialId: Buffer.from(credential.id).toString('base64url'),

Buffer.from会将这个字符串视为UTF-8文本进行处理,因此最终得到的结果仍然是base64url格式的字符串。虽然注册操作能够成功完成,但登录时服务器却无法识别该凭证,因为浏览器发送的是原始的ID值。应该直接使用credential.id本身的值。

用undefined清除挑战码

每个挑战码都应当仅被使用一次。以下这行代码看似是在清除挑战码:

user.currentChallenge = undefined;

但实际上,TypeORM在保存实体数据时会忽略undefined这个属性,因此旧的挑战码仍然会留在数据库中。如果有人截获了有效的响应信息,就可以重新使用这些挑战码进行登录。正确的做法应该是将这个值设置为null,并且要确保数据库列类型允许存储这种空值。

在不检查所有者身份的情况下查询凭证

人们很容易仅通过凭证ID来查找相应的认证信息:

// 错误做法:任何用户的passkey都符合这个条件
where: { credentialId: response.id }

但问题在于:攻击者可以用自己的passkey来签署你的挑战码,服务器会验证这个签名并与他们的公钥进行比对,最终就会为你生成一个登录令牌。因此,在进行查询操作时,必须确保包含用户的身份信息,就像前面的例子中所做的那样。

允许新用户为现有账户添加passkey

注册接口是公开可访问的。如果允许用户为与某电子邮件地址匹配的账户添加passkey,那么任何知道该电子邮件地址的人都可以注册自己的passkey并冒充你的身份进行登录。

如果某个账户已经拥有passkey,就不要允许再次注册。如果你确实希望用户能够添加更多的passkey,那么可以设计一个单独的功能来处理这一操作,但用户必须先登录才能使用这个功能。

在没有必要要求验证的情况下强制进行验证

如果您请求使用 `userVerification: 'preferred'`,那么设备就可以跳过指纹识别或PIN码验证步骤。但随后如果您又设置 `requireUserVerification: true`(这是SimpleWebAuthn的默认配置),那么使用这些设备的用户就会遇到验证失败的问题。因此,请在两端都选择 `'required'`,以确保选项设置与验证流程保持一致。

后续步骤

您所构建的应用已经实现了基本的无密码认证功能。但如果您想进一步优化系统,还可以进行一些改进。

“Passkey自动填充”功能会让浏览器在电子邮件输入框中自动提示用户输入已保存的passkey,就像它会自动提示用户输入已保存的密码一样。SimpleWebAuthn通过 `startAuthentication` 方法中的 `useBrowserAutofill` 选项支持这一功能。

用户可能还会需要为其他设备添加passkey或删除旧的passkey。您可以通过在JWT安全机制后添加相应的API端点来实现这些操作。

最后,挑战码也需要设置过期时间。一种简单的实现方法是:在存储 `currentChallenge` 的同时,也记录下其创建的时间戳,然后拒绝任何超过几分钟期限的挑战码。在规模较大的应用中,您可以选择将挑战码存储在会话对象或缓存系统中(例如Redis),而不是用户记录中。

总结

通过本教程,您已经成功构建了一个基于passkey的无密码认证系统。您拥有一个完整的NestJS API,能够生成WebAuthn挑战码,使用SimpleWebAuthn验证用户的响应,并将公钥存储在PostgreSQL数据库中;同时,您还开发了一个Next.js前端界面,让用户可以通过指纹、面部识别或设备屏幕锁来注册或登录。

现在您已经了解了挑战码与签名验证流程的工作原理,明白了为什么用户验证设置如此重要,也知道了为什么服务器无法决定用户应该使用哪种生物特征认证方式。此外,您还了解到了一些可能导致登录失败甚至账户被盗用的隐藏问题,以及如何避免这些问题的发生。

虽然密码在短期内不会被完全取代,但几乎所有用户的设备都已经支持passkey功能。现在您已经掌握了如何将这一技术应用到自己的应用程序中。

完整的源代码可以在GitHub上找到。欢迎您克隆这份代码,根据实际需求进行修改和优化。如果您觉得本教程对他人有帮助,请务必分享给那些可能会有类似疑问的人。

相关文章

技术实践

如何在2026年构建并发布原生Android应用程序:从Jetpack Compose到Google Play商店

大多数关于Android的开发教程都只讲到“应用程序可以在模拟器上运行”这一阶段。但实际上,要将一个从空白项目开发出来的应用发布到Google Play商店供用户下载,还需要完成许多其他步骤,而这些内容在很多教程中都被忽略了:发布版本的构建、签名密钥的生成、应用包的制作、应用信息的填写、政策声明的提交,以及新开发者账户必须经历的测试环节。 在过去的几年里,Android开发领域也发生了很大的变化。Jetpack Compose现在已经成为了默认的UI开发工具包,XML布局方式已经过时了,Kotlin成为了官方模板中唯一支持的语言,而Google Play也对新开发者提出了更高的要求。 那些以`

阅读全文
技术实践

如何在JavaScript中使用全屏API(并通过“唤醒锁API”使屏幕保持处于唤醒状态)

迟早,大多数前端开发人员都会遇到这样一个问题: "这个内容能覆盖整个屏幕吗?" 无论是幻灯片、视频播放器、信息亭的控制面板、游戏界面、绘图工具,还是教室投影仪上的计时器,当浏览器标签页和地址栏还显示在屏幕边缘时,这些界面看起来总是不完整。 好消息是,浏览器本身提供了解决方案: 全屏API 。不过坏消息是,这个API存在一些问题,在实际使用中很可能会遇到麻烦——比如用户的手势操作、Safari浏览器特有的前缀、某些iPhone设备的不兼容性,以及屏幕在进入全屏状态两分钟后就会自动变暗的问题。 在本教程中,你将学习如何: 将任何元素(或整个页面)设置为全屏显示,然后再恢复正常视图 当用户按下 Es

阅读全文
技术实践

文章:专为低端安卓手机开发的、以离线功能为主的人工智能编程辅导工具

本文基于我们在尼日利亚和印度的实践经验,探讨了如何为低端设备开发以离线功能为主的应用程序。在这个领域中,需要重点解决存储限制、数据使用配额、应用程序冷启动问题以及故障处理机制的设计等问题。我们的方法适用于那些用户使用的设备性能有限、网络连接不稳定的产品;因为在这种情况下,用户显然不可能拥有笔记本电脑或宽带接入。 作者:奇迪·恩瓦奥古

阅读全文
技术实践

Chrome开始测试电子邮件验证API:允许网站在无需发送电子邮件的情况下完成用户身份验证

谷歌已为“电子邮件验证API”启动了Chrome平台的测试计划。这一由WICG提出的技术方案允许浏览器在无需发送任何消息的情况下确认用户对电子邮件的拥有权。Gmail是首个采用该技术的服务提供商。开发者可以通过在表单中添加提示信息来帮助用户填写信息,并在服务器端验证用户生成的签名令牌。不过,一些评论者对这种技术带来的隐私保护效果表示怀疑,认为该API更适合被用作渐进式优化功能。 作者:丹尼尔·柯蒂斯

阅读全文