各位亲爱的攻城狮、程序媛、以及未来的科技大佬们,
很高兴能有机会和大家聊聊一个既酷炫又实用的技术——WebAuthn,这玩意儿可是无密码认证领域的一把利剑。今天,咱们就来扒一扒 WebAuthn 的底裤,看看它到底是怎么实现无密码登录的,以及在 JavaScript 的世界里,我们该如何驾驭这头猛兽。
一、密码的那些糟心事儿
在正式进入 WebAuthn 的世界之前,咱们先来吐槽一下密码这货。想象一下,你是不是经常遇到以下情况:
- 密码太多记不住? 最后只能用 "123456" 或者 "password" 这种弱密码,等着被黑客叔叔光顾。
- 密码泄露风险高? 哪个网站要是数据库被脱裤了,你的密码可能就成了公开的秘密。
- 每次登录都要输入密码? 简直浪费生命啊!
密码这东西,用起来麻烦,安全性还差,简直就是个鸡肋。所以,我们需要一种更安全、更便捷的认证方式,而 WebAuthn 就是那个天选之子。
二、WebAuthn:无密码认证的救星
WebAuthn (Web Authentication API) 是一种基于公钥密码学的认证标准,它允许网站利用用户设备上的认证器(Authenticator)进行身份验证,而无需用户输入密码。
啥是认证器?
认证器可以理解为一种硬件或者软件,它能够安全地存储用户的密钥对,并进行加密操作。常见的认证器包括:
- 硬件安全密钥 (Security Key): 比如 YubiKey、Google Titan Security Key 等,长得像 U 盘,插在电脑上用。
- TPM (Trusted Platform Module): 集成在电脑主板上的安全芯片。
- 指纹识别器、面部识别器: 智能手机、笔记本电脑上常见的生物识别设备。
- 平台认证器 (Platform Authenticator): 由操作系统提供的认证器,比如 Windows Hello、macOS Touch ID。
WebAuthn 的核心思想:
WebAuthn 的核心思想是利用公钥密码学,将用户的身份验证信息与设备绑定。具体来说,它包含以下几个关键步骤:
- 注册 (Registration): 用户在网站上注册时,会生成一对密钥,一个公钥 (Public Key) 和一个私钥 (Private Key)。公钥会被发送到网站服务器保存,私钥则安全地存储在用户的认证器中,绝不离开设备。
- 认证 (Authentication): 当用户登录网站时,网站会向用户的浏览器发送一个挑战 (Challenge)。浏览器会将这个挑战传递给认证器,认证器使用私钥对挑战进行签名,并将签名后的数据返回给网站。网站使用之前保存的公钥验证签名的有效性,如果验证通过,就认为用户身份验证成功。
WebAuthn 的优势:
- 安全性高: 私钥存储在硬件设备中,不容易被盗取。即使网站数据库泄露,黑客也无法利用公钥伪造用户的身份。
- 便捷性好: 无需输入密码,只需使用指纹、面部识别或者插入安全密钥即可完成认证。
- 防钓鱼: WebAuthn 认证过程会验证网站的域名,可以有效防止钓鱼网站冒充合法网站。
三、WebAuthn 的工作流程
为了让大家更直观地理解 WebAuthn 的工作原理,我们用一张表格来梳理一下注册和认证的流程:
步骤 | 角色 | 动作 | 数据 |
---|---|---|---|
1 | 用户 | 访问网站,点击注册按钮。 | 用户名、邮箱等注册信息。 |
2 | 网站前端 | 向网站后端发送注册请求。 | 包含用户注册信息。 |
3 | 网站后端 | 生成注册挑战 (Registration Challenge),并将其返回给网站前端。这个挑战是一个随机的字节数组,用于防止重放攻击。 | 注册挑战 (Registration Challenge)。 |
4 | 网站前端 | 调用 navigator.credentials.create() 方法,将注册挑战传递给浏览器。 |
PublicKeyCredentialCreationOptions 对象,包含注册挑战、用户 ID、用户名、认证器参数等信息。 |
5 | 浏览器 | 将注册挑战传递给用户的认证器。 | 注册挑战 (Registration Challenge)。 |
6 | 认证器 | 生成密钥对 (公钥和私钥),使用私钥对注册挑战进行签名,并将签名后的数据和公钥返回给浏览器。 | PublicKeyCredential 对象,包含公钥、签名、认证器附件信息等。 |
7 | 浏览器 | 将 PublicKeyCredential 对象返回给网站前端。 |
PublicKeyCredential 对象。 |
8 | 网站前端 | 将 PublicKeyCredential 对象发送给网站后端。 |
PublicKeyCredential 对象。 |
9 | 网站后端 | 验证签名,保存公钥和用户 ID 的对应关系。 | 公钥、用户 ID。 |
10 | 用户 | 访问网站,点击登录按钮。 | 用户名。 |
11 | 网站前端 | 向网站后端发送登录请求。 | 包含用户名。 |
12 | 网站后端 | 生成认证挑战 (Authentication Challenge),并将其返回给网站前端。 | 认证挑战 (Authentication Challenge)。 |
13 | 网站前端 | 调用 navigator.credentials.get() 方法,将认证挑战传递给浏览器。 |
PublicKeyCredentialRequestOptions 对象,包含认证挑战、允许的公钥列表等信息。 |
14 | 浏览器 | 将认证挑战传递给用户的认证器。 | 认证挑战 (Authentication Challenge)。 |
15 | 认证器 | 使用私钥对认证挑战进行签名,并将签名后的数据返回给浏览器。 | PublicKeyCredential 对象,包含签名、认证器附件信息等。 |
16 | 浏览器 | 将 PublicKeyCredential 对象返回给网站前端。 |
PublicKeyCredential 对象。 |
17 | 网站前端 | 将 PublicKeyCredential 对象发送给网站后端。 |
PublicKeyCredential 对象。 |
18 | 网站后端 | 使用之前保存的公钥验证签名,如果验证通过,则认为用户身份验证成功。 | 签名、公钥。 |
四、JavaScript 代码实战
光说不练假把式,接下来,咱们就用 JavaScript 代码来实现 WebAuthn 的注册和认证过程。
1. 注册 (Registration)
async function register() {
// 1. 向后端请求注册选项
const response = await fetch('/register/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: 'your_username' }) // 替换成你的用户名
});
const options = await response.json();
// 2. 调用 navigator.credentials.create() 方法
const credential = await navigator.credentials.create(options);
// 3. 将 credential 对象发送给后端
const registerResponse = await fetch('/register/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credential)
});
const registerResult = await registerResponse.json();
if (registerResult.success) {
alert('注册成功!');
} else {
alert('注册失败:' + registerResult.message);
}
}
代码解读:
/register/options
: 这个 URL 是你后端提供的 API,用于生成注册选项 (Registration Options)。后端会生成一个注册挑战 (Registration Challenge),并将其包含在注册选项中返回给前端。navigator.credentials.create(options)
: 这是 WebAuthn 的核心 API,它会调用用户的认证器,生成密钥对,并返回一个PublicKeyCredential
对象。/register/complete
: 这个 URL 是你后端提供的 API,用于完成注册过程。前端会将PublicKeyCredential
对象发送给后端,后端会验证签名,保存公钥和用户 ID 的对应关系。
2. 认证 (Authentication)
async function login() {
// 1. 向后端请求认证选项
const response = await fetch('/login/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: 'your_username' }) // 替换成你的用户名
});
const options = await response.json();
// 2. 调用 navigator.credentials.get() 方法
const credential = await navigator.credentials.get(options);
// 3. 将 credential 对象发送给后端
const loginResponse = await fetch('/login/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credential)
});
const loginResult = await loginResponse.json();
if (loginResult.success) {
alert('登录成功!');
// 跳转到首页或者其他页面
} else {
alert('登录失败:' + loginResult.message);
}
}
代码解读:
/login/options
: 这个 URL 是你后端提供的 API,用于生成认证选项 (Authentication Options)。后端会生成一个认证挑战 (Authentication Challenge),并将其包含在认证选项中返回给前端。navigator.credentials.get(options)
: 这是 WebAuthn 的核心 API,它会调用用户的认证器,使用私钥对认证挑战进行签名,并返回一个PublicKeyCredential
对象。/login/complete
: 这个 URL 是你后端提供的 API,用于完成认证过程。前端会将PublicKeyCredential
对象发送给后端,后端会验证签名,如果验证通过,则认为用户身份验证成功。
五、后端代码实现 (Node.js + Express)
光有前端代码还不够,咱们还得写一些后端代码来配合。这里我们使用 Node.js 和 Express 来实现后端 API。
1. 安装依赖
npm install express body-parser cbor @simplewebauthn/server
2. 注册 API (/register/options
和 /register/complete
)
const express = require('express');
const bodyParser = require('body-parser');
const cbor = require('cbor');
const {
generateRegistrationOptions,
verifyRegistrationResponse
} = require('@simplewebauthn/server');
const app = express();
app.use(bodyParser.json());
// 模拟数据库
const users = {};
app.post('/register/options', async (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: '用户名不能为空' });
}
const options = await generateRegistrationOptions({
rpName: 'Your Website', // 你的网站名称
rpID: 'localhost', // 你的网站域名 (生产环境需要使用 HTTPS)
userID: username, // 用户 ID (最好是唯一的)
userName: username, // 用户名
attestationType: 'direct', // Attestation 类型, 生产环境推荐使用 "indirect"
excludeCredentials: users[username] ? [{
id: Buffer.from(users[username].credentialID, 'base64'),
type: 'public-key'
}] : [], // 排除已经注册的凭证
});
// 保存 challenge 到 session 中 (生产环境需要使用更安全的方式)
users[username] = {
challenge: options.challenge,
};
res.json(options);
});
app.post('/register/complete', async (req, res) => {
const { id, rawId, response, type } = req.body;
const { clientDataJSON, attestationObject } = response;
const { username, challenge } = users[req.body.id]; // 使用 credential id 作为 user id
if (!username || !challenge) {
return res.status(400).json({ message: '注册挑战已过期' });
}
try {
const verification = await verifyRegistrationResponse({
attestationObject: attestationObject,
clientDataJSON: clientDataJSON,
expectedChallenge: challenge,
expectedOrigin: `http://localhost:3000`, // 你的网站域名 (需要和前端保持一致)
expectedRPID: 'localhost', // 你的网站域名 (需要和前端保持一致)
});
const { verified, registrationInfo } = verification;
if (verified) {
// 保存用户信息到数据库
users[req.body.id] = {
credentialID: req.body.id,
publicKey: registrationInfo.publicKey,
counter: 0,
};
return res.json({ success: true });
} else {
return res.status(400).json({ message: '注册验证失败' });
}
} catch (error) {
console.error(error);
return res.status(500).json({ message: '服务器错误' });
}
});
3. 认证 API (/login/options
和 /login/complete
)
const {
generateAuthenticationOptions,
verifyAuthenticationResponse
} = require('@simplewebauthn/server');
app.post('/login/options', async (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: '用户名不能为空' });
}
if (!users[username]) {
return res.status(400).json({ message: '用户不存在' });
}
const options = await generateAuthenticationOptions({
allowCredentials: [{
id: Buffer.from(users[username].credentialID, 'base64'),
type: 'public-key',
transports: ['usb', 'nfc', 'ble']
}],
userVerification: 'preferred', // 推荐用户验证
});
// 保存 challenge 到 session 中 (生产环境需要使用更安全的方式)
users[username].challenge = options.challenge;
res.json(options);
});
app.post('/login/complete', async (req, res) => {
const { id, rawId, response, type } = req.body;
const { clientDataJSON, authenticatorData, signature } = response;
const { username, challenge, publicKey, counter } = users[req.body.id];
if (!username || !challenge || !publicKey) {
return res.status(400).json({ message: '认证挑战已过期' });
}
try {
const verification = await verifyAuthenticationResponse({
authenticatorData: authenticatorData,
clientDataJSON: clientDataJSON,
expectedChallenge: challenge,
expectedOrigin: `http://localhost:3000`, // 你的网站域名 (需要和前端保持一致)
expectedRPID: 'localhost', // 你的网站域名 (需要和前端保持一致)
signature: signature,
publicKey: Buffer.from(publicKey, 'base64'),
counter: counter,
});
const { verified, authenticationInfo } = verification;
if (verified) {
// 更新 counter
users[req.body.id].counter = authenticationInfo.newCounter;
return res.json({ success: true });
} else {
return res.status(400).json({ message: '认证验证失败' });
}
} catch (error) {
console.error(error);
return res.status(500).json({ message: '服务器错误' });
}
});
app.listen(3000, () => {
console.log('Server started on port 3000');
});
代码解读:
@simplewebauthn/server
: 这是一个 Node.js 库,用于简化 WebAuthn 后端开发。它提供了generateRegistrationOptions
、verifyRegistrationResponse
、generateAuthenticationOptions
、verifyAuthenticationResponse
等方法,可以帮助我们快速实现 WebAuthn 的注册和认证逻辑。- 模拟数据库: 为了简化示例,我们使用一个简单的 JavaScript 对象
users
来模拟数据库。在实际项目中,你需要使用真正的数据库来存储用户信息。 - 域名和 Origin: 在生产环境中,你需要使用 HTTPS,并且确保前端和后端的域名和 Origin 配置正确。
六、注意事项
- HTTPS: WebAuthn 必须在 HTTPS 环境下使用。
- 域名: WebAuthn 认证过程会验证网站的域名,所以你需要确保你的域名配置正确。
- 用户体验: WebAuthn 的用户体验非常重要,你需要设计友好的界面,引导用户完成注册和认证过程。
- 错误处理: WebAuthn 的错误处理比较复杂,你需要仔细处理各种错误情况,并向用户提供有用的错误信息。
- Attestation: Attestation 是一种机制,用于验证认证器的真实性。在生产环境中,建议使用 "indirect" 或者 "enterprise" 类型的 Attestation。
- 存储: 安全地存储用户公钥和凭证 ID 非常重要。使用适当的加密和访问控制措施来保护这些数据。
- 依赖库: 选择合适的 WebAuthn 客户端和服务端依赖库,可以大大简化开发工作。
七、总结
WebAuthn 是一种非常有前途的无密码认证技术,它可以提高网站的安全性,并改善用户体验。虽然 WebAuthn 的实现过程比较复杂,但是有了 JavaScript 和各种 WebAuthn 库的帮助,我们可以更容易地驾驭这头猛兽。
希望今天的分享能够帮助大家更好地理解 WebAuthn 的原理和实现方式。 祝大家编码愉快,早日实现无密码登录!