WebAuthn 无密码登录是目前唯一能从根本上消除钓鱼风险的认证方式:私钥永远留在用户设备的安全芯片里,签名结果与域名强绑定,钓鱼站即使拿到完整页面代码也无法完成认证。本文用可直接复制的代码与配置,讲清 WebAuthn 的注册与登录流程、服务端校验要点、SSH 硬件密钥登录,以及最容易踩错的 origin 与 RP ID 配置。
适用场景
- 后台管理系统、云控制台、运维平台的登录入口,需要抵御钓鱼与凭证填充(撞库)。
- 高权限账号(服务器管理员、财务、数据库运维)要求抗钓鱼的第二因素,替代可被实时转发的短信与 TOTP。
- 已有账号密码体系,希望增加无密码登录通道并逐步迁移。
- SSH 远程登录需要硬件密钥绑定,防止私钥文件被拷贝后异地使用。
前置条件
- 站点已启用 HTTPS,且证书有效——WebAuthn 强制要求安全上下文(
localhost除外)。 - 后端可运行 Node.js 16+(示例使用
@simplewebauthn/server),或使用同规范的其他语言实现。 - 用户侧有可用的认证器:Windows Hello、Touch ID、Android 指纹,或 USB/NFC 安全密钥。
- 数据库可新增表结构,用于存储公钥凭证。
原理说明
WebAuthn 是 FIDO2 规范的浏览器侧接口,认证过程分两个阶段:
- 注册(create):服务端下发
challenge、rp(Relying Party,即域名身份)与用户信息;认证器在本地生成一对密钥,私钥不出芯片,只把公钥和凭证 ID 回传;服务端保存公钥、credentialID、签名计数器。 - 登录(get):服务端下发新的
challenge;认证器用私钥对「认证器数据 + 客户端数据哈希」签名;服务端用保存的公钥验签。
抗钓鱼的关键在绑定关系:签名内容里包含认证器记录的 origin,服务端校验时也检查 origin 与 rpID。攻击者把页面完整克隆到 evil.com 后,认证器生成与校验的 origin 是 https://evil.com,与服务端期望的 https://admin.example.com 不一致,签名必然校验失败。这一点是短信验证码与 TOTP 无法做到的——它们只是「一串数字」,可以被中间人实时索取并转发。
rpID 是这一机制的核心参数:它必须是域名后缀,取值 example.com 时对 admin.example.com 与 www.example.com 同时有效,但绝不能写 IP、端口或完整路径。
操作步骤
步骤一:明确 RP ID 与 origin 白名单
// 服务端统一配置,避免各处硬编码
const RP_ID = 'example.com'; // 只能是域名后缀,不带协议端口
const RP_NAME = 'Admin Console';
const ORIGINS = [ // 允许的完整来源,必须精确匹配
'https://admin.example.com',
'https://example.com',
];
步骤二:实现注册选项下发与校验
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
// 1) 下发注册选项
app.post('/webauthn/register/options', async (req, res) => {
const user = req.session.user;
const devices = await db.getCredentialsByUser(user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(String(user.id)),
userName: user.email,
attestationType: 'none',
timeout: 60000,
authenticatorSelection: {
residentKey: 'preferred', // preferred 便于后续免用户名登录
userVerification: 'preferred', // 要求生物识别或 PIN
},
excludeCredentials: devices.map(d => ({ id: d.credential_id, type: 'public-key' })),
});
// challenge 必须一次性、短有效期:存入 session,60 秒后失效
req.session.challenge = options.challenge;
res.json(options);
});
// 2) 校验注册响应
app.post('/webauthn/register/verify', async (req, res) => {
const expectedChallenge = req.session.challenge;
if (!expectedChallenge) return res.status(400).json({ error: 'challenge expired' });
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGINS,
expectedRPID: RP_ID,
requireUserVerification: true,
});
req.session.challenge = null; // 立即作废,防重放
if (!verification.verified) return res.status(400).json({ error: 'verify failed' });
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
await db.saveCredential({
user_id: req.session.user.id,
credential_id: Buffer.from(credentialID).toString('base64url'),
public_key: Buffer.from(credentialPublicKey).toString('base64'),
counter,
transports: req.body.response.transports || [],
created_at: new Date(),
});
res.json({ ok: true });
});
步骤三:实现登录校验与计数器检查
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/webauthn/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: [], // 留空以支持 residentKey 免用户名登录
userVerification: 'preferred',
timeout: 60000,
});
req.session.challenge = options.challenge;
res.json(options);
});
app.post('/webauthn/login/verify', async (req, res) => {
const cred = await db.getCredentialById(req.body.id);
if (!cred) return res.status(401).json({ error: 'unknown credential' });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: ORIGINS,
expectedRPID: RP_ID,
requireUserVerification: true,
authenticator: {
credentialID: Buffer.from(cred.credential_id, 'base64url'),
credentialPublicKey: Buffer.from(cred.public_key, 'base64'),
counter: cred.counter,
},
});
req.session.challenge = null;
if (!verification.verified) return res.status(401).json({ error: 'verify failed' });
// 计数器只增不减;出现回退说明凭证可能被克隆
const newCounter = verification.authenticationInfo.newCounter;
if (newCounter !== 0 && newCounter <= cred.counter) {
await security.alert('credential clone suspected', { userId: cred.user_id });
return res.status(401).json({ error: 'counter regression' });
}
await db.updateCounter(cred.credential_id, newCounter);
req.session.user = await db.getUserById(cred.user_id);
res.json({ ok: true });
});
前端只需调用原生接口,不要引第三方库接管:
const opts = await (await fetch('/webauthn/login/options', { method: 'POST' })).json();
opts.challenge = base64urlToBuffer(opts.challenge);
opts.allowCredentials = (opts.allowCredentials || []).map(c => ({
...c, id: base64urlToBuffer(c.id),
}));
const assertion = await navigator.credentials.get({ publicKey: opts });
await fetch('/webauthn/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bufferToBase64url(assertion)),
});
步骤四:Nginx 侧强制 HTTPS 并放行 WebAuthn 权限
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/nginx/certs/admin.crt;
ssl_certificate_key /etc/nginx/certs/admin.key;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
# 只有本域可调用 WebAuthn 接口,禁止被 iframe 嵌入
add_header Permissions-Policy "publickey-credentials-create=(self), publickey-credentials-get=(self)" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
location /webauthn/ {
limit_req zone=login burst=10 nodelay; # 防高频探测与撞库
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-Proto https;
}
}
# HTTP 一律跳转 HTTPS,禁止在明文上下文中尝试认证
server {
listen 80;
server_name admin.example.com;
return 301 https://$host$request_uri;
}
注意:Permissions-Policy 写错会让浏览器直接拒绝调用。publickey-credentials-create 与 publickey-credentials-get 必须显式声明为 (self),若站点本身设了严格的 default-src CSP,也要确认未误伤前端脚本。
步骤五:为 SSH 登录启用 FIDO2 硬件密钥
# 生成绑定硬件的 SSH 密钥(私钥无法导出)
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-C "yubikey-admin" -f ~/.ssh/id_ed25519_sk
# 公钥类型为 sk-ssh-ed25519@openssh.com,属主指纹不可导出私钥
cat ~/.ssh/id_ed25519_sk.pub
ssh-keygen -L -f ~/.ssh/id_ed25519_sk.pub # 查看 flags,应含 verify-required
# 上传公钥
ssh-copy-id -i ~/.ssh/id_ed25519_sk.pub root@10.0.0.10
# 服务端只接受硬件密钥,禁用普通 ed25519/rsa 登录
# /etc/ssh/sshd_config
PubkeyAuthentication yes
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com
PasswordAuthentication no
AuthenticationMethods publickey
systemctl reload sshd
配置验证
- HTTPS 与 HSTS:
curl -sI https://admin.example.com | grep -i strict-transport,应返回max-age=31536000。 - 权限策略头:
curl -sI https://admin.example.com/ | grep -i permissions-policy,确认包含两个 publickey-credentials 指令。 - 注册流程:用 Chrome DevTools 的 WebAuthn 面板完成一次注册,面板中
RP ID应显示example.com、Origin应为https://admin.example.com;数据库中应新增一条记录,且counter为 0 或 1。 - 抗钓鱼验证:把注册响应中的
clientDataJSON解码(base64 ... | jq -r .origin思路),手工把origin改为https://evil.example.com后重放,服务端应返回verify failed,证明 origin 校验生效。 - challenge 一次性:同一份注册响应连续提交两次,第二次应因
challenge expired被拒。 - 计数器回退告警:将数据库中的
counter手工改为一个大值后再登录,应触发counter regression拒绝并产生告警日志。 - SSH 限制:在客户端用普通 ed25519 密钥连接,应被拒绝并提示无可用认证方法;换用
-i ~/.ssh/id_ed25519_sk并触摸密钥后应成功登录,journalctl -u sshd可见Accepted publickey ... ED25519-SK。
常见问题(FAQ)
Q1:用户丢失了安全密钥或换手机,怎么恢复?
在注册阶段就强制要求「至少两把凭证 + 一次性恢复码」,不要等到出事才补流程。管理员侧设置独立的凭证吊销接口,按 credentialID 删除失效凭证并记录操作日志;恢复码只在人工核验身份后使用一次。特别要避免的退化做法是:把短信验证码设为唯一的备用通道——那等于把已经消除的钓鱼风险重新引回系统,短信可被 SIM 卡劫持与实时转发。
Q2:passkey 存在云端,还算安全吗?
安全模型不变,变化的是私钥的保管位置。使用 residentKey: 'preferred' 时,认证器可选择把私钥保存在本机安全芯片,或交由平台密码管理器同步(iCloud 钥匙串、Google 密码管理器)。同步机制带来可用性提升,但私钥会出现在厂商云中,走的是端到端加密。对高权限账号建议使用独立硬件安全密钥(USB/NFC),私钥物理不可导出;对普通员工账号可使用平台 passkey。服务端无需改动校验逻辑,两种方式都返回同样的公钥凭证。
Q3:能防住所有钓鱼吗?
防住的是凭证欺骗这一类钓鱼,即攻击者诱导用户在伪造页面上输入密码或验证码。它无法防御的路径有三条:用户终端已被植入木马,可在合法浏览器上下文中代持会话;恶意浏览器扩展直接读取认证后的 Session;以及服务端在认证后的会话管理存在缺陷(如 Session 固定或缺少 SameSite 保护)。因此 WebAuthn 是提升登录环节强度的必要措施,但不能替代终端防护、会话安全与最小权限。
Q4:迁移期间要不要保留密码登录?
保留,但要分阶段收敛:先在后台提供自助绑定入口并统计覆盖率,达到阈值后对高权限角色强制启用 WebAuthn,最后再考虑对全部用户关闭密码通道。迁移期需注意两个细节:绑定凭证时必须验证当前密码或已登录会话,防止账号被接管后被追加攻击者凭证;密码与 WebAuthn 并存期间,密码登录入口要保留全量风控(限流、异地告警),不能因为「已经有更强的认证」而放松检查。
总结
WebAuthn 无密码登录的落地要点集中在四处:RP ID 只写域名后缀、origin 白名单精确匹配、challenge 一次性且短时效、计数器回退必须告警。这四点决定了抗钓鱼能力是否真实存在——只要 origin 校验被人为放宽或 challenge 可重放,攻击者就能用正常提交流程绕过全部强度设计。部署顺序建议为:先上 HTTPS 与 HSTS,再实现注册与登录校验并做一次完整的伪造 origin 回归测试,最后把 FIDO2 硬件密钥扩展到 SSH 等运维入口,形成覆盖 Web 与主机的统一抗钓鱼认证体系。