WebAuthn 无密码登录实战:FIDO2 防钓鱼配置

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 与主机的统一抗钓鱼认证体系。