一、先看清你要防的是哪一种风险
后台被登录这件事,多数站长第一反应是「我密码够复杂」。但真实发生的情况通常是三类:一类是密码在别处泄露后被撞库,攻击者拿着几十万条邮箱密码组合在登录页上跑;一类是浏览器里存了密码、或者 Cookie 被别的插件带走;还有一类更朴素,就是同一个密码在论坛、QQ、某个小工具站上用过,被人整理成字典。
这三类的共同点是:密码本身没有错。它只是在某个环节被别人拿到了。所以只在登录页加验证码、只改密码、只做登录失败锁定,都解决不了「密码是对的,但用密码的不是你」这个问题。双因素认证要解决的正是这件事——第二把钥匙不在密码库的字典里。
第二因素的形态有几种:短信验证码、邮箱验证码、硬件密钥、TOTP 动态口令。短信需要付费通道且依赖运营商,邮箱验证码依赖邮件送达,两者都要额外的服务链路;硬件密钥最安全但需要采购,而且作者自己也会丢。对个人站长来说,TOTP 是最合适的一种:离线可用、免费、算法公开、手机丢了还能用恢复码兜底。
这一篇讲的是怎么在 Typecho 这类 PHP 博客后台把它真正落地,不是讲概念。落地要解决的问题比「扫个二维码」多得多:密钥怎么存、时钟漂移怎么处理、登录失败几次才拦、重放怎么挡、手机丢了怎么办。
二、TOTP 的算法其实只有四行
很多站长以为 TOTP 是个黑箱,其实 RFC 6238 的算法短到可以用一段代码讲完:
counter = floor(unix_time / 30)
hmac = HMAC-SHA1(secret_key, counter_as_8byte_big_endian)
offset = hmac[19] & 0x0f
code = (hmac[offset..offset+3] as uint32 & 0x7fffffff) % 1000000四个关键点:
第一,时间片是 30 秒。不是「每 30 秒随机生成一个数」,而是「用当前时间所在的 30 秒窗口去算」。同一个窗口内无论算多少次,结果都一样。理解这一点,后面「为什么需要容错窗口」就不用背了。
第二,密钥只有一次共享。密钥在绑定的时候由服务端生成,编码成 Base32 后写进二维码,手机 App 扫一次就存下来。之后手机和服务端各自独立计算,再不通信。这也是为什么 TOTP 能在关掉网络的飞机上用——它根本不需要联网。
第三,只取 6 位数字。100 万种组合看起来不多,但因为有效期只有 30 秒且每个密钥独立,暴力猜的概率被时间窗口压住了。前提是你必须做失败次数限制,否则 30 秒内打 100 万次也是能蒙对的——这一点很多自研实现都漏掉。
第四,Base32 而不是 Base64。Base32 只用 A-Z 和 2-7,没有大小写混淆也没有容易被手抄错的符号,因为用户可能需要手工输入密钥来恢复。生成密钥时用 160 位随机数(20 字节),编码后是 32 个字符。
三、绑定阶段:二维码怎么出、密钥怎么存
绑定的标准流程是:服务端生成随机密钥 → 存到数据库里但标记为「未确认」→ 生成 otpauth 链接 → 前端把它转成二维码给用户扫 → 用户输入一次 6 位码 → 服务端验证通过后才把状态改成「已启用」。
这个「先存后确认」的顺序很重要。如果先生成二维码让用户绑定、用户确认了才存密钥,那么用户在绑定途中关掉页面,密钥就丢了,下次得重来。反过来先存未确认状态,用户随时可以继续。
otpauth 链接的格式固定:
otpauth://totp/zz1984:988620?secret=JBSWY3DPEHPK3PXP&issuer=zz1984&algorithm=SHA1&digits=6&period=30冒号前是发行方(一般写站点名),冒号后是账号标识。手机上会显示成「zz1984 (988620)」,用户一眼能分清是哪个站的码。参数里 algorithm、digits、period 三个都可以省略,默认就是 SHA1 / 6 / 30,但如果你的实现用了别的值就必须写全,否则 App 算出来的码跟服务端对不上,而这种不一致在排查时极难定位。
密钥在数据库里不能明文存。它不是密码,不能单向哈希——验证时需要还原出原始密钥来算 HMAC。正确做法是用一把主密钥做对称加密(AES-256-GCM),主密钥放在配置文件或环境变量里,不进数据库。这样即使数据库被拖走,攻击者也拿不到 TOTP 密钥。同理,备份数据库时也要意识到:备份文件加上配置文件才是完整的凭证泄露面。
二维码本身要防止被截图留存。绑定时可以限制这个二维码只显示 60 秒,过期就作废重新生成。这一步不严格但成本很低,能挡住「同事路过随手拍一张」这类场景。
四、验证阶段:容错窗口、重放与失败计数
服务端验证时,不能只算当前时间片。要向前向后各放宽一片,也就是同时校验 t-1、t、t+1 三个窗口。
原因很实在:手机和服务器的时间不可能严格同步,手机时钟慢 20 秒是有可能的;用户输入 6 位数字也要花几秒,正好卡在窗口边界上就会失败。实践中 ±1 个窗口(±30 秒)已经能覆盖绝大多数情况,放宽到 ±3 就过头了——每多一个窗口,猜中的概率就多一倍。
放宽窗口带来一个新问题:同一个验证码在 90 秒内可以重复使用。如果攻击者能截获一次验证码(比如通过中间人或者恶意浏览器插件),他可以在窗口内再用一次。挡这个的办法是记录「这个用户上一次成功验证用的是哪个时间片」,同一个时间片的码只接受一次。
// 只记时间片编号,不记码本身if (used_step === step) { reject 'replayed'; }
失败计数要单独做,不能和密码错误共用一套。因为密码错误的锁定阈值通常是 5 次,而 6 位 TOTP 在 30 秒内被猜中的概率是 100 万分之一,但如果攻击者拿到正确密码后无限次试第二因素,试够几十万次也是能进去的。规则建议:
- 同一账号连续 5 次 TOTP 失败 → 锁定 15 分钟,并给站长本人发通知(这条通知的价值在于:如果你没在登录,说明密码已经泄露了);
- 同一 IP 在 10 分钟内出现多个账号的 TOTP 失败 → 直接封 IP 一小时,因为正常用户不会来回切账号;
- 锁定期间的失败继续计数但不延长锁定时长,避免攻击者用无效请求把你的账号永久锁死。
还有一条容易忘:验证失败时的响应时间要恒定。如果「码错了」立刻返回、「码对了」要走后续逻辑慢 100 毫秒,攻击者能通过计时区分出哪一次猜得接近。实际做法是在返回前统一 sleep 到固定时长,或者干脆不做区分——TOTP 校验本身耗时极短,这点差异在实践中不那么致命,但既然是安全功能就顺手做对。
五、恢复路径:恢复码与备用通道
双因素功能上线后最常见的求助不是「被攻击了」,而是「手机换了我进不去了」。如果没有恢复路径,你会在最需要进后台的那天被锁在外面——而且这时候你连找客服都没有,因为这是你自己搭的站。
标准做法是绑定时一次性生成 10 个恢复码,每个 8~10 位,只显示一次,要求用户抄下来或者存到密码管理器里。数据库里存的是这些码的哈希值,不是明文。使用时一次性消耗,用掉一个就标记作废。
恢复码存储:
code -> sha256(code + per_user_salt)
used -> 0 / 1 / used_at验证:遍历该用户的未使用码,逐个哈希比对
恢复码最多 10 个,逐个哈希比对的成本可以接受。不要为了省这点开销去做全局唯一索引——恢复码允许跨用户重复(概率极低),做唯一约束反而会在生成时撞出莫名的插入失败。
恢复码用完后要能重新生成,生成时要求同时提供密码和当前 TOTP(或另一个未使用的恢复码),形成链式验证。如果两条都丢了,就只剩最后一条路:数据库直接改。这条路上要写清楚步骤放在自己的运维笔记里——通常是一条 UPDATE 把该用户的 TOTP 状态字段置空,然后清缓存。写在笔记里的意思是:那天你进不去后台,但你有服务器 SSH。
还有一点值得单独提:不要给双因素留「记住这台设备 30 天」的免验证 Cookie 而不加保护。如果要做,这个 Cookie 必须绑定一个服务端可验证的令牌(存哈希),并且能在后台一键全部吊销。否则一旦这个 Cookie 被偷,双因素等于没开。
六、在 Typecho 后台具体怎么接
Typecho 的登录流程在 var/Widget/Login.php 里,校验密码通过后会在 Widget_User::login() 里写 Cookie。双因素要插的位置就在「密码校验通过」和「写登录态」之间,不能晚于写 Cookie,也不能靠前端拦截。
适合个人站长的接入方式是插件而非改核心,因为改核心会在下次升级时被覆盖,而升级恰恰是你最不想动登录逻辑的时候。插件挂两个钩子就够:
- 登录表单提交前:判断该用户是否已启用 TOTP,如果启用则不在这一步建立会话,而是渲染一个「输入 6 位码」的中间页;
- 中间页提交时:验证 TOTP 或恢复码,通过后调
Widget_User::login()建立会话。
中间页的状态怎么维持?不要在 Query String 里带上用户 ID(那等于把「谁的密码对了」这个信息暴露出去)。用一个短时效的一次性令牌:密码校验通过后生成随机 token 存进 session,中间页只认 session 里的 token,验证成功即销毁。这样即使有人构造 URL 也进不去。
给中间页加两个限制:有效期 5 分钟;同一个 session 只能尝试 5 次。超时或超次就回到登录页重新输密码——这看起来麻烦,但它是「密码泄露 + 第二因素失败」时的正确收敛方式。
后台用户如果不止你一个,要允许按用户单独启用,不要全站强制。编辑账号十天半月不登录一次,一强制就会来问你「为什么登不进去」,然后你就得临时给他开后门,安全性反而更低。正确的做法是先只给自己开,跑两周确认没问题,再推给其他账号。
七、上线前后必须验证的四件事
功能写完不等于能用。上线前按顺序做四步验证:
第一步,校验算法一致性。用同一个密钥,在服务端和你手机 App 上分别算当前时间片的码,必须一致。这一步建议写个小脚本,把 secret、时间戳、期望码三者固定成测试用例——以后怀疑有问题先跑它,能立刻区分「算法错」还是「时钟错」。
php -r 'echo (new OTPHPTOTP("JBSWY3DPEHPK3PXP"))->at(1793705430), PHP_EOL;'第二步,验证时钟漂移。把服务器时间改慢 60 秒(或者直接改测试用例里的时间戳),确认失败;改成慢 25 秒,确认成功。如果你只测了「现在能过」,那就没测到容错窗口有没有生效。
第三步,验证恢复路径。用一个恢复码登录,确认成功,然后立刻用同一个码再登一次,必须失败。这一步测的是「一次性消耗」有没有真的实现——很多实现只查了哈希,忘了置 used 标记。
第四步,验证锁定。连续输错 5 次,确认被锁;等 15 分钟或手动解锁后再试正确的码,确认能进。同时确认锁定时确实发出了一条通知到你自己的邮箱或手机。
四步都过,再把它真正开在自己的账号上。顺序千万别反过来——先把唯一的入口锁死,再回头调试,是最典型的自伤方式。
八、几个容易被忽略的细节
API 与 CLI 不能绕过。后台有 XML-RPC 或者开放了 REST 接口,双因素只加在网页登录上就等于没加。要么让所有非网页入口都走同一套校验,要么给接口单独发令牌并单独审计。这一条是本站在用 XML-RPC 发布文章时最该注意的:接口凭证本身就是一把钥匙。
日志要记「验证过」而不是只记「登录成功」。登录成功日志里加上「第二因素: TOTP / 恢复码 / 无」这个字段,事后翻日志就能看出有没有人用恢复码偷偷进过——恢复码登录是最值得警惕的一种,因为正常人不常用它。
备份的密钥要跟服务器分开。前面说的主密钥如果用环境变量,那么恢复备份时别忘了它也在一份配置里。把它和数据库备份分开放,别让一次拖库同时拿到加密后的密钥和主密钥。
不要在绑定页面上打印密钥文本。有些实现为了方便手工输入,会在页面上显示 Base32 明文密钥。这等于在任何能截屏的场合都会泄露。如果确实需要手工输入(没有摄像头),让用户点击「显示密钥」再展示,并给一个可复制按钮,默认不显示。
九、小结
双因素认证真正的价值不在算法,而在它把「拿到密码」和「进入后台」拆成了两件事。对个人站长来说,这一步的意义格外大:你的站后台通常没有第二道人工审核,一旦进去就是全站权限。
落地时按这个顺序走就不会乱:先想清楚防的是哪类风险,再把算法和密钥存储做对,然后处理容错窗口和重放,最后一定要留一条能自救的恢复路径。绑定、验证、恢复、锁定四步验证全过,再开在自己账号上。
如果只做一件事,那就先把失败计数和恢复码做上——前者挡住拿到密码后的暴力尝试,后者保证你自己永远进得去。剩下的细节可以慢慢补,这两条缺了任何一个,都会在你最不希望的时候出问题。