什么是 PassKey

如今社会处于互联网迅速爆发的时代,几乎每个人都会注册上N个应用账号,每个账号需要设置密码,为了安全性当然是建议每个应用设置独立密码,不要使用重复密码(一旦某个网站发生密码泄漏其他网站会被撞库),而且密码复杂度要高一些。

但是这样对人的记忆是一种考验,几乎不会有人可以记住每个网站的无规律密码,这就需要有工具来记录所有的密码。浏览器自带了记住密码的功能,iOS也自带了密码册功能,也有很多第三方工具用于记录密码(比如1Password)。

虽然看起来没什么问题了(其实就目前而言确实没什么问题),但是头部厂商们还是不满现状,他们妄图完全丢弃密码。

PayPal和联想创办了一个叫「FIDO联盟」的非营利性组织,该组织旨在“解决强身份认证设备之间缺乏可互操作性的问题,以及用户面临的设立、记忆多组用户名和密码的难题”。

image-20240201235826911

随后,FIDO和W3C一起推出了基于公开密码学的通用无密码登录标准——Passkey。

通过系统(现在好像也支持第三方平台)验证用户身份后(如FaceID、TouchID等)根据网站的用户信息生成凭证,将凭证保存到钥匙串中,网站验证公钥和凭证后保存公钥信息(不支持生物识别的设备可以通过扫码来完成);后续登陆网站时,验证用户身份后取出凭证,网站验证签名之后添加登陆态。

就像这样,在电脑上

image-20240202000811006

移动设备上是这样

IMG_4325

苹果生态中 Apple 钥匙串同步凭证还是挺舒服的,一台设备上注册,所有的设备都可以使用。

Passkey 的简易交互过程描述如下:

注册

image-20240201000855106

登陆

image-20240201000928844

图片来源:webauthn.guide,也是一个很不错的了解webauthn的网站。

Web Authentication API

Web Authentication API(简称WebAuthn)是浏览器提供的API, 是一种用于创建、注册和验证用户的新型 Web 标准,旨在提供更安全、更无缝的身份验证体验。它旨在取代传统的用户名/密码登录方式,并使用生物识别技术(如指纹或面部识别)或硬件密钥(如 USB 安全密钥)等替代方案来进行认证。

Web Authentication API 的主要组成部分包括:

  1. 公共 KeyCredential 接口:用于生成和管理密钥对,其中私钥存储在客户端,公钥可供服务端验证。
  2. 导航CredentialManagement界面:实现网站对用户凭据进行管理以及创建新凭据。

通过 Web Authentication API,开发人员可以轻松地集成基于生物特征或硬件密钥的身份认证机制到他们的网站中。这不仅能够提高安全性,还能为用户带来更便捷和无需注重记忆密码等优势。同时也可以防范许多常见的网络攻击形式,例如密码猜测、盗窃和重放攻击。

⚠️需要在HTTPS环境中使用,localhost开发环境也可以。

主要的API有两个create用于创建凭证,get用于读取存储在设备上的凭证。

create

create([options]),用于创建凭据,可选参数是一个options 对象,有两个可选的属性:

  • 一个描述凭证的类型对象,以下三选一
    • federated:创建联合标识提供者凭据所需的参数;
    • password:创建密码凭据所需的参数;
    • publicKey:创建公钥凭据所需要的参数;
  • signal:创建凭据的过程可以中止,使用一个AbortSignal实例来进行控制,和中断ajax请求类似。

两外两种类型不过多描述,publicKey是本文的重点,一般来说是从服务器获取的,用于控制创建凭证过程中的一些行为,下面摘几个重要的属性来看下。

  • userVerification:依赖方控制是否用户对create操作进行验证,discouraged进行验证;preferred进行验证,如果用户无法验证不会报错;required(默认值)进行验证,如果用户无法验证则报错。
  • *challenge:质询,一个二进制对象(ArrayBuffer、TypedBuffer、DataView等),从依赖方获取,使用生成的私钥凭证进行加密后用于依赖方进行验证。
  • excludeCredentials:排除的凭证列表,如果应用仅允许一台设备注册一个用户,可以通过这个字段来进行排除,每个凭证包含三个字段:id(以注册的凭证ID)、type(凭证类型,目前仅支持’public-key’)和transports(可选,允许传输的类型数组,blenfc……)。
  • *rp:一个描述依赖方的对象:id,用于钥匙串管理凭据,一般是网站hostname;name,应用名称,用于注册凭证的时候显示应用名称。
  • timeout:超时时间,标识应用愿意等待创建操作完成的时间。
  • *user:一个用户描述对象:id ,用户唯一标识,需要是一个二进制类型;name,用于登陆的用户名; displayName,显示名称,一般是用户昵称。

返回值是一个包含公钥凭证Promise<PublicKeyCredential>对象:

  • id:凭据id,rawId经过base64编码后的字符串。
  • rawId:凭据id,一个二进制数据。
  • type:凭据类型,目前仅支持public-key
  • response:一个AuthenticatorAttestationResponse对象
    • clientDataJSON:从浏览器传递给身份验证器的数据,包含challenge、origin等信息。是一个二进制数据。
    • attestationObject:包含凭据公钥、可选的证明证书和其他用于验证注册事件的元数据,是用CBOR编码的二进制数据。

get

get([options])用于读取已经创建的凭据,通常用于登陆验证,这里的options和注册时基本一样,多了identityotp,这不是重点,重点还是publicKey:

  • allowCredentials:和创建的excludeCredentials一样。
  • *challenge:质询,一个二进制数据,和注册时的一样。
  • rpId:依赖方的id,一般是hostname,用于读取钥匙串中改网站注册的凭据。
  • timeout:网站愿意等待用户验证的超时时间。
  • userVerification:和创建的userVerification一样。

返回值也是一个包含公钥凭证Promise<PublicKeyCredential>对象,和注册不同的是response的内容有区别

  • response:一个AuthenticatorAssertionResponse对象,比注册时多了两个属性
    • signature:与此凭证关联的私钥生成的签名。
    • userHandle:此字段可选择由身份验证器提供,表示注册期间提供的user.id。

可以在Google WebAuthn网站体验一下,通过开发者工具NetWork看一下交互的流程,会更好理解验证的过程。

实战 passkey

本次实现基于之前已有的用户系统,在原有的密码校验基础上添加passkey绑定和登陆。

基于simplewebauthn进行实现,可以避免使用繁琐的原生API来处理各种数据格式。

后端部分

还是我最爱的那一套:

  • 环境/语言:NodeJS
  • 框架:MidwayJS+Koa
  • 数据库:MySQL+TypeORM

项目的初始化就不多说了,可以去MidwayJS官网看下文档,安装WebAuthn相关依赖

$ npm install @simplewebauthn/server // 依赖方凭据处理
$ npm install @simplewebauthn/types // 类型

在开始逻辑之前我们需要先进行表结构的设计,在本次实践的环境中,我们将用户记录和PassKey进行一对多绑定,即单用户可以绑定多个凭据。

一下是TypeORM的实体配置信息

@EntityModel('passkey')
export class PasskeyModel {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    comment: '凭证ID',
  })
  credentialID: string;

  @Column({
    comment: '用户公钥',
    type: 'text',
    transformer: {
      from: (value: string) =>
        value && Uint8Array.from(Buffer.from(value, 'base64')),
      to: (value: Uint8Array) => value && Buffer.from(value).toString('base64'),
    },
  })
  credentialPublicKey: Uint8Array;

  @Column({
    comment: '状态: 0 停用 1 正常',
    type: 'integer',
    default: PasskeyStatus.ENABLE,
  })
  status: PasskeyStatus;

  @Column({
    comment: '计数',
    type: 'bigint',
    default: 0,
  })
  counter: number;

  @CreateDateColumn({
    type: 'datetime',
    comment: '创建时间',
  })
  createTime: string;

  @UpdateDateColumn({
    type: 'datetime',
    comment: '上一次使用信息时间',
  })
  updateTime: string;

  @Column({
    comment: '设备信息',
  })
  userAgent: string;

  @ManyToOne(() => UserModel, user => user.passkeys)
  user: UserModel;
}

credentialPublicKey和credentialID类型在使用中都是二进制数据,为了查询时操作方便,credentialID存储为base64字符串,使用时进行编码;credentialPublicKey可以直接存储为二进制,但是考虑到不同的数据库支持的二进制类型不一样,这里通过transformer来对读取和写入数据进行转换,最终落库时的体现是base64字符串。

然后是接口部分,我们需要提供四个接口用于注册凭据(返回配置和校验保存凭据)及使用凭据验证(返回配置和校验凭据)。

@Controller('/passkey')
export class PasskeyController {
  @Inject()
  passkeyService: PasskeyService;

  @Inject()
  ctx: Context;

  // 获取webauthn选项
  @Get('/registration/options')
  async getRegistrationOptions() {
    const userId = this.ctx.state.user.uid;
    const options = await this.passkeyService.startRegistration(userId);
    // 保存到session
    this.ctx.session.challenge = options.challenge;
    return options;
  }

  // 注册webauthn
  @Post('/registration')
  @Validate({
    validationOptions: {
      allowUnknown: true,
    },
  })
  async registration(@Body() credential: RegisterCredentialDTO) {
    // 获取质询
    const expectChallenge = this.ctx.session.challenge;
    const bool = await this.passkeyService.finishRegistration(
      expectChallenge,
      credential
    );

    return bool;
  }

  // 获取验证(登陆)webauthn选项
  @Get('/assertion/options')
  async getAssertionOptions() {
    const options = await this.passkeyService.startAssertion();
    // 保存到session
    this.ctx.session.challenge = options.challenge;
    return options;
  }

  // 验证(使用webauthn登陆)
  @Post('/assertion')
  @Validate({
    validationOptions: {
      allowUnknown: true,
    },
  })
  async assertion(@Body() credential: AuthCredentialDTO) {
    // 获取质询
    const expectChallenge = this.ctx.session.challenge;
    const { user } = await this.passkeyService.finishAssertion(
      expectChallenge,
      credential
    );

    this.ctx.session.challenge = null;
    return user;
  }
}

为了方便阅读,删除了Controller中的一些边界值检测及报错,只保留的主要的业务逻辑。获取配置时(create和get一样)将challenge保存到session,验证时从session中取出。

然后是Service层,对我们使用到的一些方法进行实现。

@Provide()
export class PasskeyService {
  @InjectEntityModel(PasskeyModel)
  passkeyModel: Repository<PasskeyModel>;

  @InjectEntityModel(UserModel)
  userModel: Repository<UserModel>;

  @Config('passkey')
  passkey;

  @Inject()
  ctx: Context;

  /**
   * 根据用户ID生成注册passkey所需的配置
   * @param userId 用户ID
   * @returns passkey 配置
   */
  async startRegistration(userId: string) {
    const user = await this.getAllPasskeysInUser(userId);

    return await generateRegistrationOptions({
      rpName: this.passkey.rp.name,
      rpID: this.passkey.rp.id,
      userID: user.userId,
      userName: user.username,
      userDisplayName: user.nickname,
      attestationType: 'none',
      excludeCredentials: user.passkeys.map(authenticator => ({
        id: Uint8Array.from(Buffer.from(authenticator.credentialID, 'base64')),
        type: 'public-key',
      })),
      authenticatorSelection: {
        residentKey: 'preferred',
        userVerification: 'preferred',
        authenticatorAttachment: 'platform',
      },
    });
  }

  /**
   * 绑定用户的passkey
   * @param expectChallenge 质询字符串
   * @param credential 凭证信息
   */
  async finishRegistration(
    expectChallenge: string,
    credential: RegistrationResponseJSON
  ) {
    const { verified, registrationInfo } = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge: expectChallenge,
      expectedOrigin: this.passkey.origin,
    });

    if (verified) {
      // 保存凭据
      const { credentialPublicKey, counter } = registrationInfo;
      const passkey = this.passkeyModel.create({
        credentialID: credential.id,
        credentialPublicKey,
        userAgent: useragent.parse(this.ctx.headers['user-agent']).toString(),
        counter,
        user: {
          userId: this.ctx.state.user.uid,
        },
      });

      await this.passkeyModel.insert(passkey);
    }

    return verified;
  }

  /**
   * 生成登录passkey所需的配置
   */
  async startAssertion() {
    return await generateAuthenticationOptions({
      rpID: this.passkey.rp.id,
      userVerification: 'preferred',
    });
  }

  /**
   * 验证预期凭证和质询
   * @param expectedChallenge 预期的质询
   * @param credential 身份凭证
   */
  async finishAssertion(
    expectedChallenge: string,
    credential: AuthenticationResponseJSON
  ) {
    const passkey = await this.getPasskeyById(credential.id, true);

    if (!passkey) {
      throw new CustomError(PasskeyError.CODE_1, PasskeyError.MESSAGE_1);
    }

    const authResponse = {
      response: credential,
      expectedChallenge,
      authenticator: {
        ...passkey,
        credentialID: Uint8Array.from(
          Buffer.from(passkey.credentialID, 'base64')
        ),
      },
      expectedOrigin: this.passkey.origin,
      expectedRPID: this.passkey.rp.id,
    };

    const { verified, authenticationInfo } = await verifyAuthenticationResponse(
      authResponse
    );

    if (verified) {
      // 更新计数器
      passkey.counter = authenticationInfo.newCounter;
      await this.updatePasskey(passkey);
    }

    return passkey;
  }

  /**
   * 获取用户及其绑定的所有 passkey
   * @param userId 用户ID
   */
  async getAllPasskeysInUser(userId: string) {
    const user = await this.userModel.findOne({
      where: {
        userId,
      },
      relations: ['passkeys'],
    });

    return user;
  }

  // 保存passkey数据
  async updatePasskey(passkey: PasskeyModel) {
    return await this.passkeyModel.save(passkey);
  }

  // 根据凭证ID获取passkey
  async getPasskeyById(id: string, auth = false) {
    return await this.passkeyModel.findOne({
      where: {
        credentialID: id,
        ...(auth ? { status: PasskeyStatus.ENABLE } : {}),
      },
      relations: ['user'],
    });
  }
}

主要的三层代码就是这些。

前端部分

安装依赖(纯JS依赖,不区分框架)

$ npm install @simplewebauthn/browser
$ npm install @simplewebauthn/types

在我原有的用户安全设置中新加一个PassKey相关的设置项,用于管理绑定过的凭据

image-20240205184426831

点击【新增绑定】时触发事件来进行注册

async function register() {
  const options = await getRegisterOptions(); // 获取注册配置接口
  const credential = await startRegistration(options); // @simplewebauthn/browser API
  const success = await registration(credential); // 注册凭据接口
  if (success) {
    Message.success("绑定成功");
    reloadPasskeys();
  }
}

image-20240205190208965

验证成功后即可生成凭据(状态控制和删除的接口没有放出来,没啥难度,可以自己尝试一下)。

image-20240205190258304

然后在使用场景添加验证逻辑,我是在登陆场景中使用了PassKey

image-20240205190500577

给Button绑定事件用于验证凭据

async function passkeyLogin() {
  const options = await getAssertionOptions(); // 获取验证配置的接口
  const res = await startAuthentication(options); // @simplewebauthn/browser 提供的API
  const token = await assertion(res); // 验证凭据的接口
  if (token) {
    // 登陆成功的逻辑
  }
}

基于 simplewebauthn 来实现WebAuthn还是挺简单的。

结语

目前而言 passkey 并不能完全取代密码,有许多弊端没有解决办法,比如用户设备损坏导致的凭证丢失,那么就无法登陆网站,还是需要借助一些第三方工具来进行授权,比如邮箱或者SMS,passkey 只是作为一种快捷登录的锦上添花还是不错的。

但是长远来看,passkey还是很有潜力的,相信不远的将来能够成为主流的登陆手段。


前端小白