土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】MFA、TOTP、WebAuthn、Passkey 工程实践

文章导航

分类入口
architecturesecurity

目录

多因子认证(MFA)已经不是「加分项」,而是任何涉及用户资产、企业数据系统的默认要求。从最早的短信验证码,到基于时间的一次性密码(TOTP),再到 FIDO2/WebAuthn 硬件密钥与 Passkey,十几年来 MFA 的形态发生了非常大的变化;但很多团队在落地时仍然在重复早期那套「短信 + 密码」的组合,即使业内已经一致认为它既不安全、又体验糟糕。本文从工程视角出发,讨论各种 MFA 方法的安全等级、TOTP 的计算细节、WebAuthn 协议栈、Passkey 的同步模型以及企业落地时真正踩过的坑。本文是【身份与访问控制工程】系列的第八篇,前文讨论了 Session 与吊销体系(见 Session、Refresh Token 与吊销体系),整体架构视角可参考 认证架构整体概览

WebAuthn 注册与认证流程

一、MFA 方法的安全等级

1.1 等级排序与选择依据

从真实攻击面与工程实践出发,常见 MFA 方法的强度大致如下(从弱到强):

SMS OTP  <  Email OTP  <  TOTP  <  Push 通知  <  Hardware FIDO Key  ≈  Platform Authenticator (WebAuthn)

这里的排序并不是看「密钥长度」或「算法是否安全」,而是综合考虑以下维度:

如果从产品决策角度看,可以先用下面这张表做第一轮筛选:

场景 推荐主因子 / 二因子 原因
普通 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:

因此 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 = 0X = 30sdigits = 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

几个工程细节:

2.3 otpauth URI 与 QR 码

注册时服务端生成一个 otpauth:// URI,客户端扫码导入:

otpauth://totp/Example:alice@example.com
        ?secret=JBSWY3DPEHPK3PXP
        &issuer=Example
        &algorithm=SHA1
        &digits=6
        &period=30

几个常见陷阱:

2.4 Secret 的 at-rest 加密

TOTP secret 本质上是一个长期对称密钥,泄漏即等同于「第二因子被绕过」。生产落地的一般做法:

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 启用时必须配套的功能,否则用户换手机就是客服灾难。工程要点:

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 None

2.6 TOTP 的真实弱点

所以 TOTP 在 2020 年后通常被视为「比 SMS 好,但远不如 WebAuthn」的过渡方案,尤其是对员工内部系统。

三、WebAuthn / FIDO2 协议栈

3.1 概念地图

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/servergo-webauthnpy_webauthn,核心校验逻辑都要覆盖:

注册和登录排障时,最容易漏掉的不是签名算法,而是下面几个「环境绑定」问题:

3.5 Attestation 类型简述

关键点:大部分 to-C 场景用 attestation: "none" 就够了;只有监管/企业场景需要核对具体型号与供应链。Apple 与 Google 为了隐私,默认会返回匿名化的 AAGUID。

3.6 signCount 与克隆检测

每次认证,authenticator 都会把内部计数器 signCount 自增并签入 authenticatorData。RP 必须存一份最近值:

// 伪代码: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 年联合推广的市场名词,本质上是:

从用户视角,它是「一次指纹/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 标记的含义与用法

对高安全场景,RP 可以根据这些标记做差异化策略:

4.4 企业 Passkey 的真实风险

Passkey 带来了体验上的飞跃,但也把信任链从「用户设备」外延到了「云账户」:

因此企业场景的最佳实践通常是:

五、MFA 的用户流失与落地挑战

5.1 用户流失数据

行业里若干公开数据可做参考(不同产品差异很大,数字仅做量级感):

这些数据对产品决策的含义是:MFA 启用永远要和恢复路径、降级策略、用户沟通一起设计。只做 MFA 开关是做不下去的。

5.2 员工设备与 MDM 分发

企业落地 MFA 常见两条路线:

对于有合规审计要求(SOC2、ISO 27001、等保 3)的企业,FIDO key + attestation 基本是绕不开的。

5.3 客服恢复流程

MFA 恢复是整条链路里最容易被社工的一环。典型失陷模式:

  1. 攻击者掌握用户基本信息(从第三方泄漏数据库拿到)。
  2. 冒充用户联系客服,以「手机丢失」「海外出差」为由请求解除 MFA。
  3. 客服按照既有 SOP 验证身份证 + 最近一笔订单,放行。
  4. 攻击者绑定自己的 MFA,完成接管。

对策可以分多层:

六、Passkey 迁移路径

6.1 混合登录策略

大多数产品不可能一夜切到 Passkey,需要准备一个 6~18 个月的共存期:

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);

关键点:

6.3 降级到 TOTP 或密码

降级是体验与安全的拉锯战。建议策略:

七、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 相关

8.2 WebAuthn 相关

8.3 Passkey 相关

8.4 产品与运营相关

九、选型建议

9.1 To-C 消费级产品

9.2 To-B / 企业员工

9.3 管理员账户(super admin)

9.4 新产品起步

十、端到端时序参考

为了给实现团队一个「肉眼可对」的参考,下面把注册与认证两条链路拆成最小粒度的状态机,便于排障时定位到具体环节。

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 已添加」通知邮件(带撤销入口)
   ▼
[可用于后续登录]

每一步失败的常见原因:

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 可观测性建议

十一、参考资料


上一篇Session、Refresh Token 与吊销体系

下一篇风险感知认证:设备信任、异常登录与挑战升级

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】IAM 全景:为什么这是高价值赛道

身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。

2026-04-21 · architecture / security

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。


By .