跳到主要内容无密码登录安全对比与 WebAuthn 技术实战 | 极客日志Java大前端java
无密码登录安全对比与 WebAuthn 技术实战
无密码登录相比传统密码方案在防钓鱼和凭证窃取上优势明显,WebAuthn 凭借公钥加密与设备绑定成为首选。通过对比三种登录方式的安全性,结合 Spring Boot 与 webauthn4j 库,详解从注册到认证的后端验证逻辑,重点剖析 Challenge 机制、签名校验及 Counter 防重放策略,并提供前后端交互的完整代码示例与生产环境落地建议。
蓝绿部署10 浏览 三种登录方式的安全性横向对比
在构建现代认证系统时,我们常面临选择:用户名 + 密码、用户名 + FingerprintJS(设备指纹) 以及 用户名 + WebAuthn(Passkey / FIDO2)。这三种模式虽然都基于'凭证'验证,但在真实攻击场景下的表现差异巨大。
下表以当前主流威胁模型为基准,从多个维度进行对比:
| 维度 | 用户名 + 密码登录 | 用户名 + FingerprintJS 无密码登录 | 用户名 + WebAuthn 无密码登录(Passkey) |
|---|
| 凭证窃取风险 | 高(易受键盘记录器、钓鱼页面窃取) | 中低(需先访问站点,但 JS 可被窃取) | 极低(私钥永不离开设备,无法被窃取) |
| 钓鱼(Phishing)抵抗力 | 差(用户易向假站输入密码) | 差(指纹数据可在假站采集并重放) | 极强(RP ID / origin 绑定,假站 Challenge 无效) |
| 中间人攻击(MitM)抵抗 | 差(HTTPS 下仍可能因后端弱配置泄露) | 中(数据传输但未加密绑定) | 极强(公钥加密 + origin 校验) |
| 凭证重放攻击 | 高(密码可无限次使用) | 中(可加 nonce/时间戳缓解) | 极低(Challenge 每次不同 + Counter 防重放) |
| 克隆 / 复制设备攻击 | 高(知道密码即可在任何设备登录) | 中高(高级攻击者可伪造指纹) | 低(私钥绑定硬件,克隆极难) |
| XSS 攻击下凭证泄露 | 高(密码输入框易被脚本读取) | 高(localStorage 中的 device-secret 易读) | 低(私钥不暴露在 JS 可访问存储中) |
| 肩窥 / 键盘记录 | 高(用户需手动输入) | 低(无需输入) | 低(仅需 PIN/生物识别验证) |
| 用户体验(摩擦) | 中(需记忆复杂密码) | 低(几乎无缝,首次需绑定) | 低–中(首次注册稍繁琐,后续一键) |
| 隐私影响 | 低(仅存 Hash) | 高(指纹可跨站追踪,GDPR/CCPA 风险大) | 中(不收集额外指纹,设备信息有限泄露) |
| NIST / OWASP 推荐级别 | AAL1–AAL2 | AAL2(勉强) | AAL3(最高,抗钓鱼 MFA) |
场景化建议
- 高价值目标(银行、金融、企业内部系统):必须采用 WebAuthn。这是唯一真正抗钓鱼的方案,符合 NIST AAL3 标准。
- 中价值目标(电商、一般 SaaS):优先 WebAuthn,FingerprintJS 仅作为辅助风控手段,不建议单独作为唯一认证方式。
- 低价值 / 高便利需求(论坛、内容站):可考虑 FingerprintJS 无密码 或传统 密码,成本较低但安全性明显弱于 WebAuthn。
为什么需要 WebAuthn?
WebAuthn 是浏览器原生 API,允许网站使用公钥凭证来安全认证用户身份。它支持无密码登录、生物识别(指纹、面部)、设备 PIN 或硬件安全密钥(如 YubiKey),彻底取代了传统密码 + 短信验证码的弱认证方式。
作为 FIDO2 框架的核心组成部分(FIDO2 = WebAuthn + CTAP2),它通过以下机制解决传统密码痛点:
- 私钥永不离开用户设备:服务器只存储公钥,即使数据库泄露也无法还原私钥。
- Origin / RP ID 绑定:假网站无法使用你在真实站点注册的凭证,天然防钓鱼。
- Challenge + 签名:每次认证使用随机挑战值,防止重放攻击。
- Counter 机制:防止同一凭证被克隆使用。
前置环境要求
| 环境 | 开发阶段可接受值 | 生产环境必须值 | 备注 |
|---|
| 协议 | http://localhost | https:// | 几乎所有浏览器强制要求 |
| 域名(RP ID) | localhost | 你的真实域名(example.com) | 不能是 IP、127.0.0.1 |
| 端口 | 任意(5500、3000 等) | 443(标准 HTTPS)或自定义但需证书 | 端口不影响,但必须 HTTPS |
下图展示了包含所有技术细节的序列图,涵盖 Challenge 生成、浏览器 API 调用及后端具体验证点(Challenge 匹配、签名验证、Counter 检查等)。假设后端使用 webauthn4j 库。
代码实现参考
下面给出一个前后端分离的参考实现,后端使用 Spring Boot,前端为单文件 HTML 风格。项目结构如下:
webauthn-demo/
├── backend/
│ ├── src/main/java/...
│ ├── pom.xml
│ └── ...
└── frontend/
└── index.html
后端:Spring Boot + webauthn4j
首先配置核心依赖 pom.xml。这里使用了 webauthn4j-core 和 webauthn4j-util,并预留了 Redis 依赖用于生产环境存储 Challenge。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>webauthn-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
</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>
<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>
</project>
核心配置类
我们需要定义 Relying Party Identity 和 Validator。注意开发环境 RP ID 设为 localhost,生产环境需替换为真实域名。
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")
.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。
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;
@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);
Set<PublicKeyCredentialDescriptor> descriptors = new HashSet<>();
for (CredentialRecord record : records) {
descriptors.add(new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY,
record.getCredentialId(),
record.getTransports()));
}
return descriptors;
}
}
Controller 实现
Controller 负责处理注册和认证的请求分发。注意 startRegistration 和 startAuthentication 生成的 Challenge 在生产环境中应存入 Session 或 Redis,以便后续验证。
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.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";
@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);
}
@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());
}
}
@PostMapping("/authenticate/start")
public ResponseEntity<Map<String, Object>> startAuthentication() {
Challenge challenge = new DefaultChallenge();
PublicKeyCredentialRequestOptions options = new PublicKeyCredentialRequestOptions(
challenge, 60000L, RP_ID, null, UserVerificationRequirement.PREFERRED, null);
Map<String, Object> json = objectConverter.getJsonConverter().writeValueAsMap(options);
return ResponseEntity.ok(json);
}
@PostMapping("/authenticate/finish")
public ResponseEntity<String> finishAuthentication(@RequestBody Map<String, Object> request) {
try {
PublicKeyCredential<AuthenticatorAssertionResponse, CollectedClientData> pkc =
objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class);
return ResponseEntity.ok("认证成功");
} catch (Exception e) {
return ResponseEntity.badRequest().body("认证失败:" + e.getMessage());
}
}
}
前端:index.html
前端主要调用 navigator.credentials.create 和 navigator.credentials.get。确保后端运行在 http://localhost:8080,前端通过 Live Server 运行在 http://localhost:5500。
<!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. = ;
credential = navigator..({ : options });
status. = ;
responseJSON = credential.();
(, , responseJSON);
status. = ;
output. = .(responseJSON, , );
} (err) {
status. = ;
output. = ;
.(err);
}
}
() {
status. = ;
{
optionsJSON = ();
options = .(optionsJSON);
status. = ;
assertion = navigator..({ : options });
status. = ;
responseJSON = assertion.();
(, , responseJSON);
status. = ;
output. = .(responseJSON, , );
} (err) {
status. = ;
output. = ;
.(err);
}
}
.().(, register);
.().(, login);
</script>
</body>
</html>
运行步骤
- 启动 Spring Boot 后端(端口 8080):
mvn spring-boot:run
- 将前端
index.html 放在 VS Code,用 Live Server 打开(端口 5500)。
- 浏览器访问
http://localhost:5500/index.html,点击按钮即可完成流程。
生产环境落地注意点
当前示例为了演示简化了逻辑(内存存储、单用户、无 session 管理 Challenge)。实际落地时必须完善以下几点:
- Challenge 存储:生产环境必须使用 Redis/JWT/Session 存储 Challenge,设置 TTL(如 2 分钟),防止重放。
- 完整验证逻辑:在
/authenticate/finish 接口中,必须传入正确的 ServerProperty(包含 Challenge、Origin、RP ID),并从数据库查到的 CredentialRecord 中提取公钥进行验签。
- Counter 更新:验证成功后必须更新并持久化 Counter,防止凭证克隆。
- 域名一致性:RP ID 必须与真实域名匹配,且前端 Origin 必须与后端校验一致。
后端何时确认登录成功?
在 WebAuthn 流程中,后端(Relying Party)只有在完成所有密码学验证后,才真正知道'这次登录是没问题的'。
关键验证时机
- 前端发起登录 → 调用
/authenticate/start
- 后端生成随机 Challenge + Options,返回给前端。
- 前端调用
navigator.credentials.get()
- 浏览器弹出 PIN/指纹/面部提示。
- 用户验证成功后,生成签名(Assertion)。
- 前端将
.toJSON() 结果 POST 到 /authenticate/finish。
- 后端收到
/authenticate/finish 请求(关键时刻)
- 后端执行核心验证(webauthn4j 库协助完成):
- Challenge 是否匹配:防止重放。
- Origin / RP ID 是否正确:防止钓鱼。
- AuthenticatorData 标志位:检查 UP(User Present)、UV(User Verified)是否符合预期。
- 签名验证:用注册时存储的公钥验证 Signature 是否有效。
- Counter 检查:防止同一凭证被克隆使用。
- 如果以上全部通过,webauthn4j 不会抛异常。
- 此时后端才知道:这次认证是合法的,用户身份可信。
- 后端可以签发会话(JWT、Cookie、Session ID 等),完成登录。
代码层面的体现
在 /authenticate/finish 接口里,真正的验证逻辑如下:
@PostMapping("/authenticate/finish")
public ResponseEntity<String> finishAuthentication(@RequestBody Map<String, Object> request) {
try {
PublicKeyCredential<AuthenticatorAssertionResponse, CollectedClientData> pkc =
objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class);
AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponse(
pkc, serverProperty, credentialRecord, null, false
);
credentialRecord.setCounter(authenticationData.getAuthenticatorData().getSignCount());
credentialRepository.save(credentialRecord);
String jwt = generateJwtForUser(credentialRecord.getUserHandle());
return ResponseEntity.ok("登录成功,token: " + jwt);
} catch (DataConversionException | VerificationException e) {
return ResponseEntity.badRequest().body("认证失败:" + e.getMessage());
} catch (Exception e) {
return ResponseEntity.internalServerError().body("服务器错误");
}
}
结论:后端不是在收到请求时就认为登录成功,而是在 finishAuthentication 接口里,完成所有密码学验证(尤其是签名验证 + Counter 检查)后,才确认'登录没问题'。如果任何一项校验失败,webauthn4j 会抛异常,后端返回失败。
前后端交互数据可以被伪造吗?
黑客可以伪造'数据包'发送给后端,但无法伪造'有效的签名数据'。
1. 数据包结构:看得见,改不动
前端发给后端的数据主要包含三部分:ClientDataJSON、AuthenticatorData、Signature。签名是硬件使用私钥对前两部分内容的哈希值加密的结果。如果你篡改了 ClientDataJSON 里的任何一个字节,后端使用公钥解密签名时,计算出的哈希值就对不上了。
2. Challenge:无法'预制'数据包
后端在每次登录前给出的 Challenge 都是随机且唯一的。黑客拿着'昨天的暗号'签名数据过来,后端会发现 Challenge 不匹配,直接丢弃。
3. 浏览器的'强制诚信':拦截域名伪造
Origin 不是由前端 JavaScript 定义的,而是由浏览器内核强制写入的。如果黑客在 evil.com 上运行,浏览器传给硬件进行签名的域名永远只能是 evil.com。后端收到数据发现签名里锁定的域名不是自己的域名,校验失败。
4. 模拟请求为什么行不通?
如果黑客跳过前端页面,直接用 Postman 发一段伪造的 JSON:
- 没有私钥:脚本无法生成合法的 Signature。
- 验签失败:后端内部会检查 Challenge 是否存在于 Session 中,并使用数据库里的公钥对 Signature 进行 RSA/EC 验签。
- 只要其中一项不符,后端就不会给这个请求签发 JWT 或 Session。
总结:黑客唯一能做的就是'观察'。他能看到你发了什么,但他改不了,也无法在没有你设备硬件的情况下生成一份新的、能通过后端校验的数据。这就好比黑客截获了你的一张'亲笔签名信',他可以复印这张信(重放攻击),但因为信上的日期(Challenge)不对,收信人不认;他想改信的内容(篡改数据),但由于他没法模仿你的笔迹(私钥签名),收信人一眼就能看出签名被破坏了。
textContent
"请按照浏览器提示创建 Passkey"
const
await
credentials
create
publicKey
textContent
"正在提交注册结果..."
const
toJSON
await
fetchJson
`${API_BASE}/register/finish`
'POST'
textContent
"注册成功!"
textContent
JSON
stringify
null
2
catch
textContent
"注册失败"
textContent
`${err.name}\n${err.message}`
console
error
async
function
login
textContent
"正在请求认证选项..."
try
const
await
fetchJson
`${API_BASE}/authenticate/start`
const
PublicKeyCredential
parseRequestOptionsFromJSON
textContent
"请按照浏览器提示验证身份"
const
await
credentials
get
publicKey
textContent
"正在提交认证结果..."
const
toJSON
await
fetchJson
`${API_BASE}/authenticate/finish`
'POST'
textContent
"登录成功!"
textContent
JSON
stringify
null
2
catch
textContent
"登录失败"
textContent
`${err.name}\n${err.message}`
console
error
document
getElementById
'registerStartBtn'
addEventListener
'click'
document
getElementById
'loginStartBtn'
addEventListener
'click'
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online