登录页是唯一一扇所有人都要摸的门,也是唯一一扇你不能关的门。
一、一句「我从没注册过」
2026 年 9 月 3 日,一个用户写信说:「我注册时提示邮箱已存在,但我从来没注册过。」他去查,发现登录页对已注册邮箱返回「密码错误」,对未注册邮箱返回「账号不存在」。两个不同的句子,等于把「这个邮箱有没有账号」免费送出去。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
更麻烦的是时间。已注册的请求要校验哈希,比未注册的请求慢;两者的差超过了 50 ms 的要求。有人不需要读文案,只看响应快慢就能批量判断 344 个账号里哪些真实存在。
他先补的不是验证码,而是三处文案与三条时间预算:注册、登录、重置统一成「如果该邮箱存在,重置邮件已发送」,并在三处都把响应压到同一个区间。这一步花了不到一个下午,却堵住了最便宜的枚举方式。
登录页是唯一一扇所有人都要摸的门。任务板没有公开主页,没有社区,没有内容流;陌生人能接触到的唯一入口就是注册与登录。这决定了认证不是「一个功能」,而是整个产品的门面。
那天他还顺手跑了一遍弱密码规则。344 个账号里命中 12 个,占 3.5%:12345678、password、和站名相同的组合。12 个人里没有一个是恶意用户,他们只是把任务板当成一个「随便注册看看」的工具。
强制改密的通知他改了三版才发出去。第一版写「您的密码不安全」,第二版写「系统升级需要重置密码」,第三版才写清楚:系统检测到你的密码在公开泄露库中出现过,请在下次登录时修改,旧密码立即失效。第三版发出后,12 个人里 11 个在两天内改完,剩下 1 个从此没再登录。
二、密码、魔法链接与 OAuth 的取舍
他评估过三种登录方式,最后没有全上。
| 维度 | 密码 | 魔法链接 | OAuth |
|---|---|---|---|
| 用户成本 | 记一个密码 | 切到邮箱点链接 | 有第三方账号即可 |
| 实现成本 | 哈希、重置、防枚举 | 邮件送达、令牌、退信处理 | 回调、账号合并、邮箱一致性 |
| 主要失败模式 | 复用与撞库 | 延迟、垃圾箱、链接被转发 | 配置错、回调被劫持 |
| 适合的场景 | 老用户与低频工具 | 极低频、无密码偏好 | 开发者与企业用户 |
密码是默认项,因为它不依赖任何外部服务。代价是所有失败路径都要自己写:哈希、重置、锁定、防枚举,一条都不能省。
魔法链接看起来最省事,实际上把可用性押在邮件送达上。他试过两周,三封链接进了垃圾箱,一封被企业邮箱网关延迟了 40 分钟;用户不知道发生了什么,只会觉得「登录坏了」。
OAuth 只接了一个 GitHub,因为任务板的目标用户是开发者。接第二个、第三个的边际收益很低,但每接一个都要处理「同一个邮箱两个身份」的合并问题。
邮箱一致性是 OAuth 最容易出事的地方。GitHub 允许把主邮箱设为私密,回调里可能拿不到邮箱;这时不能按用户名自动合并,否则有人可以把用户名改成别人的邮箱前缀,接管一个已有账号。他的做法是:拿不到已验证邮箱就要求再走一次邮箱验证,绝不按昵称合并。
三种都上的代价不是开发时间,而是三套找回流程、三套限流、三套审计字段。一个人的维护预算不允许同一件事有三条实现路径。
他最后的结构是:密码为主,GitHub OAuth 为辅,魔法链接只出现在「重置」环节,不作为日常登录方式。
三、哈希、重置与防枚举
不管选哪种方式,有三件事必须做对,第四件是前一件的兜底。
| 必做 | 默认动作 | 验收方式 |
|---|---|---|
| 哈希 | Argon2id,参数按生产机器实测调整 | 数据库里没有明文,也没有可逆密文 |
| 重置 | 32 字节随机令牌、30 分钟、一次性 | 同一令牌第二次使用返回失效 |
| 防枚举 | 三处统一文案、统一时间区间 | 响应时间差 <50 ms,文案不区分存在与否 |
| 锁定 | 5 次/账号/15 分钟 | 第 6 次返回 429 与等待秒数 |
哈希参数不是抄来的。他在与生产同规格的机器上跑一次校验,把内存与迭代次数调到「单次校验不拖慢登录、但批量猜测足够贵」的位置,然后把参数写进配置,升级机器时重测。
重置令牌 32 字节,也就是 256 位随机值;数据库只存它的哈希,不存明文。有效期 30 分钟,够用户从收件箱点进来,又不至于让一封被转发的邮件在几天后仍然可用。
一次性靠一个 used_at 字段保证:校验令牌与写入使用时间放在同一个事务里,第二个请求要么读到已使用,要么在唯一约束上失败。只把令牌从邮件里删掉不算数,链接可能已经被复制。
改密与重置之后还要让旧会话失效,否则用户以为「密码改了,别人就进不来了」,实际上对方的会话还在。他在用户表放一个 session_epoch,改密时加一,服务端会话表按 epoch 批量撤销;这一步属于认证,不属于会话管理。
防枚举不只发生在登录页。注册页的「邮箱已存在」、重置页的「未找到账号」、甚至邀请页的「该用户已在组织中」,都在回答同一个问题。他把这些句子的判断逻辑收到一个函数里,避免下次改文案时漏掉一处。
锁定按账号计,不按 IP 计,因为 IP 会变、共享出口会误伤。5 次失败锁 15 分钟,同时保留 OAuth 作为旁路;再加 20 次/IP/小时的粗粒度限制,挡住撞库脚本。
12 个弱密码的处理只花了 48 分钟:每人 4 分钟,包括生成通知、标记账号、验证旧密码失效。这笔时间摊到全年几乎看不见,但它是唯一一次能主动清掉历史弱密码的机会。
四、一次复算:三件必做的成本
认证的投入大多是开发时的一次性成本,能被清楚计量的只有弱密码处理这一小块。
复算:弱密码命中 12 ÷ 344 = 3.49% ≈ 3.5%
强制改密:12 人 × 4 分钟 = 48 分钟 = 0.8 h
占全年:0.8 ÷ 1,920 = 0.04%
重置令牌:32 字节 = 256 位;有效期 30 分钟;一次性
防枚举:注册、登录、重置三处响应时间差 < 50 ms
锁定:5 次/账号/15 分钟;20 次/IP/小时
48 分钟看起来可以忽略,但 12 个账号里有 4 个是付费用户,占 120 个付费用户的 3.3%。认证问题从来不是按账号总数算影响,而是按「这些人里有多少会因此离开」算。
与一次账号接管的成本相比,48 分钟几乎为零。账号被接管后要处理的是:重置密码、通知用户、检查导出记录、判断是否涉及付费信息,任何一项都超过 48 分钟;这笔账不需要精确,只需要在决定「要不要现在做」时想起来。
3.5% 这个比例他只用来做一件事:判断弱密码规则要不要继续加严。12 个命中里,9 个是纯数字或站名变体,3 个是常见英文单词;再加规则只会把第 13、14 个人推去用「Password2026!」这类更容易猜的变体。
他没有强制全部 344 个账号改密,只处理命中泄露库的 12 个。全量强制改密会把日活打掉一截,而且大部分新密码仍然会复用;按泄露库命中来处理,命中率低,但每一次都是真的该改。
响应时间差 <50 ms 是一个工程预算,不是安全承诺。做法很朴素:三处入口先做同样的参数校验,再统一从后台取一次「是否有账号」,发信放进异步队列,最后返回同一句话。只要慢的那一步不在同步路径上,时间差就压得住。
5 次锁定会误伤。他见过用户连续试了五次旧密码,第六次才想起正确的那一个;15 分钟的锁定期里,页面要明确写出「还有多久」和「可以用 GitHub 登录」,否则用户会直接关掉页面。
认证的复杂度不在登录成功,而在登录失败。成功路径只有一条,失败路径有十几条:密码错、账号不存在、账号被锁、令牌过期、令牌已用、OAuth 回调状态不匹配。每一条都要有同样的日志字段,否则出事时拼不出一次完整的尝试。
他没有上短信验证码。按 344 个账号的规模,短信成本与覆盖收益不匹配;二次验证留给了不可逆入口,比如注册邮箱和云根账号,而不是日常登录。
五、行动清单与复算验收
- 把注册、登录、重置三处的失败文案统一成不泄露账号存在与否的句子。
- 实测三处响应时间,把差值压到 50 ms 以内。
- 确认密码用 Argon2id 或 bcrypt 存储,数据库里没有明文。
- 重置令牌改为 32 字节随机值,30 分钟过期,使用一次即失效。
- 给登录加 5 次/账号/15 分钟的锁定,并保留 OAuth 作为旁路。
- 跑一遍弱密码规则,列出命中账号与处理方式。
- 强制改密的通知写清楚原因、时限与旧密码失效规则。
- 每季度用一个新邮箱走一遍注册到重置的完整路径。
验收标准:用一个陌生邮箱和一个已注册邮箱分别走注册、登录、重置,三处的文案、时间与状态码都无法区分账号是否存在。
前四条是「把门锁好」,后四条是「让锁一直好着」。如果只有半天,先做第一条和第三条;文案泄露和明文存储是最便宜的两个漏洞,也是最容易被忽略的两个。
清单不要一次全做。第一周统一文案,第二周压时间差,第三周才动令牌与锁定;一次改太多,回归时你分不清是哪一个改动让登录变慢。
验收时用两个浏览器窗口:一个登录已注册账号,一个用陌生邮箱走注册。两边同时提交,观察返回的文案、状态码和耗时;只读代码看不出时间差,必须实测。
使用边界与实操验收
三种方式没有优劣,只有取舍。 密码依赖用户,魔法链接依赖邮件,OAuth 依赖第三方;选哪一种,等于选一种失败模式,而不是消灭失败。
哈希参数要实测。 同一个 Argon2id 参数在不同机器上的耗时可能差一倍;抄来的参数要么太慢拖垮登录,要么太快让离线猜测变便宜。
锁定会误伤。 按账号锁比按 IP 锁更准,但必须有旁路和明确的等待提示;没有旁路的锁定,本质上是把攻击者的成本转嫁给了真实用户。
48 分钟是教学基线。 344 个账号、12 个弱密码与 3.5% 都取自本组统一口径,不构成对任何产品账号质量的判断,也不构成安全承诺。
不替代合规要求。 身份核验、留存义务与通知时限因司法辖区而异;涉及这些内容时,以具体司法辖区与专业意见为准。
关键参考与核验范围
- NIST SP 800-63B :用于说明口令长度、泄露库比对与重置流程的推荐做法。
- OWASP Password Storage Cheat Sheet :用于核对哈希算法与参数选择。
- OWASP ASVS :用于核对认证与会话管理的验证项。
- Have I Been Pwned API :用于说明如何在注册或登录时比对公开泄露库。
以上链接于 2026-10-26 实测可公开访问;未核验版本、接口配额与条款变更。核验日期:2026-10-26。
边界句。 本文只讨论一个人的认证方式取舍、密码存储与防枚举,不讨论多因素认证的产品选型、身份提供商集成与合规审计。
系列导航
- 上一篇:攻击面是一张清单:十四个入口与它们的坏结局
- 下一篇:会话与令牌:过期、撤销与一次没退出的登录
- 回到目录:一个人的安全基线:十四个入口、十七个密钥与一次 72 小时响应
- 相关阅读:权限判定的三层校验:UI、API 与数据
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。