无密码登录的安全问题及采用WebAuthn技术与实现分析
使用用户名+FingerprintJS无密码登录和用户名+密码登录和用户名+WebAuthn的安全性比较
以下是对三种登录方式的安全性比较:用户名 + FingerprintJS(设备指纹)无密码登录、用户名 + 密码登录、用户名 + WebAuthn(Passkey / FIDO2)登录。
三种方式都支持“用户名 + 某种凭证”的模式,并存时用户可选择(例如登录页面有三个按钮/选项)。比较维度以真实世界攻击场景为主(2025–2026 年常见威胁)。
| 维度 | 用户名 + 密码登录 | 用户名 + FingerprintJS 无密码登录 | 用户名 + WebAuthn 无密码登录(Passkey) | 安全性排名(1最高) |
|---|---|---|---|---|
| 凭证窃取风险 | 高(密码可被 keylogger、phishing 页面窃取) | 中低(指纹数据可被 JS 窃取,但需先访问真实站点) | 极低(私钥永不离开设备,无法被窃取) | WebAuthn > FingerprintJS > 密码 |
| 钓鱼(Phishing)抵抗力 | 差(用户易输入密码到假网站) | 差(指纹在假网站也能采集,攻击者可重放或伪造) | 极强(RP ID / origin 绑定,假网站 challenge 无效) | WebAuthn >> 其他两种 |
| 中间人攻击(MitM)抵抗 | 差(密码明文传输易被截获,即使 HTTPS 后端弱) | 中(指纹数据传输,但无加密绑定) | 极强(公钥加密 + origin 校验) | WebAuthn >> 其他 |
| 凭证重放攻击 | 高(密码可无限重放) | 中(指纹可被重放,但可加 nonce / 时间戳缓解) | 极低(challenge 每次不同 + counter 防重放) | WebAuthn > FingerprintJS > 密码 |
| 克隆 / 复制设备攻击 | 高(密码知道即可在任意设备登录) | 中高(指纹变化大时失效,但高级攻击者可 spoof 指纹) | 低(私钥绑定硬件,克隆极难) | WebAuthn > FingerprintJS > 密码 |
| XSS 攻击下凭证泄露 | 高(密码输入框易被窃取) | 高(localStorage 中的 device-secret 易读) | 低(私钥不暴露在 JS 可访问存储中) | WebAuthn > 其他 |
| 肩窥 / 键盘记录 | 高(用户输入密码) | 低(无需输入任何东西) | 低(只需 PIN/指纹验证) | FingerprintJS ≈ WebAuthn > 密码 |
| 用户体验(摩擦) | 中(需记住密码) | 低(几乎无缝,但首次需密码绑定) | 低–中(首次注册稍麻烦,后续一键) | FingerprintJS ≈ WebAuthn > 密码 |
| 隐私影响 | 低(只存 hash) | 高(指纹可跨站追踪用户,GDPR/CCPA 风险大) | 中(不收集额外指纹,但设备信息有限泄露) | 密码 > WebAuthn > FingerprintJS |
| 实现复杂度 & 维护成本 | 低 | 中(需 FingerprintJS + 后端比对逻辑) | 高(需 webauthn4j / 类似库 + challenge 管理) | 密码 < FingerprintJS < WebAuthn |
| NIST / OWASP 推荐级别 | AAL1–AAL2 | AAL2(勉强) | AAL3(最高,phishing-resistant MFA) | WebAuthn >> 其他 |
总结对比(2026 年视角)
| 场景 / 威胁模型 | 推荐方式 | 为什么 |
|---|---|---|
| 高价值目标(银行、金融、企业内部系统) | 用户名 + WebAuthn | 唯一真正 phishing-resistant 的方案,NIST 推荐 AAL3,FIDO Alliance 标准 |
| 中价值目标(电商、一般 SaaS) | WebAuthn 或 FingerprintJS + 辅助 | FingerprintJS 作为补充(设备绑定),但单独用不推荐;WebAuthn 更安全 |
| 低价值 / 高便利需求(论坛、内容站) | FingerprintJS 无密码 或 密码 | 成本低,用户摩擦小,但安全性明显弱于 WebAuthn |
| 最差组合 | 仅用户名 + FingerprintJS | 极易被高级钓鱼 + XSS 组合攻击,隐私问题严重,不建议作为唯一方式 |
采用密码登录和用户名+WebAuthn并存策略
WebAuthn 是一个浏览器原生 API,允许网站使用公钥凭证(public-key credentials) 来安全认证用户身份,支持无密码登录、生物识别(指纹、面部)、设备 PIN 或硬件安全密钥(如 YubiKey),彻底取代传统密码 + 短信验证码的弱认证方式。
它属于 FIDO2 框架的核心组成部分(FIDO2 = WebAuthn + CTAP2)。
为什么需要 WebAuthn?(解决传统密码的痛点)
传统用户名 + 密码存在以下致命问题:
- 易被钓鱼(phishing):用户在假网站输入密码
- 易被泄露:数据库被黑、keylogger、肩窥
- 易被重放:密码一旦泄露可在任意设备无限使用
- 用户体验差:需要记住复杂密码、经常重置
WebAuthn 通过公钥密码学 + 设备绑定 解决这些:
- 私钥永不离开用户设备(手机、电脑、硬件密钥),服务器只存公钥
- origin / RP ID 绑定:假网站无法使用你在真实站点注册的凭证(强防钓鱼)
- challenge + 签名:每次认证用随机挑战,防重放
- counter 机制:防止同一凭证被克隆使用
- 支持生物识别(指纹/面部)或PIN,无需输入密码
必要环境
| 环境 | 开发阶段可接受值 | 生产环境必须值 | 备注 |
|---|---|---|---|
| 协议 | http://localhost | https:// | 几乎所有浏览器强制要求 |
| 域名(RP ID) | localhost | 你的真实域名(example.com) | 不能是 IP、127.0.0.1、子域名(除非明确配置) |
| 端口 | 任意(5500、3000、8080 等) | 443(标准 HTTPS)或自定义但需证书 | 端口不影响,但必须 HTTPS |
流程图
详细版流程图(完整前后端逻辑验证)
这是一个详细的序列图,包含所有技术细节:challenge 生成、浏览器 API 调用、后端具体验证点(challenge 匹配、签名验证、counter 检查等)。强调 WebAuthn 阶段的前后端交互和安全校验。假设使用 webauthn4j 后端库。
代码实现
这是一个前后端分离的完整示例,使用 Spring Boot 作为后端,实现 WebAuthn 的注册和认证流程,前端使用你提供的单文件 HTML 风格(但调整为与后端 API 配合)。
webauthn-demo/ ├── backend/ # Spring Boot 项目 │ ├── src/main/java/... │ ├── pom.xml │ └── ... └── frontend/ └── index.html # 单文件前端(或 index.html) 后端:Spring Boot + webauthn4j
pom.xml(核心依赖)
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> <!-- 用于临时存储 challenge(生产环境建议用 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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> 核心配置类:WebAuthnConfig.java
Java
package com.example.webauthndemo.config; import com.webauthn4j.WebAuthnManager; import com.webauthn4j.converter.jackson.ObjectConverter; import com.webauthn4j.credential.CredentialRecord; import com.webauthn4j.data.RelyingPartyIdentity; import com.webauthn4j.server.ServerProperty; import com.webauthn4j.validator.WebAuthnValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Set; @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(); } } 简易内存存储 CredentialRepository(开发测试用)
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 com.webauthn4j.server.ServerProperty; import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @Component public class InMemoryCredentialRepository { // userHandle → List<CredentialRecord> 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:WebAuthnController.java
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 com.webauthn4j.validator.attestation.statement.COSEAlgorithmIdentifier; 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, // residentKey UserVerificationRequirement.PREFERRED ), null, null, null ); // 生产环境应该把 challenge 存到 session/redis,关联用户 // 这里简化,假定单用户 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 { // 实际项目中应该从 session 取 challenge,这里简化 PublicKeyCredential<AuthenticatorAttestationResponse, CollectedClientData> pkc = objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class); // 这里需要你自己实现验证逻辑(challenge、origin、rpId 等) // 简化示例: 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, null, // allowCredentials 可以从数据库查 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); // 验证逻辑(实际要查数据库中的公钥) // 这里简化演示 return ResponseEntity.ok("认证成功"); } catch (Exception e) { return ResponseEntity.badRequest().body("认证失败: " + e.getMessage()); } } } Application.java
Java
@SpringBootApplication public class WebauthnDemoApplication { public static void main(String[] args) { SpringApplication.run(WebauthnDemoApplication.class, args); } } 前端:index.html(单文件,连接后端)
HTML
<!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; } .info { color: #555; margin-bottom: 20px; } </style> </head> <body> <h1>WebAuthn 测试(连接 Spring Boot 后端)</h1> <p>请确保后端运行在 http://localhost:8080,前端通过 http://localhost:5500 访问</p> <button>开始注册 Passkey</button> <button>开始无密码登录</button> <div>等待操作...</div> <pre></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> 运行步骤
- 把前端 index.html 放在 VS Code,用 Live Server 打开(端口 5500)
- 浏览器访问 http://localhost:5500/index.html
- 点击按钮 → 应该能正常完成 WebAuthn 流程(注册后凭证保存在内存中,认证时能验证)
启动 Spring Boot 后端(端口 8080)Bash
mvn spring-boot:run 注意事项(重要!)
- 当前后端实现非常简化(内存存储、单用户、无 session 管理 challenge、无完整验证逻辑)
- 生产环境必须:
- 使用 Redis/JWT/session 存储 challenge
- 完整验证 origin、rpId、challenge
- 用户体系(关联 userHandle)
- HTTPS
- 域名改为真实域名(RP ID 必须匹配)
后端什么时候知道登录是没问题的?
在 WebAuthn(包括你项目中使用的 webauthn4j 库)的完整认证流程里,后端(Relying Party,简称 RP / 服务器)在 finishAuthentication / finishAuthenticationResponse 这一步验证通过后,才真正知道“这次登录是没问题的”。
下面我用前后端分离的视角,详细说明后端什么时候、如何确定登录成功(结合你之前提供的代码骨架)。
认证流程回顾(重点标注后端确认成功的时机)
- 前端发起登录→ 调用/authenticate/start
- 后端生成随机 challenge + options
- 返回给前端(PublicKeyCredentialRequestOptionsJSON)
- 前端调用 navigator.credentials.get()
- 浏览器弹出 PIN/指纹/面部/密码提示
- 用户验证成功后,生成签名(assertion)
- 前端拿到 PublicKeyCredential(包含 authenticatorData、signature、userHandle 等)
- 前端把 .toJSON() 结果 POST 到 /authenticate/finish
- 后端收到 /authenticate/finish 请求(这是关键时刻)
- 收到前端传来的 JSON(assertion response)
- 后端执行核心验证(webauthn4j 帮你完成大部分)
- challenge 是否匹配(防重放)
- origin / RP ID 是否正确(防钓鱼)
- authenticatorData 中的标志位是否符合要求(UP、UV)
- 签名验证:用之前注册时存储的公钥验证 signature 是否有效
- counter(签名计数器)是否递增(防克隆/重放)
- userHandle 是否匹配预期用户(尤其是 discoverable credential)
- 如果以上全部通过 → webauthn4j 的验证方法会返回成功(或不抛异常)
- 此时后端才知道:这次认证是合法的,用户身份可信
- 后端可以签发会话(JWT、cookie、session ID 等),完成登录
在代码中的体现(基于你之前的 Spring Boot 示例)
在 /authenticate/finish 接口里:
Java
@PostMapping("/authenticate/finish") public ResponseEntity<String> finishAuthentication(@RequestBody Map<String, Object> request) { try { // 1. 把前端 JSON 解析成 PublicKeyCredential PublicKeyCredential<AuthenticatorAssertionResponse, CollectedClientData> pkc = objectConverter.getJsonConverter().readValue(request, PublicKeyCredential.class); // 2. 核心验证(这里是后端真正确认“登录没问题”的地方) // 实际项目中需要传入正确的 ServerProperty(包含 challenge、origin、rpId) // 以及从数据库查到的对应 CredentialRecord(公钥、counter 等) AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponse( pkc, serverProperty, // challenge、origin、rpId credentialRecord, // 从 DB 查到的已注册凭证 null, // 扩展可选 false // 允许未验证用户句柄等 ); // 如果上面没抛异常 → 验证成功 // 更新 counter(防克隆重放) credentialRecord.setCounter(authenticationData.getAuthenticatorData().getSignCount()); // 保存更新后的 credential(如果用 DB) credentialRepository.save(credentialRecord); // ★★★ 这里后端确认登录成功 ★★★ // 可以生成 JWT 或设置 session String jwt = generateJwtForUser(credentialRecord.getUserHandle()); return ResponseEntity.ok("登录成功,token: " + jwt); } catch (DataConversionException | VerificationException e) { // 签名无效、challenge 不匹配、counter 异常、origin 不对 等 return ResponseEntity.badRequest().body("认证失败: " + e.getMessage()); } catch (Exception e) { return ResponseEntity.internalServerError().body("服务器错误"); } } 关键结论:
- 后端不是在收到前端请求时就认为登录成功
- 而是在 finishAuthentication 接口里,完成所有密码学验证(尤其是签名验证 + counter 检查)后,才确认“登录没问题”
- 如果任何一项校验失败(签名不对、counter 没增长、origin 被篡改等),webauthn4j 会抛异常,后端返回失败,前端看到“登录失败”
- 只有验证全部通过,后端才会签发会话 token 或设置登录状态
实际项目中常见的改进点
| 点 | 为什么重要 | 怎么做(简要) |
|---|---|---|
| challenge 存储 | 防重放攻击 | 用 Redis / session / DB 临时存(TTL 2min) |
| ServerProperty | 包含当前 challenge、origin、rpId | 在 finish 时从 session 取,校验一致 |
| 查 Credential | 必须用注册时存的公钥验证签名 | 根据 credentialId 或 userHandle 从 DB 查 |
| 更新 counter | 防止同一凭证被克隆使用 | 验证成功后必须更新并持久化 |
| 签发 token | 完成登录状态 | 返回 JWT / 设置 httpOnly cookie |
如果你现在的代码里 /authenticate/finish 只是简单 return “认证成功”(没做完整验证),那其实后端还没真正确认安全,很容易被伪造请求绕过。
前后端交互数据的时候可以伪造吗?
可以伪造“数据包”发送给后端,但你无法伪造“有效的签名数据”。
在 WebAuthn 的流程中,前后端交互的数据确实可以被截获、查看,甚至有人可以手动模拟一个 POST 请求发送给 Spring Boot。但由于密码学签名的存在,后端能瞬间识别出这些数据是“真货”还是“伪造品”。
我们可以从以下三个层面拆解为什么“伪造”无法得逞:
1. 数据包的结构:看得见,改不动
当指纹识别成功后,前端发给后端的数据主要包含三部分:
- ClientDataJSON:包含 Challenge 和当前的域名(Origin)。
- AuthenticatorData:包含设备状态和签名计数器。
- Signature(签名):这是最关键的部分。
为什么不能伪造: 签名是硬件使用私钥对前两部分内容的哈希值进行加密的结果。如果你篡改了 ClientDataJSON 里的任何一个字节(比如想把域名从 fake.com 改成 real.com),后端使用公钥解密签名时,计算出的哈希值就对不上了。
2. Challenge(挑战值):无法“预制”数据包
黑客可能会想:我先录制一段你登录成功的完整数据包,下次直接发给后端。
- 后端的防御: 后端在每次登录前给出的
Challenge都是随机且唯一的(通常是 32 位以上的随机字节)。 - 结果: 就像银行柜台问你“今天的暗号是什么?”,你必须用私钥对“今天的暗号”签名。黑客拿着“昨天的暗号”签名数据过来,后端会发现 Challenge 不匹配,直接丢弃。
3. 浏览器的“强制诚信”:拦截域名伪造
这是 WebAuthn 最特殊的地方。在前后端交互中,Origin(域名)不是由前端 JavaScript 定义的,而是由浏览器内核强制写入的。
- 伪造场景: 黑客写了一个恶意脚本,试图在数据包里把
Origin字段填成你的官网域名。 - 浏览器的拦截: 浏览器在调用底层硬件 API 时,会核实当前真实的访问地址。如果黑客在
evil.com上运行,浏览器传给硬件进行签名的域名永远只能是evil.com。 - 后端的最后把关: 当后端收到数据,发现签名里锁定的域名是
evil.com而不是自己的域名,校验失败。
4. 模拟请求(脚本攻击)为什么行不通?
如果黑客跳过前端页面,直接用 Postman 或 Python 脚本往你的 Spring Boot 接口发一段伪造的 JSON:
- 没有私钥: 脚本无法生成合法的
Signature。 - 验签失败: Spring Boot 使用
java-webauthn-server库调用rp.finishAuthentication。该方法内部会执行以下逻辑:- 检查 Challenge 是否存在于 Session 中。
- 使用数据库里的公钥对 Signature 进行 RSA/EC 验签。
- 检查 Origin 是否合法。
只要其中一项不符,后端就不会给这个请求签发 JWT 或 Session。
总结:黑客唯一能做的
在前后端交互中,黑客唯一能做的就是**“观察”**。他能看到你发了什么,但他改不了,也无法在没有你设备硬件的情况下生成一份新的、能通过后端校验的数据。
这就好比黑客截获了你的一张“亲笔签名信”:
- 他可以复印这张信(重放攻击),但因为信上的日期(Challenge)不对,收信人不认。
- 他想改信的内容(篡改数据),但由于他没法模仿你的笔迹(私钥签名),收信人一眼就能看出签名被破坏了。