Spring Security 的通行密钥(passkey)凭证默认存在内存里,应用一重启,浏览器里那把通行密钥再去登录就是 401。这个默认值从 6.4 引入通行密钥时就是这样,7.1.1 里也没变。把 JdbcPublicKeyCredentialUserEntityRepository 和 JdbcUserCredentialRepository 声明成 bean 后,重启照样能登。
下面在 Spring Boot 4.1.1(Spring Security 7.1.1)上实测。启用通行密钥只要加 spring-security-webauthn 依赖,再在 HttpSecurity 上写一段 webAuthn(...),默认登录页就会多出 Sign in with a passkey 按钮。建表脚本在 spring-security-web 包里,原样交给 spring.sql.init 的话,第二次启动会报表已存在。
和密码一起做两步认证也能跑通。@EnableMultiFactorAuthentication 无条件要求 FACTOR_WEBAUTHN 时,只登录了密码的用户连注册页都进不去,要加上 when = MultiFactorCondition.WEBAUTHN_REGISTERED 才行。
文中用到的几样东西来自不同版本:通行密钥和内存存储是 6.4 加的,两个 JDBC 实现是 6.5 加的,@EnableMultiFactorAuthentication 是 7.0 加的,MultiFactorCondition.WEBAUTHN_REGISTERED 和 AllRequiredFactorsAuthorizationManager.anyOf 是 7.1 才有的。
整个过程没有真人点浏览器。认证器用的是 Chrome 的虚拟认证器:Selenium 4.43.0 驱动无头 Chrome 155.0.8059.84,通过 WebDriver 的 Virtual Authenticator 接口挂上一个认证器,配置为 CTAP2、internal 传输,支持常驻密钥和用户验证。注册和断言都由浏览器真实调用 navigator.credentials.create() 和 get() 完成。配置和类名以 Spring Security 参考文档的 Passkeys 一节 为准,再逐项跑过。
依赖和配置
环境是 OpenJDK 21.0.12.1、Maven 3.9.9、Spring Boot 4.1.1。Passkeys 的支持在单独的 spring-security-webauthn 模块里,版本由 Boot 管理,实际解析出来是 7.1.1,它会带进 com.webauthn4j:webauthn4j-core:0.31.9.RELEASE。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-webauthn</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<scope>test</scope>
</dependency>
安全配置保留表单登录,再打开 WebAuthn。rpId 是凭证绑定的域名,本地就是 localhost;allowedOrigins 要和浏览器地址栏的 origin 完全一致,包括端口。
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn(webAuthn -> webAuthn
.rpName("Spring4All Passkeys Demo")
.rpId("localhost")
.allowedOrigins("http://localhost:8080"));
return http.build();
}
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build());
}
}
为了看清当前是用哪种方式登录的,首页把用户名和权限列出来:
@RestController
class HelloController {
@GetMapping("/")
String hello(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.sorted()
.collect(Collectors.joining(", "));
return "hello " + authentication.getName() + " " + authorities;
}
}
启动后 /login 页面下方多了一块 Login with Passkeys,/webauthn/register 是默认的通行密钥管理页,要先登录才能进。新用户第一次只能先用密码登录,再到这个页面注册通行密钥。
用虚拟认证器代替真人
测试里每个用例开一个无头 Chrome,挂上虚拟认证器,再用 SpringApplication 在 8080 端口把应用拉起来。关闭上下文、再启动一次,就相当于重启应用,而浏览器和虚拟认证器一直不关,里面那把私钥还在。
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--no-sandbox", "--disable-dev-shm-usage");
options.setCapability("goog:loggingPrefs", java.util.Map.of("browser", "ALL"));
driver = new ChromeDriver(options);
VirtualAuthenticatorOptions authOptions = new VirtualAuthenticatorOptions()
.setProtocol(VirtualAuthenticatorOptions.Protocol.CTAP2)
.setTransport(VirtualAuthenticatorOptions.Transport.INTERNAL)
.setHasResidentKey(true)
.setHasUserVerification(true)
.setIsUserVerified(true);
authenticator = driver.addVirtualAuthenticator(authOptions);
setIsUserVerified(true) 表示认证器每次都当作用户已经按过指纹,不会卡在等待用户操作上。
在这个无头环境里,Selenium 的 sendKeys 和原生 click() 偶尔会丢,Passkey 按钮点了没反应、标签框是空的都遇到过,所以填表和点按钮都改成了执行 JavaScript。默认页面上的元素 id 是 username、password、passkey-signin、label、register。
private void registerPasskey(String label) {
driver.get(BASE + "/webauthn/register");
wait.until(d -> "complete".equals(((JavascriptExecutor) d).executeScript("return document.readyState")));
((JavascriptExecutor) driver).executeScript("arguments[0].value = arguments[1]",
driver.findElement(By.id("label")), label);
((JavascriptExecutor) driver).executeScript("arguments[0].click()", driver.findElement(By.id("register")));
wait.until(d -> !d.getCurrentUrl().equals(BASE + "/webauthn/register"));
}
private String clickPasskeyButton() {
String start = driver.getCurrentUrl();
WebElement button = driver.findElement(By.id("passkey-signin"));
((JavascriptExecutor) driver).executeScript("arguments[0].click()", button);
wait.until(d -> !d.getCurrentUrl().equals(start));
return driver.findElement(By.tagName("body")).getText();
}
“退出登录”用的是 driver.manage().deleteAllCookies(),会话没了,虚拟认证器里的凭证不受影响。
四个用例一起跑,测试输出开头是浏览器版本,结尾是结果:
[test] browser = chrome 155.0.8059.84
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.55 s -- in com.example.passkeys.PasskeyFlowTest
注册和登录时实际发了什么
应用里加了一个过滤器,把 /webauthn/register 下的 POST、/webauthn/authenticate 下的请求和 /login/webauthn 的请求体、响应体原样打到日志里。下面是 JDBC 用例里的一次注册。先是页面向服务端要注册参数:
2026-10-18T08:23:53.327+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-2] webauthn.traffic : POST /webauthn/register/options status=200 response={"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"ofrUW53nh4K_uzMpN2KdNKCnveCcGjKgGqTihtkthS8","excludeCredentials":[],"extensions":{"credProps":true},"pubKeyCredParams":[{"alg":-8,"type":"public-key"},{"alg":-7,"type":"public-key"},{"alg":-257,"type":"public-key"}],"rp":{"id":"localhost","name":"Spring4All Passkeys Demo"},"timeout":300000,"user":{"name":"user","id":"4XsT1iefEWb_0JxUB--y4Uk5CfF0L7PHA25kJvtdojU","displayName":"user"}}
residentKey 是 required,也就是要求认证器把凭证存成可发现凭证,登录时才能不输用户名直接选。userVerification 默认是 preferred。浏览器拿这些参数调用认证器,把结果连同标签一起提交:
2026-10-18T08:23:53.346+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-3] webauthn.traffic : POST /webauthn/register request={"publicKey":{"credential":{"id":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","rawId":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","response":{"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViBSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAQECAwQFBgcIAQIDBAUGBwgAIErTOUh4pTfLRx-TBiCMwU5smDr7WHGSSUlz6fdeDuxhpAEBAycgBiFYICz1P5I_X9GEWb4JSVFNEeVEQv6HqntA0sLpFinfPNXh","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoib2ZyVVc1M25oNEtfdXpNcE4yS2ROS0NudmVDY0dqS2dHcVRpaHRrdGhTOCIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9","transports":["internal"]},"type":"public-key","clientExtensionResults":{"credProps":{"rk":true}},"authenticatorAttachment":"platform"},"label":"chrome-virtual-authenticator"}}
2026-10-18T08:23:53.346+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-3] webauthn.traffic : POST /webauthn/register status=200 response={"success":true}
登录时同样两步,先要断言参数,再提交签名:
2026-10-18T08:23:53.883+08:00 INFO 110780 --- [passkeys-webauthn-demo] [io-8080-exec-10] webauthn.traffic : POST /webauthn/authenticate/options status=200 response={"allowCredentials":[],"challenge":"oMGBOMR1kSL4iHdI9xWfOJemgG43C44XeL2qp8Gx2P8","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
2026-10-18T08:23:53.900+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-1] webauthn.traffic : POST /login/webauthn request={"id":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","rawId":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAg","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoib01HQk9NUjFrU0w0aUhkSTl4V2ZPSmVtZ0c0M0M0NFhlTDJxcDhHeDJQOCIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0","signature":"qWKIAngCli5ldNQl9w-qwYL3CZTIvlv41v0GSjCvA0yR6yVARyWkFC66Imw4Ihiqd97jKyFA0uMfxwptbjqsAw","userHandle":"4XsT1iefEWb_0JxUB--y4Uk5CfF0L7PHA25kJvtdojU"},"credType":"public-key","clientExtensionResults":{},"authenticatorAttachment":"platform"}
2026-10-18T08:23:53.900+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-1] webauthn.traffic : POST /login/webauthn status=200 response={"authenticated":true,"redirectUrl":"/"}
登录这一步的 allowCredentials 是空数组,因为这时服务端还不知道是谁要登录,靠的就是认证器里的常驻凭证。把两次请求里的 clientDataJSON 解码出来(行首是它们在测试日志里的行号),origin 就是配置里写的 http://localhost:8080:
line 146: {"type":"webauthn.create","challenge":"ofrUW53nh4K_uzMpN2KdNKCnveCcGjKgGqTihtkthS8","origin":"http://localhost:8080","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}
line 154: {"type":"webauthn.get","challenge":"oMGBOMR1kSL4iHdI9xWfOJemgG43C44XeL2qp8Gx2P8","origin":"http://localhost:8080","crossOrigin":false}
注册时那段 other_keys_can_be_added_here 是 Chrome 有意随机塞进去的字段,提醒服务端不要按模板比对 clientDataJSON。Spring Security 照常验证通过了。
登录成功后首页显示的权限只有 FACTOR_WEBAUTHN 和角色:
[test] passkey login url = http://localhost:8080/
[test] passkey login body = hello user FACTOR_WEBAUTHN, ROLE_USER
内存存储,重启就没了
不声明任何存储 bean 时,WebAuthnConfigurer 会自己 new 一个 MapPublicKeyCredentialUserEntityRepository 和一个 MapUserCredentialRepository,两者都把数据放在进程内的 Map 里。测试里用 memory profile 把 JDBC 配置排除掉,注册、登录成功后关闭应用再启动,浏览器里的虚拟认证器原封不动,再点 Passkey 登录:
[test] ---- restart (memory) ----
2026-10-18T08:23:51.848+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-5] webauthn.traffic : POST /login/webauthn request={"id":"IzdE24pwI2Tg1mkeFJILPw-AThemUONoyEm-5KDpQ4U","rawId":"IzdE24pwI2Tg1mkeFJILPw-AThemUONoyEm-5KDpQ4U","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAw","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYmJ3VlF2aF9IbDlDTS1mSzhXNVNyQm94Zk13V1dJa1pPQi04WE5Rd0I3TSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0","signature":"tx2_TAvCk4rnN6_oAerMkUokxU7R-eDe-ee-Ym_2Aq6y5iaRP6TLSjiLQ4bNWyIX44P2g1JI7VfySVBBCWRPDg","userHandle":"_PxUnPZCHaBrTrYZPacAPUuxmX8D_5FqSx09lZMsfu8"},"credType":"public-key","clientExtensionResults":{},"authenticatorAttachment":"platform"}
2026-10-18T08:23:51.848+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-5] webauthn.traffic : POST /login/webauthn status=401 response=
[test] browser console: SEVERE http://localhost:8080/login/webauthn - Failed to load resource: the server responded with a status of 401 ()
[test] browser console: SEVERE http://localhost:8080/login/webauthn.js 205:14 Error: Authentication failed. Could not process the authentication request: HTTP 401
[test] passkey login url = http://localhost:8080/login?error
认证器照常签了名,凭证 id 和重启前注册的是同一个,服务端返回 401,页面跳到 /login?error。原因很直接:新进程的内存里没有这条凭证,也就没有公钥可以验签。真实用户遇到的情况是手机或电脑里留着一把已经失效的通行密钥。
换成 JDBC 要建哪两张表
参考文档给出的做法是把两个 JDBC 实现声明成 bean:
@Configuration(proxyBeanMethods = false)
@Profile("!memory")
public class JdbcPasskeyConfig {
@Bean
JdbcPublicKeyCredentialUserEntityRepository jdbcPublicKeyCredentialRepository(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
JdbcUserCredentialRepository jdbcUserCredentialRepository(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
}
文档这一节没有写表结构。建表脚本不在 webauthn 模块里,放在 spring-security-web-7.1.1.jar 的 org/springframework/security/ 目录下,一共三个文件:user-entities-schema.sql、user-credentials-schema.sql,以及 PostgreSQL 用的 user-credentials-schema-postgres.sql。
第一次我直接把这两个文件交给 Spring Boot 的脚本初始化,H2 用文件库:
spring.datasource.url=jdbc:h2:file:./data/passkeys
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:org/springframework/security/user-entities-schema.sql,classpath:org/springframework/security/user-credentials-schema.sql
第一次启动正常,第二次启动直接失败:
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "USER_ENTITIES" already exists; SQL statement:
create table user_entities ( id varchar(1000) not null, name varchar(100) not null, display_name varchar(200), primary key (id) ) [42101-240]
官方脚本写的是普通的 create table,spring.sql.init.mode=always 每次启动都会执行一遍,库里已经有表就报错。生产环境一般交给 Flyway 或 Liquibase 管表结构,这个问题就不存在。这里为了演示,把两个文件的内容复制到 src/main/resources/schema.sql,只改成 create table if not exists,字段一个没动:
create table if not exists user_entities
(
id varchar(1000) not null,
name varchar(100) not null,
display_name varchar(200),
primary key (id)
);
create table if not exists user_credentials
(
credential_id varchar(1000) not null,
user_entity_user_id varchar(1000) not null,
public_key blob not null,
signature_count bigint,
uv_initialized boolean,
backup_eligible boolean not null,
authenticator_transports varchar(1000),
public_key_credential_type varchar(100),
backup_state boolean not null,
attestation_object blob,
attestation_client_data_json blob,
created timestamp,
last_used timestamp,
label varchar(1000) not null,
primary key (credential_id)
);
配置也随之去掉 schema-locations,Boot 默认就会执行 schema.sql:
spring.application.name=passkeys-webauthn-demo
spring.datasource.url=jdbc:h2:file:./data/passkeys
spring.sql.init.mode=always
user_entities 存的是 WebAuthn 意义上的用户实体,id 是 Spring Security 随机生成的用户句柄,和登录名 name 分开存;user_credentials 一条记录就是一把通行密钥。注册完直接查这两张表:
[test] virtual authenticator credential: residentKey=true rpId=localhost signCount=1
[test] user_entities = [{ID=4XsT1iefEWb_0JxUB--y4Uk5CfF0L7PHA25kJvtdojU, NAME=user, DISPLAY_NAME=user}]
[test] user_credentials = [{LABEL=chrome-virtual-authenticator, SIGNATURE_COUNT=1, UV_INITIALIZED=true, BACKUP_ELIGIBLE=false, BACKUP_STATE=false, AUTHENTICATOR_TRANSPORTS=internal, PUBLIC_KEY_CREDENTIAL_TYPE=public-key, CREATED=2026-10-18 08:23:53.340776, LAST_USED=2026-10-18 08:23:53.340776}]
user_entities 的 ID 和注册参数里的 user.id 是同一个值。AUTHENTICATOR_TRANSPORTS=internal 对应虚拟认证器的 internal 传输,BACKUP_ELIGIBLE=false 是因为虚拟认证器在认证器数据里没有置备份资格位,能在设备间同步的通行密钥会把这一位置上。
然后关掉应用,用同一个 H2 文件再启动,浏览器那边继续用同一个虚拟认证器登录:
[test] ---- restart (jdbc) ----
2026-10-18T08:23:54.735+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-5] webauthn.traffic : POST /login/webauthn request={"id":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","rawId":"StM5SHilN8tHH5MGIIzBTmyYOvtYcZJJSXPp914O7GE","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAw","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTkpuc2QzWjhQbEl1U3JRNVIxVVR4MHJxeFNZLU4tTy1Ya3dtNFJ2UVl5USIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0","signature":"nxgS3lbpWO0CGMNeLfE-vxegJ4aGeH-rYrHOVQvxvrD8zVy9n2JkgaUopjicQ1BDzq7syVPL7Tb82W0eDtRUBQ","userHandle":"4XsT1iefEWb_0JxUB--y4Uk5CfF0L7PHA25kJvtdojU"},"credType":"public-key","clientExtensionResults":{},"authenticatorAttachment":"platform"}
2026-10-18T08:23:54.735+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-5] webauthn.traffic : POST /login/webauthn status=200 response={"authenticated":true,"redirectUrl":"/"}
[test] passkey login url = http://localhost:8080/
[test] passkey login body = hello user FACTOR_WEBAUTHN, ROLE_USER
[test] virtual authenticator credential: residentKey=true rpId=localhost signCount=3
[test] user_credentials after restart = [{LABEL=chrome-virtual-authenticator, SIGNATURE_COUNT=3, CREATED=2026-10-18 08:23:53.340776, LAST_USED=2026-10-18 08:23:54.734086}]
重启后登录成功。签名计数在认证器一侧是 3(注册 1 次、重启前登录 1 次、重启后登录 1 次),表里的 SIGNATURE_COUNT 也更新成了 3,LAST_USED 跟着变了。签名计数是 WebAuthn 用来发现凭证被克隆的手段,存储丢了,这个检查也就无从谈起。
和密码登录、多因素认证一起用
上面的配置里,表单登录和通行密钥是并列的两条路,用哪个都能进。之前那篇讲密码加一次性令牌两步登录的文章 用的是 @EnableMultiFactorAuthentication,把第二个因子从一次性令牌换成通行密钥,写法一样:
@Configuration(proxyBeanMethods = false)
@Profile("mfa")
@EnableMultiFactorAuthentication(authorities = {
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.WEBAUTHN_AUTHORITY })
public class MfaConfig {
}
用这个 profile 启动,用户名密码登录后马上被带到通行密钥那一步。问题是这个用户还没注册过通行密钥,想去注册页也被同样拦下:
[test] after password url = http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
[test] GET /webauthn/register with password only -> url = http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
[test] page body = Login with Passkeys Sign in with a passkey
/webauthn/register 本身也走授权规则,同样要求 FACTOR_WEBAUTHN。没有通行密钥就进不了注册页,进不了注册页就永远没有通行密钥。这个配置只适合通行密钥由别的渠道预先录入的系统。
参考文档的多因素认证一节 专门给了条件写法,只对已经注册过通行密钥的用户要求第二个因子:
@Configuration(proxyBeanMethods = false)
@Profile("mfa-registered")
@EnableMultiFactorAuthentication(authorities = {
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.WEBAUTHN_AUTHORITY },
when = MultiFactorCondition.WEBAUTHN_REGISTERED)
public class MfaWhenRegisteredConfig {
}
这个条件要查用户有没有凭证,所以容器里必须有 PublicKeyCredentialUserEntityRepository 和 UserCredentialRepository 两个 bean,这里用的就是上面的 JDBC 版本。跑一遍:
[test] before registration, after password url = http://localhost:8080/
[test] page body = hello user FACTOR_PASSWORD, ROLE_USER
[test] after register url = http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
[test] page body = Login with Passkeys Sign in with a passkey
[test] passkey login url = http://localhost:8080/webauthn/register?success&continue
[test] GET / -> hello user FACTOR_PASSWORD, FACTOR_WEBAUTHN, ROLE_USER
没注册之前,密码登录直接进首页,只有 FACTOR_PASSWORD。注册请求一成功,同一个会话立刻就满足了条件,跳回注册页时被要求补通行密钥,这时点 Passkey 按钮,服务端返回的 redirectUrl 是原来要去的页面:
2026-10-18T08:23:56.908+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-8] webauthn.traffic : POST /webauthn/authenticate/options status=200 response={"allowCredentials":[{"id":"M6cLp9RiDv5Sjad_5bMIi_jl1VkrZxTSjrTUgZTKedo","transports":["internal"],"type":"public-key"}],"challenge":"HMCeUWIagrgcepNsq3qbVzT8dviT9YrtzPa1GB0YWHE","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
2026-10-18T08:23:56.924+08:00 INFO 110780 --- [passkeys-webauthn-demo] [nio-8080-exec-9] webauthn.traffic : POST /login/webauthn status=200 response={"authenticated":true,"redirectUrl":"http://localhost:8080/webauthn/register?success&continue"}
这一次断言参数里的 allowCredentials 不再是空的,因为用户已经用密码登录过,服务端知道是谁,直接列出了他的凭证。补完这一步,权限里同时有了 FACTOR_PASSWORD 和 FACTOR_WEBAUTHN。
换一个新会话再用密码登录,就是完整的两步流程了:
[test] ---- new session ----
[test] after registration, after password url = http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
[test] passkey login url = http://localhost:8080/?continue
[test] passkey login body = hello user FACTOR_PASSWORD, FACTOR_WEBAUTHN, ROLE_USER
如果想让通行密钥单独就够、密码则必须配一次性令牌,可以用多因素认证那一节的 AllRequiredFactorsAuthorizationManager.anyOf。
用到的版本:Spring Boot 4.1.1、Spring Security 7.1.1、webauthn4j-core 0.31.9.RELEASE、H2 2.4.240、Selenium 4.43.0、Chrome 155.0.8059.84。虚拟认证器的接口说明见 Selenium 文档的 Virtual Authenticator 页面,协议细节见 W3C WebAuthn Level 3。
一起交流
分享你的思考,让讨论更进一步。