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)
- 服务器生成一个随机的 challenge。服务器将 challenge 连同 RP 和用户信息发送给浏览器。
- 浏览器调用
navigator.credentials.create()。Authenticator 为该 RP ID 生成新的密钥对。 - 浏览器向服务器发送公钥、credential ID 和签名数据。
- 服务器验证 challenge 和 origin,然后保存公钥。
3.2. 登录(authentication)
- 服务器生成新的 challenge。
- 浏览器调用
navigator.credentials.get()。Authenticator 使用私钥对 challenge 进行签名。 - 服务器使用已保存的公钥验证签名。
核心部分是“用私钥签名,用公钥验证”。下面的纯 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 登录”按钮。试验流程包括四个步骤:
- 用
minh/matkhau-tam登录。 - 打开
/webauthn/register并创建 passkey。 - 登出。
- 用 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 添加到现有账户、设计严格的账户恢复流程,然后才考虑禁用密码登录。