传统密码登录存在密码复用、凭证泄露和钓鱼攻击等问题。Passkey 则让我们通过指纹、面容识别或设备 PIN 完成登录,而不需要向服务器提交密码。
这篇文章分成两部分:先理解 WebAuthn 是什么、为什么安全,再结合我在 Batcher 项目中的实现,介绍 Angular + Spring Boot 如何完成 Passkey 注册和登录。
第一部分:WebAuthn 理论
1. WebAuthn 是什么?
WebAuthn(Web Authentication)是 W3C 制定的 Web 身份认证标准,也是 FIDO2 体系的一部分。它允许网站通过浏览器调用认证器(Authenticator),使用公钥密码学注册和验证身份。
认证器可以是 Mac 的 Touch ID、iPhone 的 Face ID、Windows Hello、硬件安全密钥,或支持 Passkey 的密码管理器。
传统密码认证依赖服务器验证用户提交的秘密;WebAuthn 则依赖认证器使用私钥签名,服务器使用此前登记的公钥验证签名。
1 2
| 传统密码:用户输入密码 → 服务器验证密码 WebAuthn:服务器发送挑战 → 认证器用私钥签名 → 服务器用公钥验证
|
2. WebAuthn 与 Passkey 的关系
- WebAuthn:浏览器与服务端进行公钥注册和认证的标准/API。
- FIDO2:包含 WebAuthn 与 CTAP 等协议的技术体系。
- Authenticator:负责创建或使用凭证密钥的认证器。
- Passkey:面向用户的 FIDO 无密码登录凭证及体验,可以是设备绑定的,也可以通过支持的密码管理器同步。
- Relying Party(RP):使用 WebAuthn 进行身份认证的网站。
注册时,浏览器可能让用户选择 Apple Passwords、Microsoft Password Manager、手机或安全密钥等位置。选择不同的凭证提供者,并不改变服务器验证签名的基本机制。
3. 公钥和私钥如何工作?
注册时,认证器创建一组密钥:
1 2 3
| 认证器创建密钥对 ├── 私钥:由认证器或凭证提供者保护,不交给网站服务器 └── 公钥:注册成功后由服务器保存
|
登录时,服务器发送随机 Challenge,认证器使用私钥签名,服务器用公钥验证。
1 2
| signature = Sign(privateKey, message) valid = Verify(publicKey, message, signature)
|
这只是概念表达。WebAuthn 实际签名的数据是:
1
| authenticatorData || SHA256(clientDataJSON)
|
clientDataJSON 包含 Challenge、Origin、操作类型等信息;authenticatorData 包含 RP ID Hash、用户验证标志、签名计数器等数据。
为什么攻击者不能只拿公钥登录? 因为公钥可以验证签名,但不能直接产生与对应私钥等效的有效签名。服务器即使泄露公钥,也不等于泄露了用户的私钥。
4. Challenge 为什么必不可少?
如果每次登录都对固定内容签名,攻击者可能重放此前截获的有效认证响应。Challenge 是服务器为本次操作生成的一次性随机值,使每次认证需要签名的数据不同。
在 Demo 中使用 Java SecureRandom 生成 32 字节随机数:
1 2
| byte[] challenge = new byte[32]; secureRandom.nextBytes(challenge);
|
服务器还会保存操作类型、用户名、过期时间,并在验证时一次性消费 Challenge。当前实现设置 5 分钟有效期。
5. RP ID 与 Origin
WebAuthn 的防钓鱼能力不仅来自签名,还来自浏览器和服务端对网站身份的校验。
- RP ID 通常是域名,Demo 中为
localhost。
- Origin 包括协议、主机和端口,Demo 中为
http://localhost:4200。
1 2
| private static final String RP_ID = "localhost"; private static final String ORIGIN = "http://localhost:4200";
|
Angular 页面运行在 http://localhost:4200,Spring Boot API 运行在 http://localhost:8080。尽管请求发送到后端,WebAuthn 客户端操作的 Origin 仍是 Angular 页面的来源。
CORS 与 WebAuthn Origin 验证不同:前者控制跨源 HTTP 访问,后者参与身份认证安全校验。正式部署通常需要 HTTPS;localhost 是允许本地开发的特殊情况。
6. 指纹和 Face ID 会发送给服务器吗?
不会。指纹、面容识别或 PIN 通常用于在本地授权认证器使用私钥。服务器收到的是公钥凭证注册数据或数字签名认证结果,而不是生物识别原始数据。
7. 注册与登录的完整流程
注册(Registration):
1 2 3 4 5 6 7 8 9 10 11 12 13
| Angular Spring Boot Authenticator | 请求注册 Challenge | | |----------------------->| | | 返回 CreationOptions | | |<-----------------------| | | startRegistration() | |--------------------------------------------------->| | 创建密钥对、用户授权 | |<---------------------------------------------------| | 提交 Credential | | |----------------------->| | | 验证并保存公钥凭证 | |<-----------------------| |
|
登录(Authentication):
1 2 3 4 5 6 7 8 9 10 11 12 13
| Angular Spring Boot Authenticator | 请求登录 Challenge | | |----------------------->| | | 返回 RequestOptions | | |<-----------------------| | | startAuthentication() | |--------------------------------------------------->| | 用户授权、私钥签名 | |<---------------------------------------------------| | 提交 Assertion | | |----------------------->| | | 使用公钥验证签名 | |<-----------------------| |
|
第二部分:Angular + Spring Boot 实践
8. 技术栈和项目结构
本次实现使用:Angular 22、Angular Material、@simplewebauthn/browser 14、Spring Boot、WebAuthn4J 0.32.0.RELEASE,并暂时使用内存 DAO 保存 Challenge 和 Credential。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| com.github.damingerdai.batcher ├── api/WebAuthnController.java ├── config/WebAuthnConfig.java ├── dao/IWebAuthnDao.java ├── dao/impl/InMemoryWebAuthnDao.java ├── pojo/ChallengePurpose.java ├── pojo/PendingChallenge.java ├── pojo/StoredCredential.java ├── service/IWebAuthnService.java └── service/impl/WebAuthnServiceImpl.java
ui/src/app ├── core/services/webauthn.service.ts └── routes/passkey/ ├── passkey.component.ts ├── passkey.component.html ├── passkey.component.scss └── passkey.routes.ts
|
Controller 负责 HTTP 请求;Service 负责注册、登录及协议验证;DAO 负责数据存储。这样的分层也方便以后接入 PostgreSQL 和 Redis。
9. Spring Boot 配置 WebAuthn4J
在 build.gradle 中引入:
1
| implementation 'com.webauthn4j:webauthn4j-core:0.32.0.RELEASE'
|
配置 WebAuthnManager:
1 2 3 4 5 6 7
| @Configuration public class WebAuthnConfig { @Bean public WebAuthnManager webAuthnManager() { return WebAuthnManager.createNonStrictWebAuthnManager(); } }
|
这里的 NonStrict 不等于不验证签名。它主要涉及注册时对认证器 Attestation 信任链的验证策略;普通 Passkey 应用通常不强制要求设备厂商证明。
10. Challenge 和 Credential 模型
PendingChallenge 保存用户、用途、随机挑战值和过期时间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public record PendingChallenge( String username, ChallengePurpose purpose, byte[] challenge, Instant expiresAt ) { public PendingChallenge { challenge = challenge.clone(); }
@Override public byte[] challenge() { return challenge.clone(); } }
|
ChallengePurpose 区分 REGISTER 和 LOGIN,避免混用挑战值。
1 2 3 4
| public enum ChallengePurpose { REGISTER, LOGIN }
|
StoredCredential 保存用户、Credential ID 和 WebAuthn4J 的凭证记录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public record StoredCredential( String username, byte[] credentialId, CredentialRecord credentialRecord ) { public StoredCredential { credentialId = credentialId.clone(); }
@Override public byte[] credentialId() { return credentialId.clone(); } }
|
在 InMemoryWebAuthnDao 中,使用 ConcurrentHashMap 保存数据:
1 2 3 4 5 6 7 8 9
| private final Map<String, PendingChallenge> challenges = new ConcurrentHashMap<>();
private final Map<String, StoredCredential> credentials = new ConcurrentHashMap<>();
public Optional<PendingChallenge> consumeChallenge(String requestId) { return Optional.ofNullable(challenges.remove(requestId)); }
|
remove() 能原子地消费 Challenge;putIfAbsent() 可以防止同一个 Credential ID 被重复注册。应用重启后内存数据会丢失,因此这只适合 Demo。
11. 服务端生成注册选项
WebAuthnServiceImpl 先生成 Challenge:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| private String createChallenge(String username, ChallengePurpose purpose) { byte[] challenge = new byte[32]; secureRandom.nextBytes(challenge);
String requestId = UUID.randomUUID().toString(); webAuthnDao.saveChallenge( requestId, new PendingChallenge( username, purpose, challenge, Instant.now().plusSeconds(300) ) ); return requestId; }
|
然后返回注册选项,结构如下(省略了外层 Java Map.of 代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| { "requestId": "generated-request-id", "publicKey": { "challenge": "base64url-challenge", "rp": { "id": "localhost", "name": "Passkey Demo" }, "user": { "id": "base64url-user-id", "name": "arthur", "displayName": "arthur" }, "pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ], "authenticatorSelection": { "userVerification": "required", "residentKey": "preferred" }, "attestation": "none", "timeout": 60000 } }
|
-7 是 ES256,-257 是 RS256;userVerification: required 要求认证器进行用户验证;residentKey: preferred 表示优先支持可发现凭证。
生产环境注意:示例中将用户名的 UTF-8 字节编码为 WebAuthn User ID,只是为了简化演示。正式应用应使用稳定、唯一且不暴露个人信息的内部用户标识。
12. 后端验证注册响应
浏览器返回注册 Credential 后,服务端先消费 Challenge,然后交给 WebAuthn4J 解析和验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| PendingChallenge pending = consumeChallenge( requestId, username, ChallengePurpose.REGISTER );
RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(credentialJson);
RegistrationParameters parameters = new RegistrationParameters( serverProperty(pending.challenge()), null, true, true );
webAuthnManager.verify(registrationData, parameters);
|
服务端安全上下文由 ServerProperty 提供:
1 2 3 4 5 6 7
| private ServerProperty serverProperty(byte[] challenge) { return ServerProperty.builder() .origin(new Origin("http://localhost:4200")) .rpId("localhost") .challenge(new DefaultChallenge(challenge)) .build(); }
|
验证成功后,从 registrationData 提取 Credential ID,创建 CredentialRecordImpl,再通过 DAO 保存公钥凭证记录。
13. 后端生成并验证登录请求
登录前先查询该用户的凭证,构造 allowCredentials,返回新的 Challenge:
1 2 3 4 5 6 7 8 9 10 11 12
| { "requestId": "generated-request-id", "publicKey": { "challenge": "base64url-challenge", "rpId": "localhost", "allowCredentials": [ { "type": "public-key", "id": "base64url-credential-id" } ], "userVerification": "required", "timeout": 60000 } }
|
浏览器返回 Authentication Response 后,后端根据 Credential ID 查询已保存的凭证,并确认它属于当前用户:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(credentialJson);
StoredCredential storedCredential = webAuthnDao .findCredential(authenticationData.getCredentialId()) .orElseThrow(() -> new IllegalArgumentException("Credential not found"));
if (!storedCredential.username().equals(username)) { throw new IllegalArgumentException("Credential does not belong to user"); }
AuthenticationParameters parameters = new AuthenticationParameters( serverProperty(pending.challenge()), storedCredential.credentialRecord(), List.of(storedCredential.credentialId()), true, true );
webAuthnManager.verify(authenticationData, parameters);
|
这一步验证成功,代表 WebAuthn 认证响应通过了服务端检查,但还不代表应用已经建立 Session。
14. REST API 设计
Controller 提供四个端点,统一前缀为 /api/auth/webauthn:
| 方法 |
路径 |
作用 |
| POST |
/register/challenge |
获取注册选项 |
| POST |
/register/verify |
验证注册结果 |
| POST |
/login/challenge |
获取登录选项 |
| POST |
/login/verify |
验证登录结果 |
请求 DTO:
1 2 3 4 5 6 7
| public record BeginRequest(String username) {}
public record VerifyRequest( String username, String requestId, JsonNode credential ) {}
|
本项目使用 Jackson 3 的 tools.jackson.databind.JsonNode 和 ObjectMapper。Controller 将 Credential JSON 序列化为字符串后传给 WebAuthn4J。不要混用 Jackson 2 的 com.fasterxml.jackson.databind.ObjectMapper 类型。
15. Angular 集成 SimpleWebAuthn
在 ui 目录安装:
1
| npm install @simplewebauthn/browser
|
使用 @Injectable() 定义 Angular Service,并显式导入 SimpleWebAuthn 的 JSON 类型,避免与 DOM 内置同名类型冲突:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { startRegistration, startAuthentication, browserSupportsWebAuthn, } from '@simplewebauthn/browser'; import type { PublicKeyCredentialCreationOptionsJSON as RegistrationOptions, PublicKeyCredentialRequestOptionsJSON as AuthenticationOptions, } from '@simplewebauthn/browser';
interface ChallengeResponse<T> { requestId: string; publicKey: T; }
interface WebAuthnResult { status: string; message: string; }
@Injectable({ providedIn: 'root' }) export class WebauthnService { private readonly http = inject(HttpClient); private readonly baseUrl = 'http://localhost:8080/api/auth/webauthn';
isSupported(): boolean { return browserSupportsWebAuthn(); }
async register(username: string): Promise<WebAuthnResult> { const { requestId, publicKey } = await firstValueFrom( this.http.post<ChallengeResponse<RegistrationOptions>>( `${this.baseUrl}/register/challenge`, { username } ) );
const credential = await startRegistration({ optionsJSON: publicKey });
return firstValueFrom(this.http.post<WebAuthnResult>( `${this.baseUrl}/register/verify`, { username, requestId, credential } )); }
async login(username: string): Promise<WebAuthnResult> { const { requestId, publicKey } = await firstValueFrom( this.http.post<ChallengeResponse<AuthenticationOptions>>( `${this.baseUrl}/login/challenge`, { username } ) );
const credential = await startAuthentication({ optionsJSON: publicKey });
return firstValueFrom(this.http.post<WebAuthnResult>( `${this.baseUrl}/login/verify`, { username, requestId, credential } )); } }
|
startRegistration() 和 startAuthentication() 是整个 Angular 实现的核心。它们封装了原生 navigator.credentials.create() / get() 及 ArrayBuffer、Base64URL、JSON 之间的转换。
SSR 提醒:Angular SSR 环境没有 window、document 等浏览器全局对象。实际项目中应通过 isPlatformBrowser() 或 afterNextRender() 在客户端检测 WebAuthn 支持情况,而不是在服务端渲染阶段直接执行浏览器 API。
16. Angular Material 页面
页面只需要一个用户名输入框、两个按钮,以及加载、成功、失败状态。使用 Reactive Forms 和 Signals:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| readonly username = new FormControl('', { nonNullable: true, validators: [Validators.required], }); readonly loading = signal(false); readonly error = signal(''); readonly success = signal('');
async register(): Promise<void> { await this.execute(() => this.webauthnService.register(this.username.value.trim()) ); }
async login(): Promise<void> { await this.execute(() => this.webauthnService.login(this.username.value.trim()) ); }
|
模板示意:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <mat-card appearance="outlined"> <mat-card-header> <mat-card-title>Passkey Authentication</mat-card-title> </mat-card-header> <mat-card-content> <mat-form-field appearance="outline"> <mat-label>Username</mat-label> <input matInput [formControl]="username" autocomplete="username" /> </mat-form-field> <button mat-flat-button (click)="register()"> <mat-icon>fingerprint</mat-icon> Register Passkey </button> <button mat-stroked-button (click)="login()"> <mat-icon>login</mat-icon> Login with Passkey </button> </mat-card-content> </mat-card>
|
实际页面应禁用重复提交,并通过 loading()、error()、success() 显示对应状态。注意不要在认证失败后仍然执行页面跳转;应只在服务端确认认证成功后继续后续流程。
17. 本地测试
1 2 3 4
| Angular: http://localhost:4200 Spring Boot: http://localhost:8080 RP ID: localhost Origin: http://localhost:4200
|
- 输入用户名
arthur,点击 Register Passkey。
- 在浏览器弹出的界面中选择 Apple Passwords、Microsoft Password Manager 或其他认证器,完成注册。
- 再次输入相同用户名,点击 Login with Passkey。
- 认证器完成私钥签名,Spring Boot 使用保存的公钥凭证验证响应。
成功时,后端返回类似:
1 2 3 4
| { "status": "success", "message": "Passkey authentication successful" }
|
这里返回的是WebAuthn 验证结果,不是 JWT 或已建立的登录 Session。
18. 从 Demo 到生产环境还差什么?
当前实现主要用于学习,正式使用前至少需要完善以下方面:
- 持久化:用 PostgreSQL 保存 Credential,用具备 TTL 和原子消费能力的存储管理 Challenge;内存 DAO 重启即丢失数据。
- 可信用户绑定:不能允许任何人仅凭用户名为现有账号注册 Passkey。必须先完成账号所有权验证,并从可信上下文获取用户 ID。
- 会话管理:WebAuthn 验证成功后,需要与 Spring Security、Session 或 Token 机制集成。
- 凭证状态更新:维护签名计数器和备份状态;可同步 Passkey 的计数器不一定严格递增。
- 凭证管理:支持查看、添加、撤销多个 Passkey,并设计安全的账号恢复机制。
- 异常与安全策略:限制滥用、处理重复注册和过期 Challenge、保护认证接口并增加测试。
另外,删除 Apple Passwords 中的 localhost Passkey 不会自动删除服务器保存的 Credential;删除服务端 Credential 也不会自动删除设备中的凭证。
总结
WebAuthn 的核心不是指纹识别,而是公钥密码学与浏览器对网站身份的绑定。指纹、Face ID 和 PIN 通常只是本地授权使用私钥的方式。
1 2
| 注册:认证器生成密钥对 → 服务器验证并保存公钥凭证 登录:服务器生成 Challenge → 认证器私钥签名 → 服务器公钥验证
|
Angular 负责获取 Challenge、调用认证器并提交结果;Spring Boot 负责生成 Challenge、验证响应及管理 Credential;SimpleWebAuthn 和 WebAuthn4J 则封装了底层协议细节。
通过这个 Demo,我们完成了最基本的 Passkey 注册和认证闭环。下一步可以继续接入 Spring Security、数据库持久化和正式的账号体系。
参考资料
本文示例基于 Batcher 项目的 WebAuthn 实验代码,用于原理学习与技术演示,不应直接视为生产级认证方案。