Passkey 替代密码:工作原理与集成方式

1. 概述

密码有三个主要问题:容易被猜测、在多个地方被重复使用、容易被欺骗输入到假页面。Passkey 通过公钥密码学解决了这三个问题。服务器不再持有用户的任何秘密。

本文解释 passkey 是什么以及注册、登录流程如何进行。之后,你将在 Spring Boot 3.x 应用中使用 Spring Security 启用 passkey。文末讨论常见错误和不应该使用 passkey 的情况。

2. Passkey 是什么?

Passkey 是一个 WebAuthn credential,即遵循 FIDO2 标准的 credential。本质上,它是一对密钥:

  • Private key 存放在用户的设备上(手机、笔记本、USB 安全密钥)或密码管理器中。这个密钥永远不会离开那里。
  • Public key 在注册时被发送到服务器。服务器将这个密钥与账户一起保存。

每个密钥对通过 Relying Party(RP,即你的网站或应用)和 RP ID 绑定到一个特定的站点。RP ID 通常是域名,例如 shop.example.com。

有两种主要的 passkey 类型:

类型 特点
同步 passkey 通过操作系统或密码管理器的云账户同步。丢失设备仍可恢复。
设备绑定 passkey 只存在于一个设备上,例如硬件安全密钥。更安全,但丢失设备就丢失了密钥。

用户通过指纹、脸部或设备 PIN 来解锁 passkey。生物识别数据仅用于本地解锁。它不会被发送到服务器。

3. Passkey 如何工作

3.1. 注册(registration)

  1. 服务器生成一个随机的 challenge。服务器将 challenge 连同 RP 和用户信息发送给浏览器。
  2. 浏览器调用 navigator.credentials.create()。Authenticator 为该 RP ID 生成新的密钥对。
  3. 浏览器向服务器发送公钥、credential ID 和签名数据。
  4. 服务器验证 challenge 和 origin,然后保存公钥。

3.2. 登录(authentication)

  1. 服务器生成新的 challenge。
  2. 浏览器调用 navigator.credentials.get()。Authenticator 使用私钥对 challenge 进行签名。
  3. 服务器使用已保存的公钥验证签名。

核心部分是“用私钥签名,用公钥验证”。下面的纯 Java 代码演示了这个步骤:

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // 设备在注册时生成密钥对(EC P-256,对应 WebAuthn 中常见的 ES256)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // 服务器为登录生成随机 challenge
        byte[] challenge = randomBytes();

        // 设备使用私钥对 challenge 签名
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // 服务器使用已保存的公钥验证
        System.out.println("Challenge 正确: " + verify(storedOnServer, challenge, signature));
        System.out.println("Challenge 错误: " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

输出:

Challenge 正确: true
Challenge 错误: false

从这个结果,你可以得出两点:

  • 服务器只保存 storedOnServer,即公钥。即使攻击者获得了数据库,也无法用它来登录。
  • 签名只对该特定时间的 challenge 有效。如果攻击者截获旧签名并重放(重放攻击),也无法通过验证步骤。

在真实的 WebAuthn 中,被签名的数据还包含页面的 origin。浏览器会自动填充 origin,网页无法伪造。因此,如果用户不小心访问了 examp1e.com,浏览器不会使用 example.com 的 passkey。钓鱼攻击在协议层面就被阻止了,不依赖于用户的谨慎。

4. 为什么 Passkey 越来越受关注

  • 从设计上防止钓鱼攻击。 密码和 OTP 都可能被骗到假页面。Passkey 则不会。
  • 服务器上没有秘密。 数据库泄露不会暴露登录凭证。
  • 更好的用户体验。 用户只需轻触指纹,不必记住密码或等待短信。
  • 生态系统已就绪。 主流操作系统、浏览器和密码管理器都支持 passkey 同步。因此,丢失设备不再是大问题。
  • 框架已有支持。 你不需要自己编写密码验证代码,那很容易出错。

5. 在 Spring Security 中集成 Passkey

从 Spring Security 6.4(Spring Boot 3.4 及更高版本附带)开始,框架内置了 webAuthn() DSL。底层使用 WebAuthn4J 库进行验证。

5.1. 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

声明属性 webauthn4j.version 为与你使用的 Spring Security 兼容的版本。Spring Security 文档明确列出了经过测试的版本。

5.2. 配置 SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

各部分的含义:

  • rpName:在操作系统 passkey 对话框中显示的名称。
  • rpId:绑定到 passkey 的域名。这个值没有 scheme 或 port。在生产环境,这会是 shop.example.com。
  • allowedOrigins:允许的 origin 列表。Origin 必须完全匹配,包括 scheme 和 port。
  • formLogin:保留此选项。用户需要先用密码登录,然后才能注册 passkey。Passkey 是绑定到现有账户的附加凭证。

运行应用时,Spring Security 自动创建以下端点:

GET  /webauthn/register              passkey 管理/注册页面(默认)
POST /webauthn/register/options      创建注册 challenge
POST /webauthn/register              保存新公钥
POST /webauthn/authenticate/options  创建登录 challenge
POST /login/webauthn                 验证签名并登录

默认登录页面会多一个“用 passkey 登录”按钮。试验流程包括四个步骤:

  1. 用 minh / matkhau-tam 登录。
  2. 打开 /webauthn/register 并创建 passkey。
  3. 登出。
  4. 用 passkey 再次登录。

5.3. 凭证存储

默认情况下,Spring Security 将凭证存储在内存中。重启应用,所有 passkey 都会丢失。在生产环境中,你需要实现两个接口的 bean:

  • UserCredentialRepository:存储公钥、credential ID 和签名计数。
  • PublicKeyCredentialUserEntityRepository:将你的用户与 WebAuthn 用户句柄映射。

较新的 Spring Security 版本为这两个接口提供了 JDBC 实现。你也可以用 JPA 自己实现。在这种情况下,每个凭证应该是单独的记录,因为一个用户通常在多个设备上有多个 passkey。

6. 浏览器端

如果使用默认登录页面,Spring Security 已为你处理 JavaScript。对于自定义界面,登录流程如下所示:

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // 1. 从服务器请求 challenge
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // 2. 浏览器显示对话框,authenticator 对 challenge 签名
  const credential = await navigator.credentials.get({ publicKey: options });

  // 3. 将签名发回服务器验证
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

代码中各步的含义:

  • 步骤 1 获取 challenge。请求必须包含 CSRF token,因为这是 POST。
  • parseRequestOptionsFromJSON 将 base64url 字段转换为 ArrayBuffer。旧浏览器可能不支持此函数。你需要一个辅助库或自行解码。
  • 步骤 3 将结果发送回服务器。JSON 格式必须与服务器期望的格式匹配。Spring Security 默认登录页面中的脚本是最好的参考。

7. 测试

你无法在单元测试中轻松模拟真实的 authenticator。但你仍然可以测试服务器端的重要部分:验证端点是否正常工作,以及每次调用是否返回不同的 challenge。

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester(Spring Framework 6.2+)允许使用 AssertJ 风格的断言。csrf() 来自 SecurityMockMvcRequestPostProcessors。第一个测试确认 challenge 不被重用,这正是抗重放机制。第二个测试确保 rpId 配置正确。这是部署时最常见的配置错误。

要进行端到端测试,请使用浏览器的虚拟 authenticator。Chrome DevTools 有 WebAuthn 标签页,WebDriver/Playwright 也支持此功能。

8. 常见错误和何时不应使用

8.1. 常见错误

  • rpId 或 allowedOrigins 配置错误。 在 rpId 中写入 https://,或在 origin 中遗漏 port,会导致浏览器或服务器拒绝。你应该将这两个值放入 application.yaml,按环境区分。
  • 不使用 HTTPS。 WebAuthn 仅在安全上下文中运行。localhost 是唯一的例外。
  • 更改域名。 Passkey 与 RP ID 绑定。如果更改主要域名,所有旧的 passkey 将无法使用。
  • 在生产环境使用内存存储。 重启应用,用户就失去了 passkey。
  • 只允许注册一个 passkey。 用户需要在多个设备上拥有多个 passkey。提供列表页面并允许逐个删除。

注意: Passkey 只有在账户恢复流程同样强大的情况下才是强大的。如果“忘记 passkey”只需通过电子邮件发送链接,攻击者会转而攻击电子邮件。

8.2. 何时不应使用(或不应单独使用)

  • 用户使用旧设备或共享设备,例如柜台电脑或信息亭。此时需要备用方法。
  • 系统不基于浏览器或应用,例如服务间通信、CLI 或后台作业。这些应该使用 OAuth2 客户端凭证、mTLS 或 API 密钥。
  • 合规要求仅接受硬件密钥。 同步 passkey 可能不符合要求。你需要设备绑定的 passkey 和证明验证。
  • 团队还没有账户恢复流程。 你应该先将 passkey 作为可选方法推出,然后再考虑弃用密码。

9. 总结

Passkey 用与域名绑定的公钥对替代共享秘密(密码)。这样,passkey 在一个机制内解决了钓鱼、凭证填充和数据库泄露风险。在 Spring Security 中,你只需将 webAuthn() 添加到 SecurityFilterChain,正确配置 rpId 和 allowedOrigins,然后将内存凭证存储替换为持久存储。

最安全的实现方法是循序渐进:允许用户将 passkey 添加到现有账户、设计严格的账户恢复流程,然后才考虑禁用密码登录。

passkeywebauthnspring-security安全技术趋势