两步验证流程图指南
怎么画一张真正够用的两步验证流程图:半登录状态、锁定分支,以及三个可直接改的例子 —— TOTP、短信验证码、开启流程。
一、什么是两步验证流程图
两步验证流程图 画的是「密码正确」到「用户登录成功」之间发生了什么 —— 也就是第二因子那一段。它是一张时序图,因为这一步牵涉到几个很容易被混为一谈的角色:你的应用、校验验证码的那个东西,以及 产生 验证码的那个东西 —— 最后这个通常根本不在你的控制之下。
让这张图值得画的核心事实是:两步验证把登录拆成了两笔事务,并且在中间造出一个状态。密码校验通过之后,用户既不是未登录也不是已登录,而是别的什么 —— 半登录 —— 这个状态需要自己的短期凭据、自己的过期时间、自己的失败计数。跳过它的图会从「密码正确」直接画一条箭头到「请输入验证码」,暗示服务器正把用户身份存在某个变量里。绝大多数真实的两步验证漏洞就住在这个暗示里。
第二个理由是这个流程主要由失败分支组成。成功路径就四条箭头,没人有异议。团队会吵的、以及工单的来源,是这些:错几次锁账号?短信一直不到怎么办?手机掉河里了用户怎么办?旧验证码能不能重放?每一条都是一个分支,而你没画的分支,总有人会在某次热修里临时发明一个。
这一页只画第二因子。骨架图里出现密码,只是为了交代交接点在哪。
二、一张两步验证流程图要画出哪些东西
六样。第一样就是「真图」和「宣传插画」的分水岭:
- 半登录状态,而且要有名字。 把密码校验通过后服务端发回的那个凭据画出来 ——
pre_auth_token、mfa_challenge,叫什么都行 —— 并把有效期写在箭头上。如果你的图里没有这个凭据,那它描述的是一个「第二因子之前会话就已经存在」的系统。 - 谁生成验证码、谁校验验证码。 TOTP 里这是两个从不通信的角色 —— 验证器 App 和你的服务器各自用同一个共享密钥算出同一个数字。把 App 画成一条生命线,这件事一目了然;写成文字则永远说不清。
- 失败计数,以及用完之后会怎样。 「验证码错了」不是一条分支,是两条:错了还能再试,和错了到此为止。把数字写到图上。
- 时间窗。 TOTP 的验证码按 30 秒一个步长,通常前后各容忍一步;短信验证码有一个你自己定的有效期。两者都该写在图上 —— 因为「明明看着对的验证码为什么被拒」是一个答案在时钟偏差上的客服问题。
- 一次性。 校验通过的验证码必须被标记为已消费,否则一个被人从背后瞄到的验证码在这个时间窗剩下的时间里一直有效。这是一条自箭头,是这份清单上最便宜的一项。
- 一条回得来的路。 没有恢复路径的两步验证图,画的是一个会把自己用户锁在门外的系统。恢复码、备用因子,或者一套客服流程 —— 你实际有哪个就画哪个。
骨架,一条失败分支都没有。注意它有多短 —— 这就是全部的成功路径,也正是为什么一张停在这里的图几乎什么都没说。
看 Mermaid 源码
sequenceDiagram
participant User
participant App as Web App
participant Auth as Auth Service
participant DB as User Store
User->>App: username and password
App->>Auth: verify credentials
Auth->>DB: look up the password hash
DB-->>Auth: hash and two_factor_enabled
Auth-->>App: password ok, second factor required
App->>User: ask for the 6 digit code
User->>App: 123456
App->>Auth: verify the code
Auth-->>App: code ok, session issued
App->>User: signed in三、两步验证流程图怎么画
三步。第 2 步是这张图从「插图」变成「评审」的地方。
第 1 步 · 定下是哪种因子,以及它归谁
三种常见的第二因子会画出三张不同的图,而区别完全在于 哪些参与方不归你管:
TOTP(验证器 App)—— 验证码的生成方是用户手机上的一个 App,和你之间没有任何网络连接。它永远不会出现在指向你服务器的箭头上。你的图里会有一条只和用户说话的生命线。
短信 —— 中间多了一个第三方网关,于是多一个参与方、多一种失败模式(发不出去),还多一笔按次计的成本 —— 这让「重发限流」变成一个商业决策,而不只是安全决策。
Passkey / 硬件密钥 —— 由浏览器居中协调,有意思的箭头是挑战和签名,而不是一串人手工重敲的数字。差别大到值得单独一张图,不该当成这张图的一条分支。
一张图一种因子。如果你两种都支持,就画两张 —— 一旦把短信兜底折进 TOTP 那张图,两条路径都变难读,而兜底那条恰好是攻击者会瞄准的。
第 2 步 · 先画失败
反常识地,从末尾开始。在画第一条成功箭头之前,先把所有失败方式写下来:
- 验证码错了,还有重试次数 - 验证码错了,重试次数用完 - 验证码对,但晚了(超出时间窗) - 验证码对,但已经用过 - 密码对,用户名不存在 —— 响应有区别吗?一定不能有 - 验证码根本没收到(只有短信会) - 用户已经彻底没有这个因子了
然后再画成功路径,把每个失败挂到「它被检测出来的那条箭头」上。顺序很重要:先搭成功路径,失败就会被塞进任何有空的地方,得到一张技术上完整、但找不出缺口的图。先列失败,缺口就是那个你哪儿都挂不上去的分支。
第五条是最容易翻车的。如果用户名错和密码错产生可见不同的响应 —— 文案不同、状态码不同,或者耗时明显不同 —— 这个登录页就是一个用户名探测器。而两步验证这一步最容易泄漏,因为「这个账号需要验证码」这句话很容易在密码都还没校验的时候就说出口。
第 3 步 · 画出来
用句子描述,让 text2diagram 排版 ——「密码校验通过后服务端返回一个五分钟有效的 pre-auth token,用户从验证器 App 读一个验证码提交上来,服务端用共享密钥校验、标记已消费、签发会话;连错五次就把 pre-auth token 作废」,还回来的是一段带好 alt 块、可以直接改的 sequenceDiagram。
或者把下面三张打开改参与方名字。值得留下的是分支结构。
四、三个两步验证流程图例子
三张图:最常见那种的完整版、中间夹着第三方的那种,以及必须发生在前两种之前的那一种。
例 1 · 登录时校验 TOTP,含锁定
这里有三个细节值得抄。带五分钟有效期的 pre_auth_token,就是上面清单里那个半登录状态的具体形态。mark this code consumed 是重放防护 —— 一条自箭头,很容易漏,而漏掉就意味着被人从背后瞄到的验证码在这个时间窗剩下的时间里一直能用。
第三个是第一条分支:密码错的时候,响应里不透露这个账号有没有开两步验证。哪怕服务端其实已经知道了,也必须如此 —— 这也是为什么这条分支在 pre-auth token 之前 而不是之后。先查两步验证状态的图,记录的是一个账号枚举接口。
还要注意验证器 App 从来没有一条指向服务器的箭头。两边各自用开启时约定好的密钥算出同样的六位数,此后再无往来。
看 Mermaid 源码
sequenceDiagram
autonumber
participant User
participant App as Web App
participant Auth as Auth Service
participant TOTP as Authenticator App
User->>App: username and password
App->>Auth: verify credentials
alt password wrong
Auth-->>App: 401 - never reveal whether 2fa is on
App->>User: generic sign in failed
else password correct, 2fa enabled
Auth-->>App: pre_auth_token, valid 5 minutes
App->>User: enter the 6 digit code
User->>TOTP: read the current code
TOTP-->>User: 123456
User->>App: 123456
App->>Auth: verify code with pre_auth_token
alt code matches inside the time window
Auth->>Auth: mark this code consumed
Auth-->>App: full session
App->>User: signed in
else wrong code, attempts left
Auth-->>App: 401 with attempts remaining
App->>User: try again
else 5 failed attempts
Auth->>Auth: burn the pre_auth_token
Auth-->>App: 429 start over from the password
end
end例 2 · 短信验证码,含那条永远没送达的短信
多出来的这个参与方对图的改变,被低估得厉害。网关接受了你的请求(queued)和手机收到短信 不是 同一件事,所以这里是两条箭头,而第二条你观察不到。这个断口就是「重发」这个功能存在的原因,也是它必须限流的原因 —— 这里画成注释而不是分支,因为它管的是整场交互,不是某一步。
失败分支是评审时真正值得吵的:网关挂了的时候,用户是拿到一个备用方式,还是撞上一堵墙?每一个用短信做两步验证的系统都需要一个答案,而相当多的系统的答案是「一堵墙」—— 仅仅因为没人画过这条箭头。
看 Mermaid 源码
sequenceDiagram
autonumber
participant User
participant App as Web App
participant Auth as Auth Service
participant SMS as SMS Gateway
participant Phone as User Phone
App->>Auth: password verified, send a code
Auth->>Auth: generate 6 digits, store the hash, ttl 5 min
Auth->>SMS: send to the stored number
alt gateway accepted it
SMS-->>Auth: queued
SMS->>Phone: your code is 123456
User->>App: 123456
App->>Auth: verify the code
Auth-->>App: full session
else gateway rejected or timed out
SMS--xAuth: delivery failed
Auth-->>App: 502 could not send
App->>User: offer a backup method
end
Note over App,Auth: resend is rate limited - 1 per minute, 3 per hour例 3 · 开启两步验证
所有人第二个实现、却从来不画的那条流程。里面有两处是承重的。
密钥在验证码证明它可用之前一直是待定状态。 二维码一显示就把两步验证开起来,会把每一个还没扫就关掉标签页的用户锁在门外 —— 一条自己造出来的工单队列。那条自箭头上的 status pending 和底下那条拒绝分支,是同一个决策的两面。
恢复码只能在这里发,别处都不行。 这是整个系统中唯一一个「用户已经通过认证、但还没有依赖新因子」的时刻,因此也是唯一能安全地把「回来的路」交给他的地方。注意 shown once:它们是像密码一样哈希存储的,所以这个页面是真的没法事后再渲染一次。
看 Mermaid 源码
sequenceDiagram
autonumber
participant User
participant App as Web App
participant Auth as Auth Service
participant TOTP as Authenticator App
User->>App: turn on two factor auth
App->>Auth: start enrollment
Auth->>Auth: generate a shared secret, status pending
Auth-->>App: secret as a QR code
App->>User: scan this code
User->>TOTP: scan
TOTP-->>User: 654321
User->>App: 654321
App->>Auth: confirm enrollment with that code
alt code matches the pending secret
Auth->>Auth: activate 2fa, mint 10 recovery codes
Auth-->>App: recovery codes, shown once
App->>User: save these somewhere offline
else code does not match
Auth-->>App: 400 - secret stays pending, 2fa still off
App->>User: scan again
end只读半登录那一段,问它能干什么
把画完的图拿来,把「密码正确」到「签发会话」之间以外的部分全遮住。在这一段里,用户手上握着一个「不是会话」的凭据。然后问三个问题:这个凭据能碰到校验接口以外的任何接口吗?它活得比它在等的那个验证码还久吗?第一个还活着的时候能再签发第二个吗?
答案应该是不能、不会、不能。凡是答不上「不」的地方,那就是一个真 bug —— 一个「只要调对接口,光有密码就够了」的账号 —— 而它在 code review 里是看不见的,因为 pre-auth token 和它能碰到的那些接口通常不在一个文件里。在图上,它只是一块被遮住的矩形。
常见问题
2FA 和 MFA 有什么区别?
2FA 恰好是两个因子,MFA 是两个或更多。所以每个 2FA 都是 MFA,反过来不成立。这些因子应该来自不同类别 —— 你知道的(密码)、你拥有的(手机、硬件密钥)、你本身是的(指纹)。两个密码不是 2FA,密码加密保问题也不是:两者都是「你知道的」,同一类的一次泄漏就能同时拿下两个。在图上,这个区别体现为 第二个值从哪来 —— 如果两个值都从同一个参与方、沿同一条路径过来,那你画的不是两个因子。
用户手机丢了怎么办?
这正是你的图需要回答的分支,而且答案必须在上线前设计好,不是出事时临时发明。真正可选的三条:开启时发的 恢复码(上面第三个例子里画了)、第二个已注册的因子 让丢一个不致命,或者一套 需要核验身份的客服流程 —— 那是一条人工流程,同样值得画出来,因为它也正是社工攻击会瞄准的那条。至少选一条。凡是答案是「联系客服」却没有明确核验步骤的系统,它的第二因子就是「电话里谁听起来更理直气壮」。
短信两步验证还值得画吗,还是已经过时了?
还值得画,因为你多半还在用它。短信是常见第二因子里最弱的一个 —— SIM 卡补办和 SS7 拦截都能绕过它,而且都不需要碰用户的设备 —— 但它也是完成率最高的一个,因为不用装 App。常见的取舍是:普通账号保留短信,管理和支付动作要求更强的因子。不管你怎么定,都要画出来,因为「这里允许短信、那里不允许」这条规则,要么活在图上,要么只活在某个人脑子里。
图上该允许失败几次?
六位验证码常见的选择是五次,但比数字更重要的是 这个计数挂在什么上。挂在 pre-auth token 上,不要挂在账号上:挂在账号上意味着任何知道用户名的人都能随意把这个用户锁死,你的安全措施就变成了一个拒绝服务工具。作废 pre-auth token 只是把真用户送回密码页,代价是十秒钟,而对攻击者的代价是整次尝试。数字和「计的是什么」都要写在图上。
开启流程和校验流程画一张还是两张?
两张。它们相隔几个月发生,实际参与方也不同(开启涉及二维码和恢复码,这两样在登录时根本不出现),失败模式更是毫无交集 —— 开启失败意味着两步验证没开起来,校验失败意味着有人被锁在外面。合成一张会得到一个有两个入口的图,读者会把它误读成「一条流程,开头可选」。上面的例子分开画,正是这个原因。
免费吗?
免费。没登录每天 20 次,登录后每天 500 次。把本页任何一个例子在编辑器里打开完全不计次 —— 那条路径根本不打模型。