能直接抄走的密码正则
这是用先行断言拼出来的经典"强密码"模式:
^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*]).{8,64}$
它一次性要求四件事——一个大写、一个小写、一个数字、一个符号——再加上总长度在 8 到 64 之间。诀窍在于,这四条要求没有一条是普通的"消费字符",它们全都是先探头看一眼、再让真正的匹配在最后发生。
为什么先行断言是对的工具
正则里普通的 token 会消费字符、把光标往前推。如果你在开头写 [A-Z],那就变成"第一个字符必须是大写",这比"某处至少有一个大写"严格得多。先行断言解决了这个问题。
(?=...)是零宽正向先行断言。它断言:从当前位置起,后面能匹配这个子模式;但它不会移动光标,也不把内容算进匹配结果。- 断言里的
.*[A-Z]意思是"从这儿往前任何地方有一个大写字母"。.*负责扫描,[A-Z]才是我们要找的东西。 - 因为每个先行断言都回到同一个起点,你可以把四个叠在一起,它们各自独立地扫整串。
只有四个断言全过之后,.{8,64}$ 才真正消费整串密码并确认长度。检查时光标压根没动,这就是关键。
为什么 NIST 劝你别这么干
这是本页核心论点。NIST 的特别出版物 800-63B——大多数银行和政府的参考标准——明确反对强制字符组合。理由如下:
- 强制混搭催生出
Password1!这种"合规但最该被先试"的密码。它增加用户烦恼,却没增加真正的熵。 - 它压制创造力:一长串随机单词组成的助记密码(类似
correct-horse-battery-staple)会因为缺个数字被拒,可它比P@ssw0rd强得多。 - NIST 转而建议:给一个宽松的最短长度(8 是底线,12 以上更好)、对比已知泄露密码库、拦掉明显规律。
上面那条正则作教学例子挺好。当生产策略用,它打的是一场错误的仗。
性能与人们真正会踩的坑
先行断言模式通常很快,因为整串只被扫常数次。出问题的方式更隐蔽:
- 忘了开头锚点:写
(?=.*[A-Z])却不带^,断言可能从字符串中间开始,放行本不该过的密码。 - 丢了
$:如果.{8,64}后没有$,引擎可能在匹配前 64 位后就不再管更长的尾巴,从而接收 200 位的密码。 - 写成 OR 而非 AND:若误用
|连接规则而不是叠先行断言,密码只需满足任一分支,策略被架空。 - 换行的意外:
.*默认不跨换行,除非开s(dotall)标志。粘贴进来的密码若夹了换行,可能悄悄绕过某条规则。
常见错误写法与正确写法
| 错误写法 | 问题 | 修正 |
|---|---|---|
| ^(?=.*[A-Z])$ | 只检查了一条规则,其余随便 | 在 .{n,m}$ 前把所有先行断言叠齐 |
| ^(?=.*[A-Z])(?=.*[a-z]).{8,}$ | 缺少数字和符号要求 | 补上 (?=.*\d) 和 (?=.*[!@#$%^&*]) |
| ^[A-Za-z\d!@#]{8,}$ | 只在第 1 位强制了字符类 | 用先行断言让字符类出现在任意位置 |
| ^.{8,64}$ | 完全没有组合要求(有时反而对) | 若遵循 NIST 只卡长度,可以接受 |
严格版与宽松版
你可以收紧符号集,也可以放宽。更严的规则可能要求两个符号;更松的则用 \W 放行任意非字母数字。但要注意 \W 把空白也算进去了,于是末尾一个空格就算"符号"——通常这不是你要的。只卡长度的 ^.{12,64}$ 反而是最站得住脚的现代选择,用户也最容易满足。
该用什么替代
把真正的安全工作从正则里挪出来:
- 先卡长度:设一个最短值(12 是不错的门槛),再设一个上限,防止超大输入打瘫服务。
- 比对泄露库:如果密码出现在泄露数据集里就拒绝,比如用 Have I Been Pwned 的 range 接口。
- 估计熵:做强度条时,算香农熵,或用工信部风格的 zxcvbn 估算器,它能识别字典词和键盘规律。
正则仍可以用来渲染界面提示,但真正的关卡应该是"长度 + 泄露比对",而不是字符类的障碍赛。
代码:查泄露库
import hashlib, requests
def is_breached(pw):
h = hashlib.sha1(pw.encode()).hexdigest().upper()
prefix, tail = h[:5], h[5:]
resp = requests.get("https://api.pwnedpasswords.com/range/" + prefix)
return tail in resp.text
把它和一个简单长度检查搭配,你就得到一套比四段先行断言怪兽既安全又少惹人烦的策略。