
做了几年带账号体系的系统每次接到“给后台加两步验证”的需求我第一反应就是那串被扫进手机里的二维码。刚开始图省事直接抄一段网上的案例代码能扫能过就完事。结果真到线上什么扫码后验证码不对、某些手机扫出来是纯文本、二维码糊成一团识别不了各种问题全冒出来。回头一看问题根本不是出在验证器上而是出在从密钥生成到二维码内容拼装的整条链路上。这篇文章打算把这套流程从头到尾拆一遍按实际开发顺序来otpauth 协议内容、密钥和安全随机数、TOTP 的动态码计算、二维码生成 API 的设计与实现、用户扫码验证的闭环最后再补一堆真实环境里踩过的坑。核心目标只有一个你照着走完不只会调库还能自己讲清楚“用户扫进验证器的到底是什么东西”。适合所有要在 Web 或移动端里接入 Google Authenticator 兼容多因素认证的后端开发、全栈工程师以及正在排查“手机验证码和服务器不一致”这类问题的人。1. 二维码里装了什么先读一遍 otpauth 协议很多人以为二维码里面藏的是账号和密码其实藏在二维码里的只是一个 URI标准叫法是otpauth://协议。Google Authenticator 也好其它兼容的身份验证器也好它们扫二维码时真正做的事就是解析这段 URI然后把里面的密钥保存到本地。如果你不理解这个 URI 每个字段的含义后面无论怎么调参都容易出问题。1.1 被扫进手机的不是一串乱码而是一条URI先看一条完整的二维码内容otpauth://totp/某运营后台:alice?secretJBSWY3DPEHPK3PXPissuer某运营后台algorithmSHA1digits6period30这看起来像 URL但 scheme 不是 http 或 https而是otpauth。它和普通网址的区别很大——你点它不会打开网页当你用身份验证器去扫这段内容时验证器才会识别并保存信息。如果用普通的微信扫一扫或相机去扫就只会显示出一段类似上面的纯文本因为普通扫码工具不认otpauth这个协议也不应该认。这个细节很关键我后面排查问题时会经常提到。拆开看结构totp协议后面的第一部分表示动态口令类型。现在大多数业务都使用 TOTP也就是基于时间的一次性密码。还有一种类型是 HOTP基于计数器Google Authenticator 早期也支持但业界现在基本不再用。某运营后台:alicepath 部分的 label冒号前是发行者名称冒号后是账号标识。它的作用是在验证器 App 里显示为一条条目方便用户区分是哪套系统的验证码。?后面的 query 参数包含密钥、算法、位数、时间步长等配置。记住一个原则二维码里的内容本质上不是给你看的是给身份验证器看的。它必须严格遵循协议有一点偏差验证器可能拒绝导入或者导入后算出来的验证码和服务端不匹配。1.2 参数逐项拆解otpauth URI 的 query 参数是这套流程里最容易被忽略也最容易出错的地方。我整理了一张表参数示例值是否必填说明secretJBSWY3DPEHPK3PXP必填用户的私有密钥Base32 编码issuer某运营后台选填但强烈建议发行者名称用于区分多个账号algorithmSHA1选填默认 SHA1哈希算法部分验证器支持 SHA256/SHA512digits6选填默认 6动态码位数绝大多数场景用 6 位period30选填默认 30TOTP 时间步长单位秒有几个点值得展开说说。secret是最核心的参数它是一串经过 Base32 编码的随机字节。Base32 的字符集是A-Z和2-7之所以不用 Base64是因为 Base32 去掉了容易混淆的字形而且不计大小写方便用户在手机丢失、需要手动输入密钥时把字符串用键盘敲进去。你会发现市面上验证器导出的密钥全是这种大写字母和数字的混合没有或这种符号就是这个原因。issuer这个参数很容易踩坑。协议规范里要求如果 URI 的 label 里已经带了发行者比如某运营后台:alice那么 query 里的issuer参数应该和 label 里的发行者保持一致。如果不一致有些验证器尤其是新版本会弹出警告甚至拒绝导入。我见过有同事把 label 里的发行者写成了ExampleApp而issuer参数漏传导入后账号显示是乱掉的用户根本分不清是哪套系统的验证码。algorithm和digits一般直接用默认值即可不建议为了炫技改成 SHA256 或 8 位数字。Google Authenticator 的老版本只认 SHA1很多内部系统为了兼容所有设备老老实实用 SHA1 反而最省事。8 位数字看着更安全但用户输入时压力会变大而且对验证器的兼容性没有明显收益这属于典型的“加了复杂度但没换来安全收益”的改动。1.3 验证器拿到内容后做什么理解了 URI 参数再往后想一步验证器扫完二维码后本地到底发生了什么这是排查问题的核心。验证器解析出secret之后会把它作为加密密钥保存在本地的安全存储里。接下来它每隔 30 秒就是period的值执行一次计算用当前 Unix 时间戳除以 30向下取整得到一个计数器然后以secret为密钥对这个计数器做 HMAC-SHA1 运算最后从结果中提取出 6 位数字显示在屏幕上。注意动态码的计算全部是在用户手机本地完成的不需要向服务器发起请求。服务器只做一件事用同样的算法和同样的密钥在收到用户输入的动态码时用当前时间也计算一遍然后比较两边结果是否一致。这就是为什么“手机时间不准”会成为 TOTP 校验失败的头号原因。你在服务器上用 14:00:05 计算了一个动态码用户手机慢了 5 分钟手机算出的动态码对应的其实是 13:55:05 的时间窗口两边结果自然对不上。理解了这条链路后面排查代码就再也不至于一头雾水了。2. 密钥生成与 TOTP 计算原理二维码只是载体真正的安全核心是那段密钥和动态码算法。这一节把密钥生成和 TOTP 计算从底层讲清楚别只停留在“调用一个返回二维码的库”这种层面。2.1 随机密钥与Base32生成密钥的第一步是拿到一串足够强的随机字节。很多开发者在网上抄代码时写的是Math.random()或Random()这种普通随机数生成器这是很大的错误。密钥本身就是你账号的第二道防线它必须不可预测。正确做法是使用操作系统的安全随机源比如 Python 的os.urandom()、Java 的SecureRandom或者 JavaScript 环境里的crypto.randomBytes。密钥长度建议至少 20 字节160 位。20 字节是 Google Authenticator 官方推荐的默认长度既保证了熵值足够又不会让 Base32 编码后的字符串长到用户难以手动输入。如果嫌不够可以用 32 字节但一般情况下 20 字节已经够了。生成随机字节后要编码成 Base32 字符串因为 otpauth URI 里要求的secret就是 Base32 格式。看这段 Python 示例import os import base64 def generate_secret(byte_length20): raw os.urandom(byte_length) secret base64.b32encode(raw).decode(utf-8) return secret这里有两个细节建议注意。第一Base32 编码后默认是带填充字符的比如JBSWY3DPEHPK3PXP。在 otpauth URI 里最好去掉填充字符或者确保你使用的库能正确解析带填充的字符串。我去掉填充字符是因为有些老设备在解析 URI 时对的处理不一致去掉之后实测兼容性更好。第二当用户需要手动输入密钥时验证器对输入内容通常会忽略空格和大小写。比如用户输入jbsw y3dp ehpk 3pxp验证器自动把空格去掉并转成大写。但服务端存储时我强烈建议统一转成大写避免后续比较时出现大小写不一致的坑。2.2 六位数字的诞生HMAC-SHA1 与时间窗口现在进入核心算法。前面讲验证器拿secret和period计算动态码其实背后就是 TOTPRFC 6238。TOTP 是基于 HOTPRFC 4226扩展出来的区别只在于 HOTP 每次验证后计数器加一而 TOTP 用时间戳除以时间步长作为计数器。计算步骤如下取当前 Unix 时间戳比如1692691200。除以 30向下取整得到计数器 C。例如1692691200 / 30 56423040。把 C 编码成 8 字节的大端序二进制串。用 Base32 解码出来的原始密钥作为 HMAC 密钥对第 3 步的二进制串执行 HMAC-SHA1得到 20 字节摘要。取摘要最后一个字节的低 4 位作为偏移量 offset。从摘要的 offset 位置开始取 4 个字节转成整数去掉最高位符号位然后对10^6取模。结果不足 6 位左边补零。这段逻辑用 Python 写出来大概是import hmac import hashlib import struct import time import base64 def get_totp_code(secret, at_timeNone, period30, digits6): if at_time is None: at_time time.time() key base64.b32decode(secret.upper(), casefoldTrue) counter int(at_time // period) counter_bytes struct.pack(Q, counter) digest hmac.new(key, counter_bytes, hashlib.sha1).digest() offset digest[-1] 0x0F binary struct.unpack(I, digest[offset:offset 4])[0] 0x7FFFFFFF code binary % (10 ** digits) return str(code).zfill(digits)注意第 6 步去掉了最高位符号位这一步绝不能省。RFC 4226 里规定动态截断时要做 0x7FFFFFFF目的是避免结果变成负数从而保证不同语言实现的计算结果一致。如果哪套实现漏掉了这一步同一个时间窗口算出来的动态码可能跟标准算法不符。你可能会问为什么每 30 秒变一次时间窗口越短动态码被截获后重放攻击的窗口就越小但用户输完 6 位数字通常需要 3 到 10 秒窗口太短会导致用户输到一半验证码就过期了。30 秒是经过大量实践验证的折中值既能控制重放风险又不会让人抓狂。2.3 服务器端校验宽容窗口与重放防护动态码算出来之后下一步是做服务器端校验。这里有一个新手很容易踩的坑直接在if user_input current_code上做判断。问题在于用户手机和服务器的时钟不可能精确同步加上网络延迟和用户输入耗时用户提交动态码时可能对应的是上一秒甚至下下秒的时间窗。如果只比较当前时间窗实际线上会出现不少“明明看对了却被系统拒绝”的情况。更稳妥的方法是允许向前后各偏移一个时间窗口也就是比较三个时间窗当前窗口当前时间 / 30前一个窗口当前时间 / 30 - 1后一个窗口当前时间 / 30 1只要用户的动态码和这三个窗口里任意一个计算值匹配就算校验通过。这个偏移参数通常叫drift或window。考虑到互联网应用的网络抖动前后各偏移 1 个窗口已经比较宽容了不建议偏移太大否则攻击者重放动态码的窗口会变大。校验时还有一个安全问题不要用直接比较字符串因为字符串比较不涉及敏感信息泄露但并非时间恒定比较在极端场景下可能被时序攻击猜测。虽然动态码的有效期只有几十秒攻击成本很高但安全编码的习惯最好还是养起来用hmac.compare_digest()做比较它保证两端长度相等的比较耗时恒定。此外一个 6 位动态码在整个时间窗口内是全局可预测的必须重放防护。最常用的做法是在用户表里记录last_used_counter上一次校验成功时的时间计数器。校验成功时如果当前计数器等于或小于last_used_counter就判定为重放直接拒绝。这样才能保证一个动态码只能使用一次。3. 落地一个二维码生成 API从设计到实现这一段讲完整落地。我会用 Python 写一个尽量贴近真实项目的接口设计包含密钥生成、otpauth URI 构造、二维码图片生成、用户扫码后校验的完整闭环。代码是常见实践里的一个参考实现你可以照搬也可以改成自己熟悉的语言。3.1 接口设计思路先想清楚一个问题后端到底要把什么给前端常见方案有两种。方案一是后端只返回 otpauth URI前端用 qrcode.js 之类的库在浏览器里生成二维码。好处是后端不用依赖图像库二维码尺寸和样式可以灵活调整适合做响应式页面。方案二是后端直接返回二维码图片的 base64 字符串前端只需把它放进img标签。好处是前端逻辑最简尤其适合某些不允许外链脚本、或者前端技术栈不好集成第三方库的内部系统。代价是二维码样式固定如果需要调整得改动后端。我一般根据场景选。如果是后端管理系统前端能跑 qrcode.js就返回 URI 或 dataURL让前端渲染。如果是接口要开放给第三方或前端环境比较老旧就直接返回图片 base64。下面的示例采用方案二但会在前端展示一节里补充方案一的做法。接口设计如下接口方法入参出参说明/api/mfa/qrcodePOSTuser_idsecret,qr_code_url生成或重新生成二维码/api/mfa/verifyPOSTuser_id,codeok校验动态码并启用绑定这里有一个非常重要的设计原则先校验后启用。我见过不少团队流程做反了用户点了“开通两步验证”后端立刻生成一个密钥并标记为启用结果用户根本还没来得及扫码动态码就已经是“已启用”状态。如果用户手机丢了或者没扫成功账号就会莫名进入一个尴尬状态。正确做法是生成密钥后先保存为待确认状态等用户扫完码并成功提交一次动态码之后才正式启用该密钥。3.2 后端生成二维码的核心逻辑完整示例代码用 Flask 风格写但核心逻辑独立于框架import base64 import io import os import time import qrcode import qrcode.constants from urllib.parse import quote, urlencode def generate_secret(byte_length20): raw os.urandom(byte_length) return base64.b32encode(raw).decode(utf-8).rstrip() def build_otpauth_uri(secret, account, issuer): label f{issuer}:{account} params { secret: secret, issuer: issuer, algorithm: SHA1, digits: 6, period: 30, } return fotpauth://totp/{quote(label)}?{urlencode(params)} def generate_qrcode_base64(uri): qr qrcode.QRCode( versionNone, error_correctionqrcode.constants.ERROR_CORRECT_M, box_size6, border4, ) qr.add_data(uri) qr.make(fitTrue) img qr.make_image(fill_colorblack, back_colorwhite) buf io.BytesIO() img.save(buf, formatPNG) return base64.b64encode(buf.getvalue()).decode(utf-8) def verify_code(secret, user_code, drift1): for i in range(-drift, drift 1): expected get_totp_code(secret, at_timetime.time() i * 30) if hmac.compare_digest(user_code, expected): return True return False这段代码里有几个参数值得聊一下。box_size6表示每个模块的像素大小。box_size 越大二维码整体越大。如果生成出来的二维码在手机上经常扫不出来我建议先把 box_size 调大一点比如 8 或 10然后检查边框有没有留白。border4是二维码的静区宽度。标准 QR 码要求静区至少为 4 个模块。别小看这个空白很多人生成二维码时为了美观去掉了 border或者把二维码嵌在深色背景里结果就是扫码识别率暴跌。error_correctionERROR_CORRECT_M选择中等容错级别。M 级容错允许大约 15% 的图案损坏二维码里有个别污渍、小图标遮挡时仍能识别同时不会因为容错太高导致二维码模块变大、密度变高。L 级容错太低H 级会明显增加二维码复杂度M 是扫码成功率和信息密度之间比较稳妥的平衡点。在真实项目里secret需要和user_id关联保存到数据库。保存前建议加密存储至少要保证数据库泄露时密钥不跟着泄露。我记得有个项目早期直接把密钥明文存在用户表里后来安全审计被点名才改成用应用层加密字段保存这一步别省。3.3 前端展示与用户扫码闭环后端接口返回secret和qr_code_url后前端用几行代码就能把二维码展示出来img idmfaQrCode alt两步验证二维码 / script fetch(/api/mfa/qrcode, { method: POST, body: JSON.stringify({ user_id: alice }) }) .then(res res.json()) .then(data { document.getElementById(mfaQrCode).src data:image/png;base64, data.qr_code_url; }); /script如果选方案一让前端用 qrcode.js 渲染的话可以这样new QRCode(document.getElementById(qrBox), { text: uri, width: 220, height: 220, correctLevel: QRCode.CorrectLevel.M });qrCode 库的布局和后端生成是两个独立维度但关键是传入的uri必须是完整且正确的 otpauth URI。用户接下来做的事情很多开发文档没有明说但这正是事件的完整闭环打开 Google Authenticator 或兼容验证器点添加账号选择“扫描条形码”。摄像头扫描页面上的二维码。验证器解析 otpauth URI保存secret并展示一个条目比如“某运营后台 (alice)”。验证器立即开始在本地按 30 秒周期生成 6 位数字。用户把当前数字填回系统的确认输入框提交到/api/mfa/verify。服务端取出该用户的secret用当前时间加上宽容窗口计算动态码匹配成功则启用。这个闭环里最需要向用户解释的一步是“就算你现在不联网验证器里的数字也会每 30 秒变一次”。因为它根本不依赖网络很多第一次使用的用户会以为验证器断网是不是就不能用了其实它只是需要一个正确的时间源。配套的前端交互里二维码上方通常会放一句提示“打开身份验证器扫描二维码并在下方输入当前验证码完成绑定。”这样用户才明白下一步要做什么。很多系统把这句提示省掉用户拿着手机上号后不知道要回来输验证码造成大量固化工单。别笑这个问题在内部系统里真的很常见。3.4 数据表与状态流转密钥有了二维码生成了接口也能验证了但我还是想再强调一下数据模型里的状态设计。一个典型的 TOTP 绑定信息表至少应该包含这几个字段user_id用户主键secret_encrypted加密后的密钥statuspending/enabled/disabledlast_used_counter上次成功校验的计数器用于重放防护created_at、updated_at时间戳为什么要这么设计因为状态不同业务逻辑完全不同。pending状态下用户还没有完成扫码或验证此时密钥可以随时作废用户重新刷新页面就能再生成一个新二维码。enabled状态表示绑定已经生效登录流程需要校验动态码。此时如果用户想重新绑定必须走“先验证旧动态码再生成新密钥”的安全流程否则任何人都可以调接口把目标用户踢出两步验证保护。last_used_counter前面已经提过这里补充一下细节校验成功时要把当前时间计数器写入这个字段。下一次校验如果传入相同计数器说明用户提交的是一个用过一次的动态码直接拒绝。这一点在账号被盗场景下尤为重要6 位动态码一旦被钓鱼页面截获如果服务端允许重复使用攻击者就能在剩余时间窗口里直接登录。另外建议在绑定流程里同时为用户生成一组一次性恢复码通常是 8 到 10 个随机字符串存储在服务端并只展示给用户一次。否则验证器一丢账号就彻底锁死只能走人工申诉这个体验谁的账号都会难受。4. 真实环境里的常见坑与排查这一节记录真正上线后反复踩到的问题按症状分类整理。很多问题不是看文档能看出来的只有真实用户分散在各个设备、各种网络环境下坑才会暴露出来。4.1 验证码对不上先别怀疑算法最典型的现象是用户扫码正常但输入验证器上显示的 6 位数字系统提示“验证码错误”。排查时先别怀疑 HMAC 算法先看三个最可能的原因。第一个是手机时间不准。Google Authenticator 这类 TOTP 验证器要求设备时钟与标准时间偏差不要太大。手机手动设置过时间、快进过时间或者系统时间常年错几分钟动态码和服务器算出来的就对不上。我处理过的工单里大概有七成是这个原因。解决方案很简单把系统时间改为“自动获取”然后再试一次。第二个是secret的大小写和编码问题。你在生成密钥时如果统一用了大写校验端解码时也必须能处理大小写。建议解码时用base64.b32decode(secret.upper(), casefoldTrue)。casefoldTrue这参数能容忍一些用户手动输入时的小写字母实测对提升兼容性有帮助。第三个是 URI 里的参数名或算法被改动了。比如有的同事把algorithm写成了大写SHA1或者digits写成了8导致验证器算出来的策略和服务端不一致。这种问题在自测时往往发现不了因为自己写的服务端和验证器可能都默认识别了某种格式但用户换一个 App 扫一下就露馅。推荐一个非常有效的排查手段在验证器里添加账号时如果支持手动输入密钥就先用手动输入试试。如果能通过说明密钥没问题问题大概率出在 otpauth URI 生成或二维码绘制上如果手动输入也不行那就是算法或时间的问题。4.2 二维码扫不出来或扫出来是文本第二个高频问题集中在二维码生成端。一类是普通扫码工具能扫出纯文本但验证器扫了没反应。这通常不是二维码的锅而是验证器识别不了非otpauth://开头的内容。有些第三方验证器对协议兼容性要求更严格如果 URI 里的 label 部分没有正确 URL 编码或者issuer参数缺失可能就静默失败。解决办法是先用一个普通的二维码解析工具看下内容确认是完整的 otpauth URI 再做判断。另一类是二维码太密、太小、边框缺失导致摄像头反复对焦不上。处理经验是把box_size调大到 8 以上border保持在 4并且不要把二维码贴在深色背景或花哨背景上。要替用户想一步他们盯着手机屏幕扫二维码的时候屏幕反光、摄像头距离、后台光线都会影响识别你生成二维码时大方一点扫码的人就少骂一句。如果是大屏展示场景注意不要用 CSS 把已经生成的位图二维码无限制拉伸拉伸会让模块边缘发虚反而降低识别率。更好的办法是让前端直接渲染按屏幕宽度动态生成。4.3 安全和用户体验的平衡这一步对应到真实系统里还有一些带偏安全与体验的细节。第一绑定入口一定要做身份验证。让任意一个已登录用户调用POST /api/mfa/qrcode就能重新生成密钥看起来没什么问题但如果攻击者已经拿到了密码和会话他也可以直接开启自己的验证器并扫绑定页面然后把原账号锁死。所以重新生成密钥一定要先验证当前账号的密码或者校验旧验证码。第二生成二维码的接口要做频率限制。用户误点按钮刷新二维码每次都会生成一个新密钥如果不做限制用户可能把旧密钥失效了都不知道。更糟的是如果接口被脚本高频调用数据库里会积压大量pending状态的密钥记录。建议每个用户在两分钟内最多生成一次或者每次生成时把旧的pending状态密钥作废只保留最新一个。第三启用两步验证后要在登录流程中提示用户“验证器上一旦删除账号需要用恢复码才能解除绑定”。很多用户不知道自己绑定的是哪个账号换手机时直接删掉验证器里的旧条目等下次登录就抓瞎了。虽然这不属于技术问题但从系统设计的角度你至少要提供一条清晰的恢复路径。4.4 排查问题速查表最后给一张可以直接贴到团队 Wiki 里的排查表遇到问题按行查就行。症状可能原因处理方式验证码一直不对手机时间和服务器时间偏差大设置为自动获取时间再试一次验证码偶尔不对服务器校验窗口太小将drift设为 1允许前后各偏移一个窗口验证器扫码无反应二维码内容不是 otpauth URI用普通扫码工具确认文本内容验证器扫码后账号名乱码label 未 URL 编码对 label 里的issuer:user做 URL 编码二维码识别率低box_size 太小或 border 缺失将 border 设为 4box_size 调到 8 以上同一个码可以通过两次登录未记录last_used_counter增加重放防护记录上次成功计数器用户换手机后无法登录没有恢复码也没有重新绑定流程提供一次性恢复码或提供安全重绑流程输入动态码报成功后下次又不对服务端把旧动态码当成功录入了校验成功后更新last_used_counter忽略旧计数器这张表不是理论推导出来的是几个项目反复踩坑后沉淀出来的。最容易被忽略的其实是最后一条很多团队在开发阶段没有并发测试两个设备同时提交同一个动态码或者用户提交了两次结果last_used_counter没有正确更新导致旧动态码还能继续用排查时要花不少时间。我个人的习惯是每次上线前都做一次端到端验证用一台真实手机从扫码开始走完绑定和登录全流程再手动把手机时间往后调 5 分钟确认服务端会拒绝并提示时间错误。这一套小动作下来能省掉后续至少九成的“验证码为什么不对”工单。等你真正把 otpauth 协议读懂了你会发现自己再看这类问题思路会清爽很多二维码就是一段字符串密钥就是一把锁TOTP 只是用时间戳驱动 HMAC 算法的一把动态钥匙仅此而已。