多因子认证(MFA)已经不是「加分项」,而是任何涉及用户资产、企业数据系统的默认要求。从最早的短信验证码,到基于时间的一次性密码(TOTP),再到 FIDO2/WebAuthn 硬件密钥与 Passkey,十几年来 MFA 的形态发生了非常大的变化;但很多团队在落地时仍然在重复早期那套「短信 + 密码」的组合,即使业内已经一致认为它既不安全、又体验糟糕。本文从工程视角出发,讨论各种 MFA 方法的安全等级、TOTP 的计算细节、WebAuthn 协议栈、Passkey 的同步模型以及企业落地时真正踩过的坑。本文是【身份与访问控制工程】系列的第八篇,前文讨论了 Session 与吊销体系(见 Session、Refresh Token 与吊销体系),整体架构视角可参考 认证架构整体概览。
一、MFA 方法的安全等级
1.1 等级排序与选择依据
从真实攻击面与工程实践出发,常见 MFA 方法的强度大致如下(从弱到强):
SMS OTP < Email OTP < TOTP < Push 通知 < Hardware FIDO Key ≈ Platform Authenticator (WebAuthn)
这里的排序并不是看「密钥长度」或「算法是否安全」,而是综合考虑以下维度:
- 抗钓鱼能力:凭据是否与域名(origin)绑定、用户能否被诱导把凭据输入到伪造页面。
- 抗重放能力:凭据是否一次性、是否受 challenge 保护。
- 通道独立性:凭据是否需要经由不可信通道(SS7、邮箱、推送)传输。
- 设备接管:设备丢失、SIM 卡被盗、邮箱被盗对账户的影响面。
- 批量攻击经济性:攻击者做一次工程投入能否批量拿下账户。
如果从产品决策角度看,可以先用下面这张表做第一轮筛选:
| 场景 | 推荐主因子 / 二因子 | 原因 |
|---|---|---|
| 普通 to-C 登录 | 密码 + Passkey 渐进引导 | 兼顾转化率与抗钓鱼能力 |
| 高风险 to-C 操作 | Passkey / 硬件 key step-up | 改绑手机、转账、导出数据需要更强证明 |
| 企业员工日常办公 | Passwordless Passkey 或硬件 key | 能显著降低钓鱼与客服恢复成本 |
| 管理员 / 财务 / 运维 | 硬件 FIDO key 优先 | 对社工恢复、代理钓鱼更稳 |
| 无法大规模发硬件 key 的过渡期 | TOTP | 比 SMS / Email OTP 稳定,兼容性最好 |
| 不希望增加太多教育成本 | Push + number matching | 体验较好,但必须防 MFA fatigue |
1.2 为什么 SMS OTP 被监管推离主线
SMS OTP 看起来和 TOTP 都是六位数字,但实际上它的攻击面远超 TOTP:
- SIM swap(号码接管):攻击者通过社工运营商客服把受害者号码转到新 SIM 上,此时所有短信 OTP 都会到攻击者手中。美国 T-Mobile、AT&T 历年的 SIM swap 报告、Twitter CEO 本人账号失陷事件都是典型案例。
- SS7 漏洞:运营商信令协议 SS7 允许漫游路由查询被滥用,攻击者可以在不接管 SIM 的情况下将短信重定向到另一个节点。Positive Technologies、Karsten Nohl 在 2014—2017 年的公开演示已经把这一点坐实。
- 运营商内部权限:第三方短信下行通道(国际短信聚合商、运营商内部员工)都可能获取到明文 OTP。
- 国际号码、eSIM、虚拟号:合规与 KYC 薄弱,攻击者可以批量注册拦截。
因此 NIST SP 800-63B 从 Rev.3 开始把 SMS OTP 标记为「restricted」,欧洲 PSD2 RTS(支付安全监管标准)要求强认证元素具备「动态链接 + 抗重放」属性,SMS OTP 在单独使用时基本不符合。美国 CISA、FIDO 联盟、支付行业的 PCI SSC 都在 2022—2024 年间陆续发布了「远离 SMS OTP」的指引。
1.3 Email OTP 的问题
Email OTP 比 SMS OTP 看起来更「工程友好」(无需运营商通道),但在账号接管场景下反而更糟:邮箱往往本身就是账户恢复的终极入口,一旦邮箱被钓鱼或会话 Token 被盗,攻击者同时获得「密码重置 + OTP」两条路径。Email OTP 可以作为新注册用户的低级身份验证,但不应作为高价值账户的第二因子。
1.4 TOTP 的相对强度
TOTP 的凭据从不经过任何通道传输(秘密只在 enrollment 时下发一次),每 30 秒本地计算一次,攻击者只有在实时钓鱼站点前可以使用偷来的 6 位数字。这就是为什么它比 SMS OTP 强得多,但仍然不抗实时中间人钓鱼站点(Evilginx、Modlishka 这类工具在 2019 年之后已经白菜化)。
1.5 Push 通知的两面性
Push 通知(Duo Push、Microsoft Authenticator、Okta Verify)曾被认为体验与安全并重,但 2022 年 Uber、Cisco、Twilio 等事件暴露出「MFA fatigue」攻击:攻击者在拿到密码后高频发起登录,用户被推送轰炸烦到直接批准。现代 Push 通知必须加上 number matching(用户在登录页看到一个数字,需要在手机上点对应数字)才能抗这种攻击。
1.6 WebAuthn / FIDO2 的天花板
WebAuthn 把凭据与 origin 绑定,浏览器只会把凭据发给那个精确的 origin,从根本上杜绝了钓鱼站点「哪怕拿到代码也不能重放」。这是它和 TOTP、Push 的本质差异。硬件 key(YubiKey、SoloKey、Feitian)与 platform authenticator(Touch ID、Windows Hello、Android StrongBox)在密码学上等价,差异主要体现在 attestation、BE/BS 标识和用户体验上。
二、TOTP 工程细节
2.1 算法回顾(RFC 6238 / RFC 4226)
TOTP 建立在 HOTP 之上:
TOTP(K, T) = HOTP(K, T) 其中 T = floor((UnixTime - T0) / X)
HOTP(K, C) = Truncate(HMAC-SHA1(K, C)) mod 10^digits
Truncate(H): offset = H[19] & 0x0F
value = (H[offset] & 0x7F) << 24
| (H[offset+1] & 0xFF) << 16
| (H[offset+2] & 0xFF) << 8
| (H[offset+3] & 0xFF)
典型参数:T0 = 0、X = 30s、digits = 6、算法
SHA1。尽管 HMAC-SHA1 被视为过时,但由于 HMAC
使用场景下对底层哈希要求仅为 PRF,HMAC-SHA1
在可预见未来仍然安全;Google Authenticator、1Password
至今仍默认 SHA1,是考虑兼容性。
2.2 Python 实现一个可验证的 TOTP
import hmac, hashlib, struct, time, secrets, base64
def generate_secret(n_bytes: int = 20) -> str:
# base32 是 otpauth URI 的规范格式
return base64.b32encode(secrets.token_bytes(n_bytes)).decode("ascii").rstrip("=")
def _hotp(key: bytes, counter: int, digits: int = 6, algo=hashlib.sha1) -> str:
msg = struct.pack(">Q", counter)
h = hmac.new(key, msg, algo).digest()
offset = h[-1] & 0x0F
code = (struct.unpack(">I", h[offset:offset+4])[0] & 0x7FFFFFFF) % (10 ** digits)
return str(code).zfill(digits)
def totp(secret_b32: str, at: float | None = None, step: int = 30, digits: int = 6) -> str:
key = base64.b32decode(secret_b32 + "=" * (-len(secret_b32) % 8))
t = int((at if at is not None else time.time()) // step)
return _hotp(key, t, digits)
def verify_totp(secret_b32: str, code: str, at: float | None = None,
step: int = 30, digits: int = 6, window: int = 1) -> bool:
key = base64.b32decode(secret_b32 + "=" * (-len(secret_b32) % 8))
t = int((at if at is not None else time.time()) // step)
for drift in range(-window, window + 1):
if hmac.compare_digest(_hotp(key, t + drift, digits), code):
return True
return False几个工程细节:
window=1表示允许前后各 30 秒的时钟漂移,生产通常取 1 足矣;取 2 以上会线性放大在线猜码的成功率。- 校验必须用
hmac.compare_digest做常数时间比较,防止时序侧信道。 - 生成代码
_hotp中的0x7FFFFFFF是 HOTP 规范中「动态截断」的位掩码,必须保留。 - 不要用
str(code)[-digits:],要用zfill;否则小于 100000 的码会丢前导零。
2.3 otpauth URI 与 QR 码
注册时服务端生成一个 otpauth://
URI,客户端扫码导入:
otpauth://totp/Example:alice@example.com
?secret=JBSWY3DPEHPK3PXP
&issuer=Example
&algorithm=SHA1
&digits=6
&period=30
几个常见陷阱:
label中的冒号用来分隔 issuer 与 account,必须做 URL 编码。- 同一账号重复扫码会在 Authenticator 里产生两条并存条目;UI 上必须明确「重置会作废旧 secret」。
- iOS 的 Safari 会把不识别参数直接丢弃;Microsoft Authenticator 只读 SHA1。如果要用 SHA256 / SHA512,需要事先评估生态兼容性。
2.4 Secret 的 at-rest 加密
TOTP secret 本质上是一个长期对称密钥,泄漏即等同于「第二因子被绕过」。生产落地的一般做法:
- 数据库中只存加密后的密文,KEK 放 KMS,DEK 每条记录独立。
- DB 备份、binlog、数据导出时必须确认不会暴露明文。
- 管理员无法直接读明文 secret(这也是为什么不能提供「重新显示一次 QR 码」的功能,必须重新 enrollment)。
- 严禁与用户密码共表;最好与敏感字段放在独立 schema,走单独审计链路。
CREATE TABLE mfa_totp (
user_id BIGINT PRIMARY KEY,
secret_kms_id VARCHAR(64) NOT NULL,
secret_cipher VARBINARY(256) NOT NULL,
digits TINYINT DEFAULT 6,
step_sec SMALLINT DEFAULT 30,
algo VARCHAR(16) DEFAULT 'SHA1',
created_at DATETIME(3) NOT NULL,
last_used_at DATETIME(3),
disabled_at DATETIME(3)
);2.5 Recovery Codes 的生成与存储
Recovery code(备份码、救援码)是 TOTP 启用时必须配套的功能,否则用户换手机就是客服灾难。工程要点:
- 生成 8~10 个一次性码,每个约 10 字符(Base32 避免易混字符 0/O/1/l)。
- 展示 一次,用户必须自行保存;之后只能重新生成。
- 服务端只存 bcrypt(或
argon2id)哈希;使用时逐一比较并把用掉的标为
consumed。 - 一次使用直接作废,并强烈建议通知用户「你刚用了一个恢复码还剩 N 个」。
- 恢复码使用应视为高风险事件,触发 风险感知认证 中的挑战升级。
import secrets, string, bcrypt
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 去掉 I O 0 1
def gen_recovery_codes(n: int = 10, length: int = 10) -> list[str]:
out = []
for _ in range(n):
raw = "".join(secrets.choice(ALPHABET) for _ in range(length))
# 展示成 XXXXX-XXXXX 的形式
out.append(raw[:5] + "-" + raw[5:])
return out
def hash_codes(codes: list[str]) -> list[bytes]:
return [bcrypt.hashpw(c.replace("-", "").encode(), bcrypt.gensalt(12)) for c in codes]
def consume_code(stored_hashes: list[bytes], input_code: str) -> int | None:
normalized = input_code.replace("-", "").upper().encode()
for idx, h in enumerate(stored_hashes):
if bcrypt.checkpw(normalized, h):
return idx
return None2.6 TOTP 的真实弱点
- 不抗实时中间人钓鱼:用户在 Evilginx 钓鱼站点输入 OTP,攻击者把代码即时转发到真实站点。
- 不抗恶意软件:有 Accessibility 权限的安卓木马可以直接读取 Authenticator 屏幕。
- 多设备共享 secret 会带来不可审计的复制(用户往往把同一 secret 同时存在手机、iPad、1Password 里)。
- 迁移痛点:换手机时如果没有云同步或恢复码,用户只能走客服重置。
所以 TOTP 在 2020 年后通常被视为「比 SMS 好,但远不如 WebAuthn」的过渡方案,尤其是对员工内部系统。
三、WebAuthn / FIDO2 协议栈
3.1 概念地图
- FIDO2 = WebAuthn(浏览器 API 与服务端协议) + CTAP2(浏览器/OS 到外部 authenticator 的传输协议,USB/NFC/BLE)。
- RP(Relying Party):你的 Web 服务。
- Authenticator:硬件 key(YubiKey)、platform authenticator(Touch ID、Windows Hello、Android)。
- Credential:由 authenticator 生成的非对称密钥对,公钥送回服务端,私钥永不离开 authenticator。
WebAuthn 有两个独立的流程(ceremony):注册(Registration)与认证(Authentication)。前者建立绑定、把 credentialId 与公钥登记到 RP;后者使用既有凭据完成登录。流程图参考本文开头的 SVG。
3.2 注册流程(Registration Ceremony)
服务端首先生成一次性 challenge(≥16 字节随机数),并下发
PublicKeyCredentialCreationOptions:
{
"rp": { "id": "example.com", "name": "Example" },
"user": { "id": "base64url(user-handle)", "name": "alice@example.com", "displayName": "Alice" },
"challenge": "base64url(32-byte-random)",
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 }, /* ES256 */
{ "type": "public-key", "alg": -257 } /* RS256 */
],
"authenticatorSelection": {
"residentKey": "preferred",
"userVerification": "preferred"
},
"attestation": "none",
"timeout": 60000,
"excludeCredentials": [ /* 已绑定 credId,避免重复注册 */ ]
}浏览器调用
navigator.credentials.create({ publicKey: options }),authenticator
生成 keypair,返回:
{
"id": "base64url(credentialId)",
"rawId": "...",
"type": "public-key",
"response": {
"clientDataJSON": "base64url(...)",
"attestationObject": "base64url(CBOR)"
}
}clientDataJSON 是浏览器产生的 UTF-8
JSON(不是 CBOR),结构:
{
"type": "webauthn.create",
"challenge": "base64url(...)",
"origin": "https://example.com",
"crossOrigin": false
}attestationObject 是 CBOR 编码的结构:
{
"fmt": "none" | "packed" | "tpm" | "android-key" | "apple" | "fido-u2f",
"attStmt": { ... }, // 与 fmt 对应的签名材料
"authData": <bytes> // authenticatorData,见下
}
3.3 authenticatorData 字段
authenticatorData 是定长 +
可选尾部的二进制:
| rpIdHash (32) | flags (1) | signCount (4) | attestedCredentialData? | extensions(CBOR)? |
flags 位含义:
0x01 UP User Present
0x04 UV User Verified
0x08 BE Backup Eligible (凭据可被云端同步)
0x10 BS Backup State (当前已处于备份/同步状态)
0x40 AT Attested Credential Data present(仅注册时为 1)
0x80 ED Extension Data present
attestedCredentialData:
| aaguid(16) | credIdLen(2, big-endian) | credId | COSE_Key(CBOR) |
AAGUID 用来标识 authenticator 的「型号」,可以结合 FIDO MDS(Metadata Service)做风险评估,例如「禁止未通过 FIDO L1 认证的 key」或「企业内只允许 YubiKey 5 系列」。
3.4 服务端校验清单
无论实现用的是
@simplewebauthn/server、go-webauthn、py_webauthn,核心校验逻辑都要覆盖:
clientDataJSON.type == "webauthn.create"(或认证时的"webauthn.get")。clientDataJSON.challenge与服务端发出的 challenge 一致且尚未使用。clientDataJSON.origin在允许列表中(注意 iOS PWA、App Link 可能使用自定义 scheme)。authenticatorData.rpIdHash == SHA-256(rp.id)。- 注册场景:
flags.UP == 1;若要求 UV 则flags.UV == 1;flags.AT == 1且解析出 credentialId 与 public key。 - Attestation:若策略为
none则跳过;否则按fmt验证链(packed 要校验证书到根、tpm 还要校验 AIK 证书)。 - 认证场景:验证
signature == sign(authData || SHA-256(clientDataJSON));signCount严格单调递增(见 3.6)。
注册和登录排障时,最容易漏掉的不是签名算法,而是下面几个「环境绑定」问题:
rp.id与真实域名不一致;origin因反向代理、PWA、App Link 或自定义域配置错误而漂移;- challenge 被重复使用,或回调到了错误的浏览器 tab;
- 明明要求强认证,却没有校验
UV; - Passkey 同步后
BE/BS变化,却还沿用老的风控假设; signCount在多设备同步 Passkey 场景下不再可靠,不能机械地「只要不递增就封号」。
3.5 Attestation 类型简述
none:最常见。Passkey、macOS/iOS 与多数企业部署默认使用。RP 不能识别具体型号,但可以接受绝大多数 authenticator。packed:FIDO 定义的通用格式,带 AAGUID 和 X.509 证书链,可用 FIDO MDS 做型号识别。tpm:Windows Hello 使用 TPM 出具的 attestation,证书链可追溯到 TPM 厂商根。android-key:Android StrongBox/Keystore 出具的硬件背书。apple:Apple platform authenticator(Face ID/Touch ID)特定格式,attestation 不含稳定设备标识,防跨站跟踪。fido-u2f:U2F 兼容格式,字段布局特殊(认证数据头不同),遗留场景才会遇到。
关键点:大部分 to-C 场景用
attestation: "none"
就够了;只有监管/企业场景需要核对具体型号与供应链。Apple 与
Google 为了隐私,默认会返回匿名化的 AAGUID。
3.6 signCount 与克隆检测
每次认证,authenticator 都会把内部计数器
signCount 自增并签入
authenticatorData。RP 必须存一份最近值:
- 新值 > 旧值:正常。
- 新值 ≤ 旧值:认为设备被克隆,按策略拒绝或要求降级挑战。
- 新值恒为 0:部分 authenticator(尤其是某些 Passkey 的同步凭据)明确返回 0,表示不再提供克隆检测,此时无法据此判断,需要在策略上豁免。
// 伪代码:signCount 更新策略
if sc := authData.SignCount; sc != 0 {
if sc <= cred.LastSignCount {
return ErrPossibleClone
}
cred.LastSignCount = sc
} else {
// 同步 Passkey 或不支持计数,按策略忽略
cred.LastSignCount = 0
}四、Passkey:可同步的 WebAuthn 凭据
4.1 Passkey 到底是什么
「Passkey」是 Apple、Google、Microsoft、FIDO 联盟在 2022 年联合推广的市场名词,本质上是:
- 一个 WebAuthn discoverable
credential(又叫 resident key),即 authenticator
自己能「想得起」用户是谁,不需要 RP 提供
allowCredentials。 - 通常由 platform authenticator(手机、Mac)生成。
- 私钥通过厂商云端(iCloud Keychain、Google Password Manager、Microsoft Authenticator Sync)在同一账号的设备间加密同步。
- 协议层通过
authenticatorData.flags中的BE=1(backup eligible)和BS=1(backup state)对 RP 做出披露。
从用户视角,它是「一次指纹/Face ID 登录,换手机自动可用」。从 RP 视角,它是一把与域名绑定的非对称凭据,能抗钓鱼;但它不再是「一机一钥」。
4.2 Resident Key vs Server-side Credentials
| 维度 | Server-side credential(非 resident) | Resident key / discoverable credential |
|---|---|---|
| 私钥存储 | 仅密钥句柄;凭据信息加密后封在 credentialId
里,由服务端存 |
authenticator 本地保存完整凭据(user handle、rpId、私钥) |
| 登录流程 | 用户必须先报上 username,服务端下发
allowCredentials |
支持无 username 登录(conditional UI / 一键登录) |
| 空间占用 | authenticator 侧几乎不占空间 | 占用 authenticator 槽位(YubiKey 5 约 25 个 FIDO2 slot) |
| 迁移性 | 绑死于一把硬件 key | 可随账户云同步(若是 Passkey) |
企业里常见的做法是:员工系统使用非 resident 的硬件 key(做 MFA),消费者产品使用 resident key Passkey(做首选登录方式)。
4.3 BE / BS 标记的含义与用法
BE=0, BS=0:硬件绑定凭据(single-device credential),例如 YubiKey。BE=1, BS=0:可备份但当前未备份(例如 Passkey 注册完成但云同步尚未开始)。BE=1, BS=1:已处于云同步状态(典型 Passkey)。BE=0, BS=1:协议上不合法。
对高安全场景,RP 可以根据这些标记做差异化策略:
- 银行转账、管理员后台:要求
BE=0(硬件单设备凭据),拒绝纯 Passkey。 - 普通登录:允许任意。
- 企业租户:通过 AAGUID + BE 组合白名单限定认可的 authenticator。
4.4 企业 Passkey 的真实风险
Passkey 带来了体验上的飞跃,但也把信任链从「用户设备」外延到了「云账户」:
- iCloud / Google 账户接管:只要攻击者拿下用户的云账户并通过 Apple ID 二次认证,所有 Passkey 都会同步过去;原本强抗钓鱼的 WebAuthn,其安全性被上推到一个可能只用密码 + SMS 的账户上。
- 设备共享:家庭共享 iCloud 的场景下,账号持有人家属可能在其设备上看到并使用同一 Passkey。
- 跨平台同步链碎裂:用户从 iPhone 切换到 Android,没有标准的跨生态迁移通道;2024 年 FIDO 联盟发布 CXP(Credential Exchange Protocol)草案后才开始推进,但落地还需要时间。
- 降级攻击:攻击者让用户「这个设备没有密钥,请使用密码 + TOTP」,如果 RP 默认保留密码回退路径,就等于给了一条旁路。
- 账户恢复悖论:企业常把「忘记 Passkey」的恢复流程外包给客服,客服往往又只能用 Email/短信验证,形成了最弱一环。
因此企业场景的最佳实践通常是:
- 对员工:强制硬件 key + 关闭 Passkey 同步,或者使用具备「企业 attestation」能力的托管 authenticator。
- 对高价值 to-C 用户:在 Passkey 之外保留一把硬件 key 作为 re-auth 凭据。
- 对普通 to-C:接受 Passkey 的风险模型,但在敏感操作(改绑手机、转账、导出数据)上做 step-up。
五、MFA 的用户流失与落地挑战
5.1 用户流失数据
行业里若干公开数据可做参考(不同产品差异很大,数字仅做量级感):
- 强制启用 TOTP:新注册流失可能上升 5%~15%;老用户被动开通的拒绝率可能达到 20% 以上,直到加入「先试用 30 天」或「仅对敏感操作要求」的缓冲。
- SMS OTP 转 TOTP:一部分用户因为换手机后无法恢复,直接流失。
- Passkey(conditional UI)作为登录首选:Google 2023 年的数据表明平均登录时长从 30 秒降至 ~6 秒,登录成功率上升,但仍有 ~20% 用户因为「不理解指纹弹窗」选择降级到密码。
- 硬件 key:在企业里部署成本在每人 $30~$80(含备用 key),但一旦全量启用,凭据钓鱼几乎清零(参考 Google 内部 2017—2018 年数据:零确认钓鱼失陷)。
这些数据对产品决策的含义是:MFA 启用永远要和恢复路径、降级策略、用户沟通一起设计。只做 MFA 开关是做不下去的。
5.2 员工设备与 MDM 分发
企业落地 MFA 常见两条路线:
- TOTP + MDM:管理员通过 MDM(Jamf、Intune)推送 TOTP app,并把 secret 通过 app 配置下发。好处是成本低;坏处是 secret 在 MDM 通道中可审计但非机密,且一旦员工离职必须做 enrollment 轮换。
- Hardware FIDO key:采购 YubiKey 或 Feitian key,入职时与员工绑定,离职时回收或远程吊销。成本高但安全性和审计性最好;搭配 FIDO MDS 与企业 attestation,可以精确到「每个员工在哪个序列号的 key 上登录」。
对于有合规审计要求(SOC2、ISO 27001、等保 3)的企业,FIDO key + attestation 基本是绕不开的。
5.3 客服恢复流程
MFA 恢复是整条链路里最容易被社工的一环。典型失陷模式:
- 攻击者掌握用户基本信息(从第三方泄漏数据库拿到)。
- 冒充用户联系客服,以「手机丢失」「海外出差」为由请求解除 MFA。
- 客服按照既有 SOP 验证身份证 + 最近一笔订单,放行。
- 攻击者绑定自己的 MFA,完成接管。
对策可以分多层:
- 恢复流程要求至少两个独立证据(真实签约身份 + 活体 + 银行卡后四位),并强制延迟 24~72 小时。
- 高价值账户启用「指定授权人」机制:账户主人预先登记两位受信联系人,只有他们一致确认才可恢复。
- 任何通过客服恢复的账户,登录后强制重新启用 MFA,并对最近 N 天敏感操作做人工复核。
- 客服侧所有 MFA 恢复请求全量录音 + 独立二次审核,审计留存 ≥ 1 年。
- 将恢复事件作为强信号传给风险感知系统(见 风险感知认证)。
六、Passkey 迁移路径
6.1 混合登录策略
大多数产品不可能一夜切到 Passkey,需要准备一个 6~18 个月的共存期:
- 保留 password + TOTP 作为 fallback。
- 引导用户在登录后、设置页、敏感操作后,逐步注册 Passkey(渐进式上屏)。
- 当用户已经有 Passkey 时,默认用 Passkey,并把密码相关入口降权。
- 监控「密码 + TOTP」的使用比例,低于某个阈值(例如 10%)后再评估关闭密码。
- 对 B2B 管理员账户,可以选择更激进的 Passkey-only。
6.2 Conditional UI(autofill)
Conditional UI 让浏览器在普通登录页的 username 输入框里直接唤出 Passkey 选择器。用户只要点击输入框,就会看到自己的 Passkey 选项,一键登录。前端只需少量代码:
<form id="login-form">
<input type="text" name="username" autocomplete="username webauthn" />
<input type="password" name="password" autocomplete="current-password" />
<button type="submit">登录</button>
</form>async function enablePasskeyAutofill() {
if (!("PublicKeyCredential" in window)) return;
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (!available) return;
const opts = await fetch("/login/begin", { method: "POST" }).then(r => r.json());
opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge);
if (opts.publicKey.allowCredentials) {
opts.publicKey.allowCredentials = opts.publicKey.allowCredentials.map(c => ({
...c, id: base64urlDecode(c.id),
}));
}
try {
const assertion = await navigator.credentials.get({
publicKey: opts.publicKey,
mediation: "conditional", // 关键:仅在用户与 autofill 交互时返回
signal: abortController.signal,
});
const body = encodeAssertion(assertion);
const res = await fetch("/login/finish", {
method: "POST", body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
if (res.ok) location.href = "/home";
} catch (e) {
// 用户取消或切回密码,不抛错
}
}
document.addEventListener("DOMContentLoaded", enablePasskeyAutofill);关键点:
autocomplete="username webauthn"是 HTML 规范里告诉浏览器这是 Passkey 入口的唯一正式方式。mediation: "conditional"必须指定,否则会立即弹出系统对话框。- 多个登录页共用一个 challenge 端点时,要处理 challenge 过期(通常 60s 左右)与一次性使用。
6.3 降级到 TOTP 或密码
降级是体验与安全的拉锯战。建议策略:
- 同设备多账号:用户若在 autofill 里选了错误账号,UI 必须允许切换而不直接降级。
- 新设备:优先跨设备 Passkey(hybrid flow / caBLE,扫码用另一台设备完成认证),其次 TOTP。
- 无任何 Passkey 与 TOTP:进入客服恢复流程,不要默认允许 SMS OTP 重置密码。
- 风险联动:结合 风险感知认证 的设备指纹、IP 信誉、行为特征,决定是否允许降级。
七、WebAuthn 服务端校验伪代码
下面给出一个较为完整的服务端校验骨架(Go-like
伪代码),真实实现请使用
github.com/go-webauthn/webauthn、@simplewebauthn/server
等经过审计的库:
type RegistrationResult struct {
CredentialID []byte
PublicKeyCOSE []byte
SignCount uint32
AAGUID [16]byte
BE, BS, UV, UP bool
}
func VerifyRegistration(req RegistrationRequest, session Challenge) (*RegistrationResult, error) {
clientData, err := parseJSON(req.ClientDataJSON)
if err != nil { return nil, err }
if clientData.Type != "webauthn.create" { return nil, ErrBadType }
if !constTimeEqual(clientData.Challenge, session.Challenge) { return nil, ErrBadChallenge }
if !allowedOrigin(clientData.Origin) { return nil, ErrBadOrigin }
attObj, err := parseCBOR(req.AttestationObject)
if err != nil { return nil, err }
authData, err := parseAuthenticatorData(attObj.AuthData)
if err != nil { return nil, err }
rpIdHash := sha256(session.RpId)
if !bytesEqual(authData.RpIdHash, rpIdHash) { return nil, ErrBadRpId }
if !authData.Flags.UP { return nil, ErrNoUserPresent }
if session.RequireUV && !authData.Flags.UV { return nil, ErrNoUserVerified }
if !authData.Flags.AT { return nil, ErrMissingAttestedCred }
if session.AttestationPolicy != "none" {
if err := verifyAttestation(attObj.Fmt, attObj.AttStmt, attObj.AuthData, req.ClientDataJSON); err != nil {
return nil, err
}
}
return &RegistrationResult{
CredentialID: authData.CredentialID,
PublicKeyCOSE: authData.CredentialPubKey,
SignCount: authData.SignCount,
AAGUID: authData.AAGUID,
BE: authData.Flags.BE,
BS: authData.Flags.BS,
UV: authData.Flags.UV,
UP: authData.Flags.UP,
}, nil
}
func VerifyAuthentication(req AssertionRequest, session Challenge, cred StoredCredential) error {
clientData, err := parseJSON(req.ClientDataJSON)
if err != nil { return err }
if clientData.Type != "webauthn.get" { return ErrBadType }
if !constTimeEqual(clientData.Challenge, session.Challenge) { return ErrBadChallenge }
if !allowedOrigin(clientData.Origin) { return ErrBadOrigin }
authData, err := parseAuthenticatorData(req.AuthenticatorData)
if err != nil { return err }
if !bytesEqual(authData.RpIdHash, sha256(session.RpId)) { return ErrBadRpId }
if !authData.Flags.UP { return ErrNoUserPresent }
if cred.RequireUV && !authData.Flags.UV { return ErrNoUserVerified }
signBase := append(req.AuthenticatorData, sha256(req.ClientDataJSON)...)
if !verifySignature(cred.PublicKeyCOSE, signBase, req.Signature) {
return ErrBadSignature
}
if authData.SignCount != 0 {
if authData.SignCount <= cred.LastSignCount {
return ErrPossibleClone
}
cred.LastSignCount = authData.SignCount
}
cred.LastUsedAt = now()
return saveCredential(cred)
}八、工程坑点
以下是我们与行业同行踩过的坑,按主题归类:
8.1 TOTP 相关
- 时钟漂移:服务器时间务必用 NTP 强同步;偏差超过 ±30s 会出现灵异的「验证码对不上」,排查很痛。
- window 过大:曾见到有团队把
window开到 5~10 来「兼容」,结果一次 OTP 可重放 10 次。控制在 ≤1。 - 重放:同一窗口的 OTP 要做「消费记录」,30 秒内同一 code 只能用一次。
- secret 回显:有产品在「重新绑定」时允许用户点一下再看一次 secret,这等于把 secret 存在了浏览器历史里。绝对不要做。
- Recovery code 复用:一定要标记 consumed,一次失败后强制让攻击者暴露其他 code。
- 编码不一致:有厂家用 hex,有用 base32,有甚至把 Base32 的「=」padding 也给吃了。生成端按 RFC 4648 标准化。
8.2 WebAuthn 相关
- rp.id 搞错:rp.id 必须是当前 origin
的可注册域(eTLD+1 或子域),跨 eTLD+1 会导致
SecurityError。多子域部署要统一到父域做 rp.id。 - IP 与 localhost:WebAuthn 只在 HTTPS 与 localhost 下工作;IP 地址不被当作合法 rpId。
- iframe / 第三方登录:默认情况 WebAuthn
不允许跨源 iframe 调用;需要用
publickey-credentials-getPermissions Policy 授权,且有浏览器限制。 - challenge 不够随机:曾见过直接
Math.random()生成 challenge,被反向推演攻击。必须用 CSPRNG。 - origin
白名单漏配:小程序、壳应用(Electron、WebView)的
origin 可能是
chrome-extension://...或app://...,需要显式允许或拒绝。 - attestation 验证错误:开发时用
none,上线时切到direct,却忘记接入 FIDO MDS,证书链校验永远失败。 - signCount 策略:忽略 signCount 的克隆检测是常见懒惰做法;至少对硬件 key(BE=0)场景必须强制校验。
- user.id 用成了可变字段:user.id 必须是账户内部不变的句柄(ULID / UUID),绝不可以是邮箱、手机号,否则用户改邮箱后 discoverable credential 会错位。
- excludeCredentials 缺失:注册时未把已有 credId 放进 excludeCredentials,导致同一个 authenticator 绑了多次,统计与吊销都麻烦。
8.3 Passkey 相关
- BE/BS 未纳入策略:高敏感操作把可同步 Passkey 当硬件 key 看待,风险模型漏洞。
- 无跨平台迁移预期:用户从 iPhone 换到 Android 时发现 Passkey 丢了,产品侧必须显式引导重新注册,而不是假设「云会搞定」。
- 多 Passkey 同账户去重:用户会在 iCloud、Google、1Password 上各绑一把 Passkey,RP 侧要展示列表与逐个吊销能力。
- 隐藏的 Mixed 来源:Chrome Android 会把 caBLE 混合流程的 Passkey 以 Android platform authenticator 的身份返回,AAGUID 与 attestation 取决于 CTAP 通道,别指望能区分「手机是新的还是老的」。
8.4 产品与运营相关
- 默认不开启 MFA 等于没开启;但强制开启必须做好 recovery。
- 只做「设置页里有个开关」的 MFA,实际启用率 <5%;必须在敏感操作前 step-up。
- 「用邮件发 MFA 二维码」是常见反模式:这会让攻击者只要能看邮件就能复制 MFA。enrollment 必须在已认证会话内完成。
- 把 MFA 当作「登录后就永远信任」是错误的;会话吊销、敏感操作 re-auth 是必须(参见 Session、Refresh Token 与吊销体系)。
- 零信任架构中 MFA 是设备验证链的一环,而不是全部。详见 零信任中的设备验证。
九、选型建议
9.1 To-C 消费级产品
- 首选:Passkey(conditional UI)。用户体验最好,抗钓鱼,设备迁移友好。
- 次选:TOTP。作为 Passkey 未就绪或用户主动选择时的备份。
- 禁用或仅作提示:SMS OTP。如果为了兼容部分老用户必须保留,限定为「低敏感场景 + 风控通过」。
- 高价值操作:登录 Passkey 通过后,敏感操作(转账、改绑、导出)额外 step-up;可结合 风险感知认证 的挑战升级。
9.2 To-B / 企业员工
- 首选:FIDO2 硬件 key(BE=0)+ 企业 attestation + FIDO MDS 白名单。
- 次选:platform authenticator(Windows Hello、Touch ID)+ MDM 强制 UV + 禁用 Passkey 同步(企业策略可配置)。
- 禁用:SMS OTP、Email OTP 作为主要因素。
- 客服恢复:至少两人复核 + 时延 24~72 小时,任何重置后 48 小时内禁敏感操作。
9.3 管理员账户(super admin)
- 强制 2 把硬件 key(主 + 备),禁用 Passkey 同步,禁用密码回退。
- 所有登录触发告警与 SIEM 记录。
- 周期性 re-enrollment(例如一年一次)。
9.4 新产品起步
- 直接跳过 SMS OTP,第一版就支持 Passkey + TOTP。
- 保留一个「无 Passkey / 无 TOTP」的谨慎降级路径,但默认所有新用户启用某种非密码因素。
- 注册时同时派发恢复码;用户不保存恢复码就不允许完成。
十、端到端时序参考
为了给实现团队一个「肉眼可对」的参考,下面把注册与认证两条链路拆成最小粒度的状态机,便于排障时定位到具体环节。
10.1 注册状态机
[未登录访客]
│ 完成一次主因素登录(密码 / magic link / SSO)
▼
[已认证会话 S1]
│ 用户进入「安全设置 → 添加 Passkey」
▼
[Challenge 已下发 C1] (服务端写入 Redis:key=session+purpose, ttl=60s, once)
│ 浏览器 create() 成功
▼
[attestationObject 待校验]
│ 服务端校验:origin/rpId/challenge/flags/attestation
▼
[credential 已登记] (写库:credId, pubKey, signCount=0, aaguid, be/bs, uv, 名称)
│ 发送「新 Passkey 已添加」通知邮件(带撤销入口)
▼
[可用于后续登录]
每一步失败的常见原因:
[Challenge 已下发 C1]阶段超时:用户在系统提示框上发呆 60s 以上;解决:在 UI 上显式倒计时。[attestationObject 待校验]阶段 rpId 不匹配:多半是把a.example.com与example.com混用;解决:统一到 eTLD+1 并在所有子域声明。[credential 已登记]阶段唯一约束冲突:同一把 authenticator 重新注册但未传excludeCredentials;解决:补齐列表或允许覆盖并通知。
10.2 认证状态机
[匿名访客 → 登录页]
│ 触发 Conditional UI 或显式选择 Passkey
▼
[Challenge C2 已下发] (ttl=60s,绑定登录会话指纹)
│ navigator.credentials.get()
▼
[assertion 待校验]
│ 服务端:签名 / origin / rpId / challenge / signCount / UV
▼
[登录成功,签发会话] (参考《Session 与 Refresh Token》)
│
├─→ 若 signCount 异常:记录事件、拒绝或要求 step-up。
└─→ 若 BE=1 BS=1 且目的敏感:进入 [敏感操作 step-up] 分支。
10.3 可观测性建议
- 每一次 ceremony 记录:user_id、credential_id、aaguid、fmt、flags、signCount old/new、耗时。
- 指标维度:成功率、平均耗时、按 UA/平台/AAGUID 拆分的失败率。出现某个 AAGUID 的失败率突增,往往是某款 authenticator 或浏览器升级引入的兼容问题。
- 日志里不要打
clientDataJSON原文(含 origin、challenge),避免触发数据合规红线;取摘要即可。 - 将 MFA 相关事件纳入统一风控事件总线,以供 风险感知认证 消费。
十一、参考资料
- RFC 4226 “HOTP: An HMAC-Based One-Time Password Algorithm”
- RFC 6238 “TOTP: Time-Based One-Time Password Algorithm”
- RFC 4648 “The Base16, Base32, and Base64 Data Encodings”
- W3C “Web Authentication: An API for accessing Public Key Credentials – Level 3”
- FIDO Alliance “Client to Authenticator Protocol (CTAP) 2.2”
- FIDO Alliance “Passkeys (Passkey Authentication)” 白皮书系列
- FIDO Alliance “Metadata Service (MDS) 3.0”
- NIST SP 800-63B “Digital Identity Guidelines: Authentication and Lifecycle Management”
- OWASP “Multifactor Authentication Cheat Sheet”
- OWASP “Authentication Cheat Sheet”
- Google Security Blog “Passkeys: A shared journey to replace passwords”(2023)
- Apple Platform Security Guide “Passkeys” 章节
- Microsoft Learn “Passwordless authentication options for Microsoft Entra ID”
- CISA “Implementing Phishing-Resistant MFA”(2022)
- Krebs on Security “A Closer Look at the Twitter SIM-Swap Saga”
- FIDO Alliance “Credential Exchange Protocol (CXP) Working Draft”
- Yubico Developer Docs “WebAuthn Developer Guide”
- SimpleWebAuthn 文档与源码(typescript 参考实现)
- py_webauthn、go-webauthn 等开源实现的 issue 区,尤其是 attestation 与 signCount 相关的讨论
上一篇:Session、Refresh Token 与吊销体系
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】IAM 全景:为什么这是高价值赛道
身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题