返回探索
指南VibeFix 编辑部更新于 2026年10月31日

密码之外再加一把锁:vibe 项目的双因素验证实战

密码泄露已是常态,单靠密码守不住能删数据、能花钱、能看别人数据的账号。本指南讲 TOTP 开通、验证与恢复的完整做法:先验证后标记、备用码只展示一次、5 次限流与可撤销的信任设备。

一只手持手机,屏幕显示验证码,背景是笔记本电脑

一只手持手机,屏幕显示验证码,背景是笔记本电脑

先把话说清楚:本文出现的例子纯属虚构——「拾光手账」和它的作者江晓都是编出来的,用来把流程讲明白,不代表任何真实产品或真实数据。讲完这句,我们聊正事。

密码这东西,早就不是秘密了。撞库、钓鱼、旧数据库泄露,任何一条都能让一个还算体面的密码变成废纸。你一个人在做项目,没安全团队,没风控系统,用户账号一旦被盗,删数据、改密码、冒名发内容,全都可以在你睡觉的时候发生。双因素验证(2FA)就是在这种局面下最便宜的一道保险:密码之外,再加一把锁。

但先划清界限,免得和已经讲过的东西混在一起。本篇讲的是在密码之上再加第二因素,也就是给现有登录加一道验证;不是用链接或验证码取代密码的那套做法,也不是手机绑定、设备生物识别那类无共享密钥的方案,更不是会话能活多久、在几台设备上登录的管理。本文只讲三件事:怎么开通、怎么验证、丢了第二因素怎么找回来。

我的立场很直接:只要你的项目里有一个账号能删数据、能花钱、能看到别人的数据,就值得上 2FA。反过来,如果只是个一次性演示、里面没有任何真实数据、丢了也不心疼,那不上也罢。安全措施是要付成本的:你要写代码、要写恢复流程、要回用户求助邮件。把这份成本花在真正有价值的账号上,别花在演示项目里给自己找麻烦。

什么时候值得上 2FA

判断标准不用搞得很复杂,就问三个问题。第一,这个账号能不能删掉或改掉别人依赖的东西?比如笔记、订单、发布过的内容、团队资料。第二,这个账号能不能花掉钱,或者改掉收款、订阅、付款方式?第三,这个账号能不能看到不属于自己的数据?只要有一个答案是肯定的,2FA 就不是锦上添花,而是上线前就该有的部分。

很多独立开发者会拖,理由听起来都合理:用户还少、等长大了再说、怕多一步把人吓跑。这些理由里只有最后一条值得认真对待,而且它有解法,后面会讲。至于“用户还少”,恰恰是上 2FA 成本最低的时候。等到用户多了、账号体系复杂了、已经有人被盗号了再补,你要同时处理迁移、兼容、客服三件事,那才叫贵。

还有一种情况容易被忽略:你的项目可能还没有付费用户,但你自己是管理员。管理员账号能改配置、能看全站数据、能动别人的内容,这个账号的价值比任何一个普通用户都高。管理员先上 2FA,这条没有商量余地,后面专门讲。

虚构的例子可以帮助理解这个判断。江晓做的「拾光手账」一开始只是自己记东西的工具,账号丢了也没什么大不了。后来他加了分享功能,别人能看到被分享的手账;再后来加了导出和删除,账号能删掉整本手账。就在分享功能上线的那一周,他给项目加上了 2FA——不是因为用户多了,而是因为账号的破坏力变了。破坏力变了,就是上 2FA 的时间点。

TOTP 到底在算什么

本文推荐的第二因素是 TOTP,也就是验证器应用里每 30 秒变一次的六位数字。它的原理说白了并不神秘:服务器和用户的验证器应用共享一个密钥,相当于一把只有你们俩知道的暗号本。双方都拿当前时间除以 30 秒得到一个时间步,再用密钥和这个时间步算出一个六位数。时间在走,数字跟着变,过期就作废。

理解这个原理,才能理解后面所有的规则。验证码不是服务器发给用户的短信,也不是存在数据库里的随机数,它是算出来的。服务器在验证时拿自己保存的密钥、按当前时间步算一遍,再和用户提交的数字比对,对上了就放行。这意味着两件事:第一,验证过程不需要发短信、不依赖运营商;第二,密钥一旦泄露,攻击者自己就能算出验证码,2FA 等于没上。

所以密钥的保管是整件事的命脉。密钥绝不能以明文存在数据库里。正确做法是像存密码一样对待它:加密存储,密钥本身放在应用配置或密钥管理里,数据库里只放加密后的版本。更重要的是,密钥绝不能出现在日志里。开通时生成的二维码、密钥字符串、用户提交的验证码,这些都不该进日志。日志是排查问题用的,不是给攻击者留后门用的。

还有一个实际问题:两边的时钟不可能永远对齐。用户的手机可能快几秒、慢几秒,刚好卡在 30 秒的边界上,上一秒的码和下一秒的码都可能被用户交上来。务实的做法是验证时接受前后各一个时间步,也就是容忍正负一个 30 秒窗口。这个窗口不是安全漏洞,而是对现实时钟误差的让步。超过这个窗口的码一律拒绝,不要为了“用户体验”把窗口放宽到五分钟,那等于把 30 秒有效期亲手作废。

开通时要让用户用验证器应用扫二维码,二维码里就包含这个共享密钥。同时必须提供手动输入密钥的备用方式,因为不是所有人扫码都方便,也不是所有设备都支持扫码。手动密钥的显示方式要和二维码一样谨慎:只在开通这一步显示一次,页面关掉就不再展示。用户如果没保存好,正确做法是重新走开通流程换一个新密钥,而不是让客服从后台把旧密钥翻出来给他。

开通流程:先验证,再标记

开通流程最容易犯的错,是顺序搞反。很多实现是用户一点“开启 2FA”,数据库里立刻就把 2FA 标记为已开启,然后才让用户去扫码验证。万一用户扫错了、输错了、验证器没配好,他自己的账号就被自己锁在外面了,接下来就是一封求助邮件和一次人工干预。

正确顺序只有四步,而且不能打乱。第一步,生成密钥并展示二维码,但此时账号的 2FA 状态仍然是“未开启”。第二步,用户在验证器里添加账号,输入当前显示的六位数字。第三步,服务器用这个密钥验证用户提交的数字,只有验证通过,才把 2FA 标记为已开启。第四步,同时生成并展示备用码,这一步只能有一次机会,后面专门讲。

这四步里第三步是关键。没有验证通过的开通,等于没有开通。不要因为用户点了按钮就相信他配好了,按钮谁都会点,六位数字只有真正配好验证器的人才交得出来。测试的时候也要测这条反向路径:故意输错一个码,确认账号状态没有变,确认页面给出的提示是“验证码不正确,请重试”,而不是稀里糊涂地把 2FA 开了。

开通页面还要把话说清楚。告诉用户这个码每 30 秒变一次,告诉他换手机之前要先做什么,告诉他备用码存在哪里。很多人不是不愿意开 2FA,而是不知道开了之后换手机怎么办。你在开通页上多写两行提醒,客服邮箱里就会少很多“我换手机了登不上”的邮件。

备用码与恢复:只给一次机会

备用码是整个 2FA 设计里最容易被做烂的部分。它的作用很简单:用户丢了验证器、换手机没迁移,靠备用码还能登录一次,把 2FA 关掉或重新绑定。但它的风险也同样直接:备用码就是一把万能钥匙,谁拿到它,谁就能绕过第二因素。

所以备用码有三条硬规矩。第一,只在开通时展示一次。页面上明明白白写着“请现在保存好,离开这个页面就再也看不到”,用户点了确认,页面就不再提供查看。任何“在设置里随时查看备用码”的功能,都是在给攻击者留一个偷钥匙的入口。第二,备用码在服务器上只能存哈希,不能存明文,道理和密码完全一样。第三,每张备用码只能用一次,用过立即作废,并且用过之后要提醒用户还剩几张。

给多少张合适?我的经验是 8 到 10 张。太少了,用户用两次就没了,又要来找你;太多了,等于发了一串万能钥匙,保管难度直线上升。备用码的格式也要对用户友好:短横线分组、四位一组,方便抄写和输入,别搞成一长串看不清的字符。

除了备用码,还需要一条不依赖验证器的恢复路径,因为备用码也可能一起丢了。务实的做法是经过验证的邮箱重置:用户申请重置,系统发一封确认邮件到注册邮箱,并且设置一个延迟期,比如 24 小时,同时给账号绑定的邮箱再发一封通知,告诉用户“有人申请关闭你的 2FA,24 小时后生效,如果不是你请立即联系我们”。延迟加通知,就是给账号真正的主人留出反应的时间。

有一种恢复方式必须明确拒绝:用户发邮件说“我是某某,麻烦帮我把 2FA 关了”,客服核对一下名字就给关了。这等于把 2FA 的生杀大权交给了任何一个会写邮件的人。恢复流程必须是不依赖人工判断的:备用码、或者经过验证的邮箱加延迟加通知。没有第三条路。一个人团队尤其要守住这条,因为你就是客服,你心软一次,规则就破一次。

登录流程:密码先行,验证码在后

登录顺序是密码先行、验证码在后。用户先交密码,密码对了,服务器再要求输入验证码;密码错了,直接拒绝,不要进入第二步。这个顺序的好处是,攻击者不知道一个账号有没有开 2FA之前,得先过密码这一关。更重要的是,密码验证失败和验证码验证失败在用户看到的提示上要尽量一致,不要写“密码正确但验证码错误”这种话。

这里有个务实的取舍要讲清楚。严格来说,最好的做法是无论密码对错都显示同一句“账号或验证码不正确”,连账号是否存在都不要泄露。但对独立项目来说,完全照搬这套会让正常用户很困惑,明明密码错了却以为验证码有问题。我的折中做法是:登录页的公开提示保持通用,写“账号或密码不正确”;进入第二步之后,如果验证码错了,就写“验证码不正确或已过期”。也就是说,不在第一步泄露密码到底对不对,但进入第二步的用户已经通过了密码验证,给他一个准确的第二步提示是合理的。这个取舍不是完美的,但它在安全和可用之间站得住。

验证码的尝试次数必须限流。六位数字只有一百万种可能,不限流等于请人慢慢猜。合理的做法是:同一个账号连续输错 5 次,就锁一段时间,比如 15 分钟,期间不再接受验证码提交;同时对同一个 IP 的高频尝试也要限流。锁定的提示不要写“你的账号被锁定了”,写“尝试次数过多,请 15 分钟后再试”就够了。锁定期间,密码登录本身不受影响吗?要小心:如果你把整个账号都锁了,攻击者可以用故意输错来锁住别人的账号。只锁第二步的验证,不锁密码登录,这条边界要守住。

下面这段伪代码是验证环节的骨架,窗口是前后各一个时间步,超过窗口直接拒绝,验证成功后还要记下用过的时间步,防止同一个码在 30 秒内被重复提交:

def verify_totp(user, code):
    secret = load_encrypted_secret(user.id)
    now_step = current_time() // 30
    for offset in [-1, 0, 1]:
        if totp(secret, now_step + offset) == code:
            if already_used(user.id, now_step + offset):
                return False
            mark_used(user.id, now_step + offset)
            return True
    record_failed_attempt(user.id)
    return False

这段代码里有三个细节值得单独说。第一,already_used 的检查不能省,没有它,一个截获的验证码可以在 30 秒内被重放一次。第二,record_failed_attempt 要接上限流计数,满 5 次就触发锁定窗口。第三,密钥的读取是 load_encrypted_secret,名字本身就是提醒:密钥不是从数据库里直接读出来的明文。

备用码的验证骨架同样简单,核心只有两点:比对哈希、用后作废:

def verify_backup_code(user, code):
    for stored_hash in load_backup_hashes(user.id):
        if hash_matches(stored_hash, code):
            delete_backup_hash(user.id, stored_hash)
            notify_remaining_count(user)
            return True
    record_failed_attempt(user.id)
    return False

注意这里没有“备用码也可以无限次使用”的选项,也没有“备用码验证失败不计数”的例外。备用码用一次就少一张,失败次数也要计入限流,否则攻击者可以拿着一串猜测慢慢磨你的备用码。

信任设备:可以方便,但要能收回

每次登录都输验证码确实烦人,尤其对每天都要打开项目的用户。所以很多产品提供“记住这台设备 30 天”,这是一个可以做的功能,但必须做成可选的、能收回的、而且有边界的。

实现上,验证通过后如果用户勾选了记住设备,服务器发一个专门的记住设备 Cookie,有效期 30 天。这个 Cookie 必须和密码 Cookie 分开管理,有自己的随机值,服务器端存哈希,同样是一次签发、随时可吊销。用户在设置里应该能看到自己信任了哪些设备,并且能一键撤销其中某台或全部。撤销之后,那台设备下次登录必须重新输验证码,没有例外。

边界同样重要:记住设备只适用于普通登录,不适用于敏感操作。改密码、关 2FA、改邮箱、改付款方式、删数据,这些操作必须重新要求验证码,哪怕设备是受信任的。道理很简单:信任设备防的是“每天重复输码的麻烦”,不是“设备被别人拿到之后的破坏”。设备丢了、借给别人用了,信任 Cookie 还在,敏感操作如果不需要二次验证,等于把门钥匙连同保险柜钥匙一起交了出去。

还有一个细节:记住设备的功能默认应该是关闭的,让用户自己勾选开启。默认开启等于替用户做了决定,而这个决定一旦被滥用,用户会觉得是你在替他冒险。把选择权交还给用户,顺便在勾选框旁边写一句“仅在你自己的设备上使用”,这句话能拦住不少公共电脑上的误操作。

管理员先上锁,再谈全站推广

如果你的项目只有你一个管理员,那么推广的第一步根本不需要讨论:你自己先开 2FA。你的账号能动全站数据、能改配置、能看别人的内容,你的账号被盗,等于整个项目被盗。不要等“有空再说”,开通流程你自己走一遍,顺便也就把流程里的坑都踩了一遍:二维码好不好扫、手动密钥好不好复制、备用码页面写得清不清楚,你自己就是第一个测试用户。

如果你有几个协作者,顺序是:管理员和协作者先强制开启,普通用户随后自愿开启、再逐步引导。管理员强制开启的理由不用解释太多:权限越大,责任越大,这个道理所有人都懂。执行上也简单:管理员账号在下次登录时直接进入 2FA 开通流程,不开通就进不了后台。给管理员留的宽限期可以短,比如一周,因为人数少、沟通成本低。

普通用户的推广要分阶段,别一上来就全站强制。先是公告:告诉用户 2FA 是什么、为什么上、怎么开、丢了怎么办,公告里把备用码的重要性放在最显眼的位置。然后是宽限期:比如两周内自愿开启,设置里有明显的入口,登录后有一次不打扰的提醒。宽限期结束,先对高权限或高风险的用户组强制,比如能发布内容、能收款、能管理团队的用户,最后再扩大到全部用户。分阶段不是拖延,而是给用户留出保存备用码的时间,也给你自己留出修流程 bug 的时间。

强制之前,一定要先确认恢复流程真的跑通过。找一台测试账号,完整走一遍“丢验证器、用备用码登录、重新绑定”的全过程;再走一遍“备用码也丢了、邮箱重置加延迟”的全过程。两条路都跑通了,再谈强制。否则你强制的不是安全,而是一批被锁在门外的用户和一堆求助邮件。

千万别做的事

第一件别做的事,是把短信当作唯一第二因素。对一个人的团队来说,短信有三重成本:要接短信服务商、要付每条短信的钱、要处理送达失败的投诉。更麻烦的是安全:SIM 卡转移攻击是真实存在的,攻击者冒充机主补办一张卡,你的短信验证码就发到他手里了。短信不是不能用,但它应该是备选,不是首选。先给 TOTP,再谈短信兜底,顺序不要反。

第二件别做的事,是自己发明加密算法。TOTP 是现成的标准,有成熟的库,验证器应用也都支持。不要因为“想做得更特别一点”就自己设计一套验证码算法,自己设计的算法没有经过公开检验,出问题的时候你连错在哪里都不知道。用标准库、用标准流程,把精力花在开通、恢复、限流这些真正决定成败的流程上。

第三件别做的事,是把验证码和密钥记进日志或发到客服聊天记录里。用户求助时可能会主动把验证码贴给你,你要做的第一件事是告诉他这个码已经作废、让他不要再发,而不是把这段对话存下来当作处理记录。日志和工单系统不是保险柜,别往里面放钥匙。

第四件别做的事,是为了省事把备用码做成可以随时查看的。前面讲过,备用码只展示一次。任何“方便用户随时查看”的设计,都是在方便攻击者。用户体验和安全在这里确实有冲突,但备用码这件事上,安全必须赢。用户丢了备用码的正确处理方式是走邮箱重置流程,而不是让系统再把备用码展示一遍。

最后一件别做的事,是上线前不做“丢手机演练”。很多 2FA 上线时测的都是正常路径:扫码、输码、登录成功。真正决定用户会不会骂你的,是异常路径:换手机、备用码用完、时钟偏差、连续输错被锁。把这些路径都测一遍,再上线。正常路径人人都会走,异常路径才是你和别人拉开差距的地方。

上线前检查清单

收尾给一张可以直接照着打勾的清单。开通流程是否先验证后标记,验证失败时账号状态是否保持不变;二维码和手动密钥是否只在开通时展示,关闭页面后是否无法再次查看;密钥是否加密存储,日志里是否搜不到密钥和验证码;备用码是否只展示一次、存哈希、用一次作废、用后是否提醒剩余数量;邮箱重置是否有延迟期和通知邮件,人工关闭 2FA 的口子是否已经堵死;登录是否密码先行、验证码在后,验证码输错 5 次是否触发锁定窗口,锁定是否只锁第二步不锁密码登录;信任设备是否默认关闭、是否可撤销、敏感操作是否重新要求验证码;管理员账号是否已经全部开启 2FA;宽限期公告是否写清楚了备用码的重要性。

这张清单上的每一项,都是有人真实踩过的坑。2FA 本身不复杂,复杂的是把它放进一个真实项目的登录流程里,还要让用户在丢手机的时候不至于绝望。把开通、验证、恢复三件事做扎实,第二因素才真正是一把锁,而不是一把挂在门上、钥匙就插在锁孔里的装饰品。

回到开头那句话:密码之外再加一把锁,锁的不是用户,而是那些拿着泄露密码到处试的人。你一个人做项目,防不住所有攻击,但你可以让攻击者的成本高到不值得。TOTP 加备用码加限流,这三样加起来,成本不高,效果实在,值得你在下一个版本里就把它做进去。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章