用户名密码代码
去年秋天,用户我帮一个做独立开发的名密码代码朋友排查他项目里的登录故障。问题不复杂:用户注册后偶尔无法登录,用户概率大概百分之三。名密码代码他查了两天,用户怀疑数据库连接池、名密码代码怀疑加密库版本,用户甚至重装了服务器。名密码代码我让他把注册和登录两段代码并排打印出来,用户看了十分钟,名密码代码发现注册时密码做了 trim,用户登录时没做。名密码代码用户在手机端输入密码,用户键盘自动在末尾带了个空格,名密码代码注册时被清掉了,用户登录时却原样参与哈希。就这一行差异,折腾了他四十八小时。
用户名和密码这件事,表面看是最简单的表单逻辑,实际上藏着一堆容易被忽略的细节。我见过太多项目在这上面翻车,而且往往不是技术难度的问题,是思维盲区的问题。
用户名不是随便起的
很多系统允许用户名包含任意字符,觉得反正只是个标识。等到做日志分析、URL 路由、数据库查询的时候,麻烦就来了。空格、引号、百分号、反斜杠,每一个都可能在某个环节咬你一口。我倾向于注册时就用一条正则把用户名限制在字母、数字、下划线和连字符范围内,长度三到二十。不是技术做不到兼容更多字符,是没必要给自己找事。
大小写也是个坑。如果用户名不区分大小写,那存储时必须统一转成小写或大写,否则 “Alice” 和 “alice” 会变成两个账号。但显示的时候又要保留用户原本输入的大小写。这意味着你需要两个字段:一个用于展示,一个用于唯一索引和查询。很多团队一开始只存一个字段,等用户量上来才发现有重复账号,改起来极其痛苦。
密码存储的底线
2024 年了,还在用 MD5 存密码的项目我依然能碰到。不是段子。上个月审一个外包交付的后台系统,数据库里密码字段是 32 位十六进制字符串,一看就是 MD5。更离谱的是没有加盐。这意味着彩虹表一查一个准。
正确的做法不复杂:用 bcrypt 或者 argon2。这两个算法自带盐值,计算速度可调,专门为密码存储设计。Python 里 bcrypt.hashpw(),Node 里 bcrypt.hash(),Go 里 golang.org/x/crypto/bcrypt,都是几行代码的事。成本因子设到 10 到 12 之间,登录时验证大概消耗几十到一百多毫秒,用户几乎无感,但暴力破解的成本被拉高了好几个数量级。
有人会问,那 SHA-256 加盐行不行?行,但不够好。SHA 系列是为速度设计的,GPU 每秒能算几十亿次。bcrypt 故意设计得慢,每次哈希都带着盐重新计算,攻击者用 GPU 集群也讨不到便宜。
那些看似聪明的蠢主意
我见过一个系统,密码在前端用 JavaScript 做了一次 SHA-1,传到后端再 bcrypt。开发者觉得这样“更安全”,因为原始密码不会出现在网络请求里。实际上 HTTPS 已经解决了传输安全问题,前端哈希反而制造了新漏洞:哈希后的字符串变成了事实上的密码,一旦数据库泄露,攻击者不需要破解原始密码,直接拿哈希值就能登录。这叫 pass-the-hash 攻击,Windows 域环境里早就是老问题了。
还有一个常见错误:登录失败时提示“用户名不存在”还是“密码错误”。很多安全指南说统一提示“用户名或密码错误”,防止攻击者枚举用户名。这没错,但实际体验很差。用户明明记得自己注册过,却被告知“用户名或密码错误”,完全不知道问题出在哪。折中方案是:登录失败统一提示,但注册时如果用户名已存在,可以明确告知。毕竟注册接口本来就需要检查唯一性,枚举风险无法完全避免,不如把体验做好。
代码之外的事
密码重置流程比登录本身更容易出问题。我见过重置令牌用自增 ID 的,见过令牌永不过期的,见过重置链接直接包含用户邮箱的。这些都不是代码 bug,是设计缺陷。令牌必须是加密随机数,至少 32 字节,存数据库时哈希后存储,设置十五分钟到一小时的过期时间,用一次就失效。
登录失败次数限制也值得做。不用搞太复杂,同一用户名连续失败五次,锁定十五分钟,或者加个指数退避。能挡住绝大部分脚本小子。真正有耐心的攻击者挡不住,但那是另一个层面的问题了。
说到底,用户名密码这套东西,技术含量不高,但细节密度极大。每一个字段、每一次比较、每一个分支,都可能有坑。写代码的时候多问一句“如果这里出错了会怎样”,能省下后面无数个加班的夜晚。
(责任编辑:休闲)














