无密码登录安全性对比:FingerprintJS、WebAuthn 与传统密码
在当前的威胁环境下,单纯依赖用户名 + 密码的认证方式已显疲态。我们对比了三种主流方案:用户名 + FingerprintJS(设备指纹)、用户名 + 密码以及用户名 + WebAuthn(Passkey / FIDO2)。
这三种模式都支持'用户名 + 凭证'的组合,实际场景中用户可自由选择。下表基于真实世界的攻击场景(如钓鱼、中间人、重放等)进行维度评估。
| 维度 | 用户名 + 密码登录 | 用户名 + FingerprintJS 无密码登录 | 用户名 + WebAuthn 无密码登录(Passkey) | 安全性排名 |
|---|---|---|---|---|
| 凭证窃取风险 | 高(易被键盘记录或钓鱼页窃取) | 中低(需访问真实站点,但 JS 数据可被读取) | 极低(私钥永不离开设备) | WebAuthn > FingerprintJS > 密码 |
| 钓鱼抵抗力 | 差(用户易误入假站输入密码) | 差(假站同样能采集指纹并重放) | 极强(RP ID/Origin 绑定,假站无效) | WebAuthn >> 其他 |
| 中间人攻击抵抗 | 差(HTTPS 下仍可能后端弱加密) | 中(数据传输无强加密绑定) | 极强(公钥加密 + Origin 校验) | WebAuthn >> 其他 |
| 凭证重放攻击 | 高(密码泄露后可无限使用) | 中(可加 nonce 缓解,但非原生防重放) | 极低(Challenge 随机 + Counter 机制) | WebAuthn > FingerprintJS > 密码 |
| 克隆/复制设备 | 高(知道密码即可在任何设备登录) | 中高(高级攻击者可伪造指纹) | 低(私钥绑定硬件,极难克隆) | WebAuthn > FingerprintJS > 密码 |
| XSS 攻击下泄露 | 高(密码框易被脚本窃取) | 高(localStorage 中的密钥易读) | 低(私钥不暴露给 JS 存储) | WebAuthn > 其他 |
| 肩窥/键盘记录 | 高(需手动输入) | 低(无需输入) | 低(仅需生物识别/PIN) | FingerprintJS ≈ WebAuthn > 密码 |
| 用户体验 | 中(需记忆复杂密码) | 低(几乎无缝) | 低–中(首次注册稍繁琐,后续一键) | FingerprintJS ≈ WebAuthn > 密码 |
| 隐私影响 | 低(仅存 Hash) | 高(跨站追踪风险大,GDPR 敏感) | 中(有限设备信息泄露) | 密码 > WebAuthn > FingerprintJS |
| NIST/OWASP 推荐 | AAL1–AAL2 | AAL2(勉强) | AAL3(最高,抗钓鱼 MFA) | WebAuthn >> 其他 |
场景建议
- 高价值目标(金融、企业内网):首选 WebAuthn。这是目前唯一真正抗钓鱼的方案,符合 NIST AAL3 标准。
- 中价值目标(电商、SaaS):推荐 WebAuthn,FingerprintJS 仅作为辅助风控手段,不建议单独作为唯一认证方式。
- 低价值/高便利需求(论坛、内容站):可使用 FingerprintJS 或传统密码,但需知晓其安全性明显弱于 WebAuthn。
- 最差组合:仅依赖 用户名 + FingerprintJS。极易受到高级钓鱼和 XSS 组合攻击,且存在严重隐私隐患。
为什么选择 WebAuthn?
WebAuthn 是浏览器原生 API,允许网站使用公钥凭证来安全认证用户身份。它支持无密码登录、生物识别(指纹/面部)、设备 PIN 或硬件安全密钥(如 YubiKey),彻底取代了传统的密码 + 短信验证码模式。
它是 FIDO2 框架的核心组成部分(FIDO2 = WebAuthn + CTAP2)。
解决传统密码痛点
传统用户名 + 密码面临以下致命问题:
- 易被钓鱼:用户在假冒网站输入密码。
- 易被泄露:数据库拖库、键盘记录器、肩窥。
- 易被重放:密码一旦泄露,攻击者可在任意设备无限使用。
- 体验差:用户需要记住复杂密码,重置成本高。
WebAuthn 通过公钥密码学 + 设备绑定解决了这些问题:
- 私钥永不离开设备:服务器只存储公钥。
- Origin/RP ID 绑定:假网站无法使用你在真实站点注册的凭证(强防钓鱼)。
- Challenge + 签名:每次认证使用随机挑战,防止重放。
- Counter 机制:防止同一凭证被克隆使用。
- 生物识别支持:无需输入密码,提升体验。
必要环境配置
| 环境 | 开发阶段可接受值 | 生产环境必须值 | 备注 |
|---|---|---|---|
| 协议 | http://localhost | https:// | 浏览器强制要求 |
| 域名(RP ID) | localhost | 你的真实域名(example.com) | 不能是 IP 或 127.0.0.1 |
| 端口 | 任意(5500、8080 等) | 443(标准 HTTPS) | 端口不影响,但必须 HTTPS |
下图展示了详细的序列图,包含所有技术细节:Challenge 生成、浏览器 API 调用、后端具体验证点(Challenge 匹配、签名验证、Counter 检查等)。假设使用 webauthn4j 后端库。
代码实现
这是一个前后端分离的完整示例,后端使用 Spring Boot,前端采用单文件 HTML 风格。项目结构如下:
webauthn-demo/
├── backend/ # Spring Boot 项目
│ ├── src/main/java/...
│ ├── pom.xml
│ └── ...
└── frontend/
└── index.html # 单文件前端
后端:Spring Boot + webauthn4j
核心依赖 (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>webauthn-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>webauthn-demo</name>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<webauthn4j.version>0.28.0.RELEASE</webauthn4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-core</artifactId>
<version>${webauthn4j.version}</version>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-util</artifactId>
<version>${webauthn4j.version}</version>
</dependency>
<!-- 生产环境建议使用 Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置类:WebAuthnConfig.java
package com.example.webauthndemo.config;
import com.webauthn4j.WebAuthnManager;
import com.webauthn4j.converter.jackson.ObjectConverter;
import com.webauthn4j.data.RelyingPartyIdentity;
import com.webauthn4j.validator.WebAuthnValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebAuthnConfig {
@Bean
public RelyingPartyIdentity relyingPartyIdentity() {
return RelyingPartyIdentity.builder()
.id("localhost") // 开发时用 localhost,生产换成你的域名
.name("WebAuthn Demo")
.build();
}
@Bean
public WebAuthnManager webAuthnManager() {
return new WebAuthnManager();
}
@Bean
public WebAuthnValidator webAuthnValidator() {
return new WebAuthnValidator();
}
@Bean
public ObjectConverter objectConverter() {
return new ObjectConverter();
}
}
简易内存存储:InMemoryCredentialRepository.java
package com.example.webauthndemo.repository;
import com.webauthn4j.credential.CredentialRecord;
import com.webauthn4j.data.ByteArray;
import com.webauthn4j.data.PublicKeyCredentialDescriptor;
import com.webauthn4j.data.PublicKeyCredentialType;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Component
public class InMemoryCredentialRepository {
private final Map<ByteArray, List<CredentialRecord>> credentials = new ConcurrentHashMap<>();
public void save(CredentialRecord credentialRecord) {
ByteArray userHandle = credentialRecord.getUserHandle();
credentials.computeIfAbsent(userHandle, k -> new ArrayList<>()).add(credentialRecord);
}
public List<CredentialRecord> findByUserHandle(ByteArray userHandle) {
return credentials.getOrDefault(userHandle, Collections.emptyList());
}
public Optional<CredentialRecord> findByCredentialId(ByteArray credentialId) {
return credentials.values().stream()
.flatMap(List::stream)
.filter(c -> c.getCredentialId().equals(credentialId))
.findFirst();
}
public Set<PublicKeyCredentialDescriptor> getCredentialDescriptors(ByteArray userHandle) {
List<CredentialRecord> records = findByUserHandle(userHandle);
return records.stream()
.map(r -> new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY,
r.getCredentialId(),
r.getTransports()))
.collect(Collectors.toSet());
}
}
Controller:WebAuthnController.java
package com.example.webauthndemo.controller;
import com.example.webauthndemo.repository.InMemoryCredentialRepository;
import com.webauthn4j.WebAuthnManager;
import com.webauthn4j.converter.jackson.ObjectConverter;
import com.webauthn4j.credential.CredentialRecord;
import com.webauthn4j.data.*;
import com.webauthn4j.data.client.challenge.DefaultChallenge;
import com.webauthn4j.server.ServerProperty;
import com.webauthn4j.validator.WebAuthnValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/webauthn")
@RequiredArgsConstructor
public class WebAuthnController {
private final InMemoryCredentialRepository credentialRepository;
private final WebAuthnManager webAuthnManager;
private final WebAuthnValidator webAuthnValidator;
private final ObjectConverter objectConverter;
private static final String RP_ID = "localhost";
private static final String ORIGIN = "http://localhost:5500";
// 1. 开始注册
@PostMapping("/register/start")
public ResponseEntity<Map<String, Object>> startRegistration() {
Challenge challenge = new DefaultChallenge();
PublicKeyCredentialCreationOptions options = new PublicKeyCredentialCreationOptions(
new RelyingPartyIdentity(RP_ID, "WebAuthn Demo"),
new UserIdentity(new ByteArray(UUID.randomUUID().toString().getBytes()), "testuser", "测试用户"),
challenge,
List.of(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256)),
null,
new AuthenticatorSelectionCriteria(null, true, UserVerificationRequirement.PREFERRED),
null, null, null
);
Map<String, Object> json = objectConverter.getJsonConverter().writeValueAsMap(options);
return ResponseEntity.ok(json);
}
// 2. 完成注册
@PostMapping("/register/finish")
public ResponseEntity<String> finishRegistration(@RequestBody Map<String, Object> request) {
try {
PublicKeyCredential<AuthenticatorAttestationResponse, CollectedClientData> pkc =
objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class);
CredentialRecord credentialRecord = webAuthnManager.parseRegistrationResponse(pkc);
credentialRepository.save(credentialRecord);
return ResponseEntity.ok("注册成功");
} catch (Exception e) {
return ResponseEntity.badRequest().body("注册失败:" + e.getMessage());
}
}
// 3. 开始认证
@PostMapping("/authenticate/start")
public ResponseEntity<Map<String, Object>> startAuthentication() {
Challenge challenge = new DefaultChallenge();
PublicKeyCredentialRequestOptions options = new PublicKeyCredentialRequestOptions(
challenge,
60000L,
RP_ID,
credentialRepository.getCredentialDescriptors(new ByteArray("testuser".getBytes())),
UserVerificationRequirement.PREFERRED,
null
);
Map<String, Object> json = objectConverter.getJsonConverter().writeValueAsMap(options);
return ResponseEntity.ok(json);
}
// 4. 完成认证
@PostMapping("/authenticate/finish")
public ResponseEntity<String> finishAuthentication(@RequestBody Map<String, Object> request) {
try {
PublicKeyCredential<AuthenticatorAssertionResponse, CollectedClientData> pkc =
objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class);
// 构建 ServerProperty,包含当前 challenge、origin、rpId
ServerProperty serverProperty = new ServerProperty(
ORIGIN,
RP_ID,
new DefaultChallenge()
);
// 获取对应的凭证记录用于验签
CredentialRecord credentialRecord = credentialRepository.findByCredentialId(
pkc.getId()
).orElseThrow(() -> new RuntimeException("凭证不存在"));
AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponse(
pkc,
serverProperty,
credentialRecord,
null,
false
);
// 更新 counter 防止克隆
credentialRecord.setCounter(authenticationData.getAuthenticatorData().getSignCount());
credentialRepository.save(credentialRecord);
return ResponseEntity.ok("登录成功");
} catch (Exception e) {
return ResponseEntity.badRequest().body("认证失败:" + e.getMessage());
}
}
}
前端:index.html
注意:确保按钮 ID 与 JavaScript 事件监听器匹配。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>WebAuthn 前端 - Spring Boot Demo</title>
<style>
body { font-family: system-ui, sans-serif; padding: 20px; max-width: 800px; margin: auto; line-height: 1.6; }
button { margin: 10px 0; padding: 12px 24px; font-size: 16px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 6px; }
button:hover { background: #0056b3; }
pre { background: #f8f9fa; padding: 16px; border-radius: 6px; overflow: auto; max-height: 500px; font-size: 14px; }
#status { margin: 20px 0; font-weight: bold; font-size: 1.1em; color: #333; }
</style>
</head>
<body>
<h1>WebAuthn 测试(连接 Spring Boot 后端)</h1>
<p>请确保后端运行在 http://localhost:8080,前端通过 http://localhost:5500 访问</p>
<button id="registerStartBtn">开始注册 Passkey</button>
<button id="loginStartBtn">开始无密码登录</button>
<div id="status">等待操作...</div>
<pre id="output"></pre>
<script type="module">
const status = document.getElementById('status');
const output = document.getElementById('output');
const API_BASE = 'http://localhost:8080/api/webauthn';
async function fetchJson(url, method = 'POST', body = null) {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : null
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function register() {
status.textContent = "正在请求注册选项...";
try {
const optionsJSON = await fetchJson(`${API_BASE}/register/start`);
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
status.textContent = "请按照浏览器提示创建 Passkey";
const credential = await navigator.credentials.create({ publicKey: options });
status.textContent = "正在提交注册结果...";
const responseJSON = credential.toJSON();
await fetchJson(`${API_BASE}/register/finish`, 'POST', responseJSON);
status.textContent = "注册成功!";
output.textContent = JSON.stringify(responseJSON, null, 2);
} catch (err) {
status.textContent = "注册失败";
output.textContent = `${err.name}\n${err.message}`;
console.error(err);
}
}
async function login() {
status.textContent = "正在请求认证选项...";
try {
const optionsJSON = await fetchJson(`${API_BASE}/authenticate/start`);
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
status.textContent = "请按照浏览器提示验证身份";
const assertion = await navigator.credentials.get({ publicKey: options });
status.textContent = "正在提交认证结果...";
const responseJSON = assertion.toJSON();
await fetchJson(`${API_BASE}/authenticate/finish`, 'POST', responseJSON);
status.textContent = "登录成功!";
output.textContent = JSON.stringify(responseJSON, null, 2);
} catch (err) {
status.textContent = "登录失败";
output.textContent = `${err.name}\n${err.message}`;
console.error(err);
}
}
document.getElementById('registerStartBtn').addEventListener('click', register);
document.getElementById('loginStartBtn').addEventListener('click', login);
</script>
</body>
</html>
运行步骤
- 启动 Spring Boot 后端服务。
- 将前端
index.html放在本地服务器(如 VS Code Live Server),确保端口为 5500。 - 点击按钮即可体验完整的 WebAuthn 流程。
注意事项
- 当前示例使用了内存存储,生产环境必须使用 Redis 或数据库持久化 Challenge 和凭证。
- 生产环境必须启用 HTTPS,并将 RP ID 配置为真实域名。
- 务必在
finishAuthentication接口中执行完整的验证逻辑,包括 Origin、RP ID、Challenge 匹配及签名验证。
后端何时确认登录成功?
在 WebAuthn 的完整认证流程里,后端(Relying Party)在 /authenticate/finish 这一步验证通过后,才真正知道'这次登录是没问题的'。
认证流程回顾
- 前端发起登录 → 调用
/authenticate/start- 后端生成随机 Challenge + Options。
- 返回给前端(
PublicKeyCredentialRequestOptionsJSON)。
- 前端调用
navigator.credentials.get()- 浏览器弹出 PIN/指纹/面部提示。
- 用户验证成功后,生成签名(Assertion)。
- 前端拿到
PublicKeyCredential(包含 authenticatorData、signature 等)。 - 前端把
.toJSON()结果 POST 到/authenticate/finish。
- 后端收到
/authenticate/finish请求(关键时刻)- 后端执行核心验证(webauthn4j 帮你完成大部分):
- Challenge 是否匹配(防重放)。
- Origin / RP ID 是否正确(防钓鱼)。
- authenticatorData 中的标志位是否符合要求(UP、UV)。
- 签名验证:用之前注册时存储的公钥验证 signature 是否有效。
- Counter 是否递增(防克隆/重放)。
- 如果以上全部通过 → webauthn4j 的验证方法会返回成功。
- 此时后端才知道:这次认证是合法的,用户身份可信。
- 后端可以签发会话(JWT、cookie、session ID 等),完成登录。
- 后端执行核心验证(webauthn4j 帮你完成大部分):
关键结论
- 后端不是在收到前端请求时就认为登录成功。
- 而是在
finishAuthentication接口里,完成所有密码学验证后,才确认'登录没问题'。 - 如果任何一项校验失败(签名不对、counter 没增长、origin 被篡改等),webauthn4j 会抛异常,后端返回失败。
- 只有验证全部通过,后端才会签发会话 token 或设置登录状态。
前后端交互数据可以被伪造吗?
可以伪造'数据包'发送给后端,但你无法伪造'有效的签名数据'。
在 WebAuthn 的流程中,前后端交互的数据确实可以被截获、查看,甚至有人可以手动模拟一个 POST 请求。但由于密码学签名的存在,后端能瞬间识别出这些数据是'真货'还是'伪造品'。
1. 数据包的结构:看得见,改不动
当指纹识别成功后,前端发给后端的数据主要包含三部分:
- ClientDataJSON:包含 Challenge 和当前的域名(Origin)。
- AuthenticatorData:包含设备状态和签名计数器。
- Signature(签名):最关键的部分。
为什么不能伪造:签名是硬件使用私钥对前两部分内容的哈希值进行加密的结果。如果你篡改了 ClientDataJSON 里的任何一个字节(比如想把域名从 fake.com 改成 real.com),后端使用公钥解密签名时,计算出的哈希值就对不上了。
2. Challenge(挑战值):无法'预制'数据包
黑客可能会想:我先录制一段你登录成功的完整数据包,下次直接发给后端。
- 后端的防御:后端在每次登录前给出的
Challenge都是随机且唯一的。 - 结果:就像银行柜台问你'今天的暗号是什么?',你必须用私钥对'今天的暗号'签名。黑客拿着'昨天的暗号'签名数据过来,后端会发现 Challenge 不匹配,直接丢弃。
3. 浏览器的'强制诚信':拦截域名伪造
这是 WebAuthn 最特殊的地方。在前后端交互中,Origin(域名)不是由前端 JavaScript 定义的,而是由浏览器内核强制写入的。
- 伪造场景:黑客写了一个恶意脚本,试图在数据包里把
Origin字段填成你的官网域名。 - 浏览器的拦截:浏览器在调用底层硬件 API 时,会核实当前真实的访问地址。如果黑客在
evil.com上运行,浏览器传给硬件进行签名的域名永远只能是evil.com。 - 后端的最后把关:当后端收到数据,发现签名里锁定的域名是
evil.com而不是自己的域名,校验失败。
4. 模拟请求(脚本攻击)为什么行不通?
如果黑客跳过前端页面,直接用 Postman 或 Python 脚本往你的 Spring Boot 接口发一段伪造的 JSON:
- 没有私钥:脚本无法生成合法的
Signature。 - 验签失败:后端库会执行以下逻辑:
- 检查 Challenge 是否存在于 Session 中。
- 使用数据库里的公钥对 Signature 进行 RSA/EC 验签。
- 检查 Origin 是否合法。
只要其中一项不符,后端就不会给这个请求签发 JWT 或 Session。
总结
在前后端交互中,黑客唯一能做的就是'观察'。他能看到你发了什么,但他改不了,也无法在没有你设备硬件的情况下生成一份新的、能通过后端校验的数据。
这就好比黑客截获了你的一张'亲笔签名信':
- 他可以复印这张信(重放攻击),但因为信上的日期(Challenge)不对,收信人不认。
- 他想改信的内容(篡改数据),但由于他没法模仿你的笔迹(私钥签名),收信人一眼就能看出签名被破坏了。


