小Cの已经记不起来的博客

正则表达式进阶:鉴权体系 匹配技巧

前段时间给公司的网关加统一鉴权,方案是 nginx auth_request + JWT,思路很简单:所有请求先被网关拦一道,丢给鉴权服务验签,通过了才放行到上游。听着挺美好,结果联调第一天就翻车了,健康检查、支付回调、还有一堆 /public 开头的静态资源全被 401 了,上游一个请求都没收到。一开始我的想法是给每个路径单独加 location,写到第十几个的时候人已经麻了,这玩意儿维护起来是真的痛苦,路径一多自己都分不清哪条是放行哪条是拦截...

最后决定用正则把白名单收拢到一起统一管理,顺手把鉴权体系里其他几个常用的匹配场景(token 校验、日志脱敏、密码强度)也一起整理了。下面这些都是实测踩完坑的版本,直接抄就能用,但有几个前提你得先搞清楚,不然就会跟我一样对着 error 日志排查一晚上。

动手前先确认引擎

这是最坑的一点,没有之一。我一开始在 regex101(PCRE 模式)上把规则调得好好的,丢进服务里直接就废了,报错信息一句人话都没有。后来翻了半天文档才发现,那个服务底层用的 Go,正则引擎是 RE2,而 RE2 压根不支持前瞻断言 (?=...)、后顾断言 (?<=...) 和反向引用 \1,写上去不是匹配不到,是直接编译报错,形如 error parsing regexp: invalid or unsupported Perl syntax。

所以先搞清楚你的运行环境用的是什么:nginx 是 PCRE,基本全功能;Go 生态是 RE2,断言和反向引用想都别想,但好处是线性时间,不怕正则爆炸;前端是 ECMAScript,现代浏览器断言都能用了,但要兼容老环境的话还是别用。后文我会尽量给三者通吃的写法,实在绕不开断言的地方会单独说明。

匹配 Bearer token

JWT 长这样 xxxxx.yyyyy.zzzzz,三段用点隔开,每段是 base64url 编码,字符集是 A-Za-z0-9 加上 - 和 _。注意没有 + 和 /,也没有 =,所以别拿网上那种 [A-Za-z0-9+/=]+ 的通用 base64 规则来套,套是能套上,但等于放进来一堆不可能出现的字符,校验了个寂寞。

^Bearer\s+[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$

几个细节:\s+ 别写成一个空格,真实世界里我见过 header 里带了多余空格的客户端,具体是哪家就不点名了;有的老系统发的是全小写 bearer,不放心就用 (?i) 或者 [Bb]earer;另外如果你只是想从日志里把 token 抓出来,记得把 ^ 和 $ 去掉,不然 token 前面只要有别的内容就匹配不上了,别问我怎么知道的。

路径白名单里的坑

先看让我翻车的最初版:

^/public/.*

看着没毛病对吧?结果联调的时候同事拿 /public/../admin/internal 一下就绕过去了,我当时脸都绿了。原因很简单,. 匹配任意字符,包括 / 本身,所以 .* 会一路吃到天荒地老。结论:匹配单段路径用 [^/]+,真的要匹配多段才用 .*,而且前面最好先做路径归一化(URL decode、合并连续斜杠、处理 ..),不然编码绕过能玩出花来。

实测可用的白名单长这样:

^/(actuator/health|actuator/info|public/[^/]+|callback/(alipay|wechat|unionpay))$

再补一个 nginx 特有的:location ~ 是区分大小写的,~* 才不区分。我在 Windows 上开发的时候路径大小写随手写都能跑,上了 Linux 就开始莫名 401,排查了半天才发现是这个问题,可能也是我瞎没注意到吧...

日志脱敏的写法

鉴权上了之后还有个附带问题:Authorization 头会原样进访问日志,等于 token 到处飞,被安全扫描扫出来就是一条整改项。做替换(不是匹配)的时候,用捕获组把前缀留住,只把 token 本体干掉:

(?i)(authorization\s*:\s*bearer\s+)[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+

替换成 $1***,日志里就只剩 Authorization: Bearer *** 了,还不影响后面按前缀做统计。API key 同理:

(?i)(api[-_]?key["']?\s*[:=]\s*["']?)[A-Za-z0-9]{16,}

同样替换成 $1***。{16,} 这个下限按你们实际的 key 长度来,太短会把正常字段误伤,太长会漏报,我就是随手举的例子,数字别照抄。想粗看一下日志里 token 的量级,也可以直接:

grep -oE '[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' access.log | sort | uniq -c

这个会把 1.2.3 这种版本号也捞出来,无所谓的,看个量级而已。

密码强度校验

最经典的 lookahead 场景:要求同时含大小写、数字、特殊字符,长度 12 到 64。

^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).{12,64}$

思路是每个 (?=...) 都从字符串开头往右扫一遍,四个条件各查各的互不干扰,最后 .{12,64} 控制总长度。最后一段我用的是 [^A-Za-z0-9\s] 而不是 \W,因为 \W 认为空格也算“非单词字符”,也就是说用户拿一个空格充当特殊字符也能过校验,这显然不是你想要的。

如果你的引擎是 RE2,这个写法直接用不了,别硬憋,老老实实拆成几个独立的小正则在代码里分别跑:长度一个判断,四类字符各一个判断。代码多几行,但清清楚楚,排查问题也方便。

写在最后

正则这东西,写的时候五分钟,排查的时候一晚上,鉴权相关的尤其如此,因为它错了不会崩,只会安静地放行不该放的人。我的习惯是先把规则丢进 regex101 过一遍测试用例,正例反例都得有,确认没问题再上环境,然后拿 curl 打几条边界请求做最终验证:

curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc_def-123" http://127.0.0.1:8080/api/user

不出问题的话就已经能正常放行了,换个缺一段的 token 再打一次,被拦掉就说明链路是通的。另外白名单能收紧就收紧,宁可多一次误拦被人找上门,也别给路径穿越留门。上面这些规则直接抄过去,改改路径和长度就能用了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放