常用正则内含 1 场可玩狩猎

用正则先行断言校验密码规则(以及何时不该这么做)

如何用先行断言在一个正则表达式里检查“至少一个大写字母、一个数字、八个字符”,为什么不限顺序的写法离不开它们,常见正则里有哪些陷阱,以及为什么把每条规则分开检查往往是更好的设计。

Regex Hunter 标志

· 阅读约 3 分钟

/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/

抓住

  • Secret123
  • aB3defgh
  • Tr0ub4dor
  • 9Lives_cat

放过

  • secret123
  • SECRET123
  • Secretpass
  • Ab1

“至少 8 个字符,包含一个大写字母、一个小写字母和一个数字。”这是人们最常让正则表达式做的事之一,也是学习先行断言(lookahead)的好机会,因为这些规则并没有说每个字符在哪里。

本文一条规则一条规则地搭建正则,讲清其中的陷阱,最后说明为什么干脆不用一个大正则。每个示例都可以交互,修改正则就能看到各行的变化。

快速答案JavaScript
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/

至少 8 个字符,至少包含一个小写字母、一个大写字母和一个数字,顺序不限。

匹配

  • Secret123
  • 9Lives_cat
  • aB3defgh

不匹配

  • secret123
  • SECRET123
  • Ab1

01为什么普通正则做不到

正则从左往右读。要表达“包含一个数字”,你可以写 .*\d.*,单独用没有问题。但“包含一个数字并且包含一个大写字母”,不限顺序,就得把每种顺序都列出来:

正则31 字符
/^(.*[A-Z].*\d.*|.*\d.*[A-Z].*)$/
在沙盒里试试
两条规则,两种顺序。三条规则需要六个分支,四条规则需要二十四个。

这样根本扩展不了。你需要一种检查规则时不在文本中前进的办法。

02先行断言只检查,不前进

先行断言 (?=…) 检查从当前位置开始是否能匹配某个内容,然后回到起点。所以开头的几个先行断言可以各自扫描整个密码,每个负责一条规则。

一条规则:“后面某处有一个数字”:

匹配3/3 符合预期
/^(?=.*\d)/

应当匹配2/2

  • abc1
  • 1abc

不应匹配1/1

  • abcd
在沙盒里试试
(?=.*\d) 向前查找“任意内容,然后一个数字”。只要找得到,匹配就在位置 0 成功。

\d 前面的 .* 正是“某处”得以成立的原因。没有它,(?=\d) 的意思就变成“紧接着的下一个字符是数字”。

03叠加规则

每条规则各用一个先行断言,全部锚定在开头,最后由长度检查来消耗文本:

匹配7/7 符合预期
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/

应当匹配3/3

  • Secret123
  • aB3defgh
  • 9Lives_cat

不应匹配4/4

  • secret123
  • SECRET123
  • Secretpass
  • Ab1
在沙盒里试试
三个先行断言(一个小写字母、一个大写字母、一个数字),然后是 8 个或更多任意字符。

把它当成一份条件清单来读。加一条规则就是加一个先行断言,顺序随意:

匹配3/3 符合预期
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/

应当匹配2/2

  • Secret123!
  • p@ssW0rd

不应匹配1/1

  • Secret123
在沙盒里试试
第四个先行断言要求一个符号:任何既不是字母也不是数字的字符。

禁止某样东西的先行断言写作 (?!…)。“任何地方都不能有空格”:

匹配2/2 符合预期
/^(?!.*\s)(?=.*\d).{8,}$/

应当匹配1/1

  • correct1horse

不应匹配1/1

  • correct 1 horse
在沙盒里试试
只要后面任何地方出现空白字符,(?!.*\s) 就会失败。

04常见正则里的陷阱

忘了锚点。 没有 ^ 和 $,.{8,} 只需要在某处有八个字符,而每个先行断言都从匹配恰好开始的地方运行。把 ^ 放在开头,让所有先行断言都从位置 0 出发;把 $ 放在结尾,让长度检查覆盖整个字符串。

最大长度不起作用。 没有 $ 的 .{8,64} 会愉快地匹配一个 500 字符密码的前 64 个字符。加上 $ 它才会拒绝,这才是你的本意。

“符号”范围太窄。 像 [!@#$%^&*] 这样的字符类会拒绝 ?、~、-、空格以及所有非英文字符。[^A-Za-z0-9](“不是字母也不是数字”)通常才是人们真正想表达的意思。

A 到 Z 以外的字母。 [A-Z] 不会把 É 算作大写字母。加上 u 修饰符后,\p{Lu} 和 \p{Ll} 分别表示任意大写字母和小写字母。

05何时不该这么做

一个正则只给出一个答案:通过或失败。而你的注册表单需要告诉用户漏掉了哪一条规则:

const rules = [
  { test: (p) => p.length >= 12, message: "Use at least 12 characters" },
  { test: (p) => /\d/.test(p),   message: "Add a number" },
  { test: (p) => /[A-Z]/.test(p), message: "Add a capital letter" },
];

const problems = rules.filter((r) => !r.test(password)).map((r) => r.message);

每项检查都很小,易读又易改,错误提示也顺手就有了。

还值得问一句:你到底需不需要字符组成规则。NIST 的密码指南(SP 800-63B)不建议使用“一个大写字母、一个数字、一个符号”这类规则,因为它们会把人逼向 Password1!。该指南更看重长度:允许长口令(至少 64 个字符,含空格),并把新密码与已知泄露密码列表进行比对。

所以先行断言这种写法值得掌握,既适用于密码,也适用于任何“必须包含以下全部内容,顺序不限”的检查。但对于真实的注册表单,最小长度加上泄露密码检查,通常比它更好。

06试一试

抓住同时包含数字和字母的字符串,顺序不限:

可玩狩猎标准杆 20

抓住至少包含一个字母和至少一个数字的字符串,顺序不限。

目标 · 抓住 0/6

  • a1目标
  • 1a目标
  • abc123目标
  • 123abc目标
  • x9y目标
  • R2D2目标

无辜词 · 误抓 0

  • abc无辜
  • 123无辜
  • ---无辜
  • a-b无辜
  • 4-5无辜
  • !!无辜

输入正则——棋盘会实时更新。

在沙盒里试试
喜欢吗?每天都有新的狩猎。玩今天的狩猎

07检查清单

  • (?=.*X) 表示“后面某处出现 X”。每条规则叠加一个。
  • (?!.*X) 表示“后面任何地方都不出现 X”。
  • 用 ^ 锚定,让每个先行断言都从位置 0 开始;用 $ 锚定,让长度上限覆盖全部内容。
  • 用 [^A-Za-z0-9] 表示“符号”,比手挑的列表更好。
  • 在表单里,分开检查就能给出分开的错误提示。
  • 长度加泄露密码检查,胜过字符组成规则。

08继续练习