HTML表单结合WebAuthn实现硬件安全密钥认证
在Web应用中,传统的账号密码认证方式存在泄露、撞库等安全风险。WebAuthn(Web Authentication API)是FIDO2标准的核心组成部分,允许网站通过硬件安全密钥、手机生物识别等方式完成无密码认证,大幅提升账户安全性。本文将介绍如何结合HTML表单与WebAuthn实现硬件安全密钥认证流程。
WebAuthn核心概念
WebAuthn的认证流程分为两个核心阶段:注册(Registration)和认证(Authentication),涉及三个关键参与方:
依赖方(Relying Party,RP):需要认证的网站,需提供合法的域名和标识信息
用户(User):使用硬件安全密钥(如YubiKey、支持FIDO的手机)完成认证的终端用户
认证器(Authenticator):用户持有的硬件安全设备,负责生成和存储凭证,完成签名验证
整个流程由浏览器作为中间层,调用navigator.credentials接口完成与认证器的交互,无需开发者直接操作硬件设备。
前期准备
使用WebAuthn需要满足以下环境要求:
浏览器支持:Chrome 67+、Firefox 60+、Edge 18+、Safari 13+均已支持WebAuthn标准
网站必须使用HTTPS协议部署,本地开发环境可使用
localhost(非HTTPS的局域网地址不支持WebAuthn)准备硬件安全密钥:如YubiKey 5系列、Feitian ePass FIDO等支持FIDO2协议的设备
注册阶段实现
注册阶段的作用是让用户将硬件安全密钥与当前账号绑定,生成对应的凭证信息存储到服务端。结合HTML表单的场景中,通常先通过表单收集用户基本信息,再触发注册流程。
1. 前端注册逻辑
首先创建一个基础的HTML表单用于收集用户信息,再添加注册按钮触发WebAuthn注册:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>WebAuthn注册示例</title> </head> <body> <h3>账号注册</h3> <form id="registerForm"> <div> <label for="username">用户名:</label> <input type="text" id="username" name="username" required> </div> <div> <label for="displayName">显示名称:</label> <input type="text" id="displayName" name="displayName" required> </div> <button type="button" id="startRegister">绑定硬件安全密钥</button> </form> <script src="register.js"></script> </body> </html>
对应的register.js实现注册逻辑:
// 检查浏览器是否支持WebAuthn
if (!window.PublicKeyCredential) {
alert('当前浏览器不支持WebAuthn,请更换浏览器后重试');
throw new Error('WebAuthn not supported');
}
document.getElementById('startRegister').addEventListener('click', async () => {
const username = document.getElementById('username').value;
const displayName = document.getElementById('displayName').value;
if (!username || !displayName) {
alert('请填写完整的用户信息');
return;
}
try {
// 1. 从服务端获取注册选项(挑战值、依赖方信息等)
const optionsResp = await fetch('https://www.ipipp.com/api/webauthn/register-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName })
});
const options = await optionsResp.json();
// 2. 转换服务端返回的需要ArrayBuffer的数据(如challenge、user.id)
options.challenge = Uint8Array.from(options.challenge, c => c.charCodeAt(0));
options.user.id = Uint8Array.from(options.user.id, c => c.charCodeAt(0));
// 3. 调用浏览器API创建凭证,此时会提示用户插入并操作硬件安全密钥
const credential = await navigator.credentials.create({
publicKey: options
});
// 4. 将凭证信息发送到服务端存储
const verificationResp = await fetch('https://www.ipipp.com/api/webauthn/register-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credential: {
id: credential.id,
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
response: {
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON))
}
}
})
});
const result = await verificationResp.json();
if (result.success) {
alert('硬件安全密钥绑定成功,请保存好您的密钥');
} else {
alert('绑定失败:' + result.message);
}
} catch (error) {
alert('注册过程出错:' + error.message);
}
});2. 服务端注册逻辑参考
服务端需要实现两个接口:生成注册选项、验证注册凭证。以下是Node.js环境的简单示例:
const express = require('express');
const app = express();
app.use(express.json());
// 生成注册选项接口
app.post('/api/webauthn/register-options', (req, res) => {
const { username, displayName } = req.body;
// 生成随机挑战值(实际生产环境需使用加密安全的随机数生成器)
const challenge = Buffer.from(require('crypto').randomBytes(32)).toString('base64');
// 依赖方信息,实际生产环境需替换为你的网站域名
const rp = {
name: '示例网站',
id: 'www.ipipp.com'
};
// 用户信息
const user = {
id: Buffer.from(username).toString('base64'), // 实际生产环境需使用唯一用户ID
name: username,
displayName: displayName
};
// 注册参数
const options = {
challenge: challenge,
rp: rp,
user: user,
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
timeout: 60000,
attestation: 'direct',
// 排除已注册的凭证,避免重复绑定
excludeCredentials: [],
authenticatorSelection: {
authenticatorAttachment: 'cross-platform', // 指定使用跨平台认证器(硬件密钥)
requireResidentKey: false,
userVerification: 'preferred'
}
};
// 将challenge存到会话中,后续验证使用
req.session.challenge = challenge;
res.json(options);
});
// 验证注册凭证接口
app.post('/api/webauthn/register-verify', (req, res) => {
const { username, credential } = req.body;
// 实际生产环境需验证challenge、解析attestationObject、存储公钥信息等
// 此处简化逻辑,仅返回成功
res.json({ success: true, message: '注册成功' });
});
app.listen(3000, () => console.log('服务启动在3000端口'));认证阶段实现
认证阶段是用户后续登录时,通过硬件安全密钥验证身份的流程,同样可以结合HTML表单实现。
1. 前端认证逻辑
创建登录表单,触发WebAuthn认证:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>WebAuthn登录示例</title> </head> <body> <h3>账号登录</h3> <form id="loginForm"> <div> <label for="loginUsername">用户名:</label> <input type="text" id="loginUsername" name="loginUsername" required> </div> <button type="button" id="startLogin">使用硬件安全密钥登录</button> </form> <script src="login.js"></script> </body> </html>
对应的login.js实现认证逻辑:
document.getElementById('startLogin').addEventListener('click', async () => {
const username = document.getElementById('loginUsername').value;
if (!username) {
alert('请输入用户名');
return;
}
try {
// 1. 从服务端获取认证选项
const optionsResp = await fetch('https://www.ipipp.com/api/webauthn/login-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const options = await optionsResp.json();
// 2. 转换ArrayBuffer数据
options.challenge = Uint8Array.from(options.challenge, c => c.charCodeAt(0));
options.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: Uint8Array.from(cred.id, c => c.charCodeAt(0))
}));
// 3. 调用浏览器API获取凭证,提示用户操作硬件密钥
const credential = await navigator.credentials.get({
publicKey: options
});
// 4. 将认证结果发送到服务端验证
const verificationResp = await fetch('https://www.ipipp.com/api/webauthn/login-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credential: {
id: credential.id,
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
response: {
authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
signature: Array.from(new Uint8Array(credential.response.signature)),
userHandle: credential.response.userHandle ? Array.from(new Uint8Array(credential.response.userHandle)) : null
}
}
})
});
const result = await verificationResp.json();
if (result.success) {
alert('登录成功,即将跳转');
window.location.href = 'https://www.ipipp.com/user/dashboard';
} else {
alert('登录失败:' + result.message);
}
} catch (error) {
alert('认证过程出错:' + error.message);
}
});注意事项
硬件安全密钥丢失后无法找回,建议引导用户绑定多个认证器,或者保留备用认证方式(如短信验证码、邮箱验证码)
生产环境中挑战值(challenge)必须使用加密安全的随机数生成器生成,避免被伪造
服务端验证时需要校验挑战值的有效性、依赖方ID是否匹配、签名是否合法,防止重放攻击
如果用户使用平台内置认证器(如手机指纹、Windows Hello),可以将
authenticatorAttachment设置为'platform',或设置为null允许用户选择认证方式
常见问题排查
如果点击按钮后无反应,检查浏览器是否支持WebAuthn,网站是否使用HTTPS(或localhost)
提示“找不到认证器”,确认硬件密钥已插入设备,或者设备已开启蓝牙/NFC(无线密钥需要)
服务端验证失败,检查challenge是否过期、依赖方ID是否与当前域名一致、凭证信息是否解析正确