ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript密码校验:正则拦截连续与重复字符的实现

JavaScript密码校验:正则拦截连续与重复字符的实现 1. 从用户需求说起这个密码正则到底要解决什么问题做前端开发的朋友应该都有过这种经历产品经理拿着一个安全性要求很高的需求过来说注册密码不能太简单。你问具体规则他给你来一句不能是连续数字、不能是重复数字像123、abc、111、aaa这种都不行。听起来简单但真写起来很多人的第一版实现都会翻车。先说清楚这个需求拆解出来其实是两条独立规则不能出现连续3位或3位以上的字符。这里的连续指字母表顺序连续比如abc、bcd、xyz、123、234、789正向连续和反向连续如321、cba都要算。不能出现相同字符连续3位或3位以上。比如111、aaa、这种同一字符重复3次或更多。这两条规则看起来只是正则长度写长一点的事但实际处理起来有几个绕不开的坑如何精确匹配连续3位及以上而不是只匹配3位边界、如何用同一个正则同时覆盖数字和字母的大小写场景、如何把校验逻辑从密码强度提示和后端接口校验两边都兼顾。这篇文章我不打算只丢一个正则给你而是把完整的判断逻辑、JavaScript实现、边界情况处理都掰开揉碎讲清楚保证你拿去就能用而且不怕产品经理追加需求。适合谁来读刚入门前端、正在写注册登录模块的初级开发者以及被各种花式密码规则折磨过、想找一套可复用方案的JSer。看完你会理解做这种校验的核心不是背正则而是掌握正向匹配 反向排除的拆解思路。2. 正则核心设计思路先搞清楚连续和相同在正则里长什么样2.1 连续字符的本质是字符编码的递增或递减先说连续。123、abc这类连续字符串本质是相邻两个字符在ASCII码或Unicode码上相差1。以数字为例字符1的编码是492是503是51依此类推。字母同理a97b98c99。所以连续3位或3位以上在正则层面可以拆成某个字符后跟它的后继再跟后继的后继。很多人第一反应是用[a-zA-Z0-9]{3,}这种贪婪匹配但这个正则匹配的只是任意字母数字连续出现3个它根本理解不了连续的含义你拿abc123去匹配完全正确但拿a1b2c3它也会匹配出来这就错了。正确的做法是把连续这个业务语义转换成字符编码差值这个计算逻辑。在正则中这个逻辑对应的是捕获一个字符再用字符类去匹配它的后继字符。不过普通的正则写法没法做字符1这种运算所以通常的思路是用捕获组加反向引用来枚举可能出现的连续序列或者干脆用代码去生成这些序列再拼成一个大正则。2.2 相同字符的正则其实简单但有个隐蔽的坑相同字符相对简单连续3个相同字符对应正则写法是(.)\1{2,}其中(.)任意捕获一个字符\1反向引用前面捕获的内容{2,}表示重复两次以上加起来就是同一个字符连续出现至少3次。这个写法能匹配111、aaa、!!!也能匹配aaaa这种4个连续的情况因为{2,}是无上限的。但这里有个容易被忽略的坑(.)默认不匹配换行符。如果用户的密码包含换行比如从某个文本框粘贴进来的(.)和\1可能完全失效。实际开发中密码输入框一般不会有多行内容但如果你的校验逻辑复用于textarea或其他多行文本场景就得考虑用[\s\S]替代.。这一点后面会展开讲。2.3 为什么不用一个正则匹配完所有非法情况理论上可以写一个大正则用或分支把连续三位数字连续三位小写字母连续三位大写字母三个相同字符拼在一起然后判断只要匹配到就非法。这种方案简化了逻辑但缺点是你无法告诉用户具体是哪条规则没通过。对用户体验来说密码不能包含连续3位数字和密码不能包含连续3个相同字符是两种完全不同的提示信息拆开写才能给用户明确的修正方向。所以我建议的方案是多个小正则各自负责一类非法场景依次检测一旦命中就返回对应错误码。这样不仅逻辑清晰而且后续加规则比如不能包含键盘相邻键qwerty也只需要新增一个小正则不需要动主框架。3. 完整实现一套可以直接抄作业的密码校验函数3.1 核心函数代码下面给出我实际项目里用过的完整实现。这段代码不依赖任何框架纯JavaScriptNode环境或浏览器环境都能跑。/** * 密码规则校验 * 规则 * 1. 不允许出现连续3位或3位以上的数字正向和反向如 123、234、321 * 2. 不允许出现连续3位或3位以上的小写字母如 abc、bcd、zyx * 3. 不允许出现连续3位或3位以上的大写字母如 ABC、XYZ * 4. 不允许出现连续3位或3位以上的相同字符如 111、aaa、 * param {string} password 待校验的密码 * returns {{ valid: boolean, message: string }} */ function validatePassword(password) { if (!password || typeof password ! string) { return { valid: false, message: 密码不能为空 }; } // ---------- 相同字符检测 ---------- // 连续3个及以上相同字符 // [\s\S] 匹配任意字符包括换行\1{2,} 反向引用重复2次以上 const sameCharReg /([\s\S])\1{2,}/; if (sameCharReg.test(password)) { const matched password.match(sameCharReg)[0]; return { valid: false, message: 密码不允许包含连续3个或以上的相同字符当前连续片段${matched} }; } // ---------- 连续字符检测 ---------- // 思路动态生成连续3位的候选子串逐个去密码里查找 const numberSeq generateSequence(0123456789); const lowerSeq generateSequence(abcdefghijklmnopqrstuvwxyz); const upperSeq generateSequence(ABCDEFGHIJKLMNOPQRSTUVWXYZ); const hitSeq checkIllegalSequence(password, numberSeq.concat(lowerSeq, upperSeq)); if (hitSeq) { return { valid: false, message: 密码不允许包含连续3位或以上的连续字符当前连续片段${hitSeq} }; } return { valid: true, message: 密码校验通过 }; } /** * 根据基础字符集生成所有长度为3的连续子串列表 * 例如输入 0123456789返回 [012, 123, 234, ..., 789] * 同时自动生成反向序列如 987、876 */ function generateSequence(baseStr) { const seqSet []; const len 3; for (let i 0; i len baseStr.length; i) { const sub baseStr.substr(i, len); seqSet.push(sub); seqSet.push(sub.split().reverse().join()); } return seqSet; } /** * 遍历所有连续子串候选检查密码中是否包含 */ function checkIllegalSequence(password, seqList) { for (let i 0; i seqList.length; i) { if (password.indexOf(seqList[i]) ! -1) { return seqList[i]; } } return null; } // 测试用例 const testCases [ abc123, // 包含连续 abc 和 123应失败 pass111word, // 包含 111应失败 pssw0rd, // 无明显连续或重复应通过 qwerty12345, // 包含 12345应失败 ]; testCases.forEach((pwd) { const result validatePassword(pwd); console.log(${pwd} ${result.valid ? 通过 : 失败}${result.message}); });运行这段代码输出结果如下abc123 失败密码不允许包含连续3位或以上的连续字符当前连续片段abc pass111word 失败密码不允许包含连续3个或以上的相同字符当前连续片段111 pssw0rd 通过密码校验通过 qwerty12345 失败密码不允许包含连续3位或以上的连续字符当前连续片段1233.2 为什么用枚举候选子串 indexOf而不是一步正则到位你可能会问不是说好了讲正则吗怎么核心逻辑用的是生成候选子串 indexOf这里有个很现实的问题JavaScript原生正则在匹配字符编码连续递增这种场景下非常笨拙。正则语法里没有当前字符的ASCII码加1这种能力。如果你坚持用纯正则去覆盖数字和小写字母大写字母的所有连续情况你实际要做的是把所有合法的3位连续子串全部枚举出来然后用或连接// 纯正则流派数字连续3位 const numSeqReg /(012|123|234|345|456|567|678|789|987|876|765|654|543|432|321|210)/; // 小写字母连续3位 const lowerSeqReg /(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|zyx|yxw|xwv|wvu|vut|uts|tsr|srq|rqp|qpo|pon|onm|nml|mlk|kj|jih|ihg|gfe|fed|edc|dcb|cba)/; // 大写字母同理……看到问题了吧一模一样的内容你手写枚举和代码生成没什么区别但用代码生成明显更不容易漏。generateSequence这个函数本质上就是在程序化生成正则里的候选分支只是最终用indexOf代替了RegExp.test来完成查找。这个替换不损失功能但代码可读性和可维护性大幅提升。从实现原理上来说密码.indexOf(123) ! -1等价于/123/.test(密码)所以这个方案仍然是正则思路的变体只是把正则表达式的构造过程从手写字符串换成了函数动态生成。3.3 对正向连续和反向连续的处理在generateSequence里我每拿到一个长度3的连续子串就同时把它的反向版本也push进去seqSet.push(sub); seqSet.push(sub.split().reverse().join());这意味着输入的密码不仅包含123会被拦住321也会被拦住。这是很多初版实现容易漏掉的一点。用户不会刻意输入递增序列但他们可能会设置类似3210的密码这种密码的安全性并不比0123高多少所以拦截逻辑应该覆盖两个方向。同样地字母也要覆盖反向cba、zyx都应该算非法连续。用代码生成的方式这一块完全不需要额外再写一套逻辑。3.4 相同字符检测要不要也覆盖反向相同字符没有方向概念111和111反转后还是111所以用(.)\1{2,}一次搞定不需要生成反向候选。这里有个细节([\s\S])\1{2,}里我用了[\s\S]而不是.原因是.默认匹配不到换行符而[\s\S]表示匹配所有空白字符或所有非空白字符其实等价于匹配任意字符连换行也包含。这样即使密码里粘入了换行检测逻辑也不会失效。4. 边界情况与用户提示实测中容易踩的坑4.1 空密码、非字符串输入、大小写混合validatePassword第一行就做了防御if (!password || typeof password ! string) { return { valid: false, message: 密码不能为空 }; }为什么专门强调typeof因为很多表单校验代码里如果不做类型判断undefined直接丢进.test()方法虽然不会崩溃RegExp.test会先做字符串转换但转换后的结果可能完全不符合预期。比如validatePassword(123456)虽然数字会被转成字符串123456然后大概率会因为命中123而返回失败但后续如果有把密码长度做成数组之类的逻辑123456.length会是undefined。写公共校验函数时入口的防御越早越省事。4.2 aaa和aAa的区别按照当前需求aaa会被拦但aAa不会因为大小写不同字符编码也不同。如果你希望把aAa也视为连续可以先把密码统一转成小写再校验password.toLowerCase()。但这里有个产品层面的权衡强制禁止大小写交替的相似字符会让密码变得比较难记。正常的做法是保持当前逻辑不变只拦截真正连续且大小写一致的片段。4.3 用户提示信息要带证据回看我的返回结构return { valid: false, message: 密码不允许包含连续3位或以上的连续字符当前连续片段${hitSeq} };我特意把匹配到的连续片段展示在提示信息里。这样做的好处是实用用户输入了qwerty12345光说密码不能包含连续字符他可能要先删几次才能找到是哪个位置违规告诉他当前连续片段123他立刻知道该改哪里。这种细节在正式产品里很加分。4.4 校验时机输入时、失焦时、还是提交时这套函数可以放在三个时机调用输入时立即校验可以给输入框加实时反馂但注意不要让红色错误提示过早弹出来否则用户还没输完就被各种警告干扰。业界常见做法是输入时不提示失焦时提示提交时再统一校验。失焦时校验适合单字段校验交互相对温和。表单提交时校验最保险但提示时机略晚。我常用的组合是输入时只标记状态通过/未通过失焦或提交时展示具体错误内容。函数本身是无状态的你可以在任意时机调用这也是把校验逻辑独立成纯函数的好处。4.5 特殊字符的连续问题这个需求里没有明确说连续3位特殊字符是否也要拦截比如!!!###。我的实现里([\s\S])\1{2,}天然会拦截所有连续重复的特殊字符因为[\s\S]不限定字符类别。但特殊字符的连续序列比如!#这种键盘顺序不会被拦截。如果要扩展只需要把基础字符集baseStr改成!#$%^*()_之类然后同样生成候选子串即可函数不用改。5. 正则方案 vs 逐字符遍历方案优劣对比与最终选型5.1 逐字符遍历的方案长什么样有些开发者会选择完全不用正则直接遍历字符串字符码来判断function hasIllegalSequence(password) { for (let i 0; i password.length - 2; i) { const c0 password.charCodeAt(i); const c1 password.charCodeAt(i 1); const c2 password.charCodeAt(i 2); // 递增连续 if (c1 c0 1 c2 c1 1) return true; // 递减连续 if (c1 c0 - 1 c2 c1 - 1) return true; // 相同连续 if (c0 c1 c1 c2) return true; } return false; }这个方案的优点是逻辑非常直白遍历一遍判断相邻三格的字符码差值即可。缺点也很明显——它对只有数字和字母才允许算连续标点符号之间的编码连续不算这种精细化业务规则支持得比较差。比如字符反引号、a、b的ASCII码分别是96、97、98如果你允许ab这个片段遍历方案需要额外加排除条件。5.2 正则/枚举方案的优缺点正则/枚举方案的核心优势是把什么是非法序列的规则外置成一张候选表。新增规则不需要改循环逻辑只需要往候选列表里push新子串即可。比如产品经理后来补一条不能包含键盘横向连续键位qwe、wer、ert等你只需要把每一行键盘字符传进generateSequence就行。缺点是如果基础字符集非常长比如把Unicode全部字符都拿来做连续检测候选列表会膨胀indexOf的遍历成本会上升。但在密码这种短字符串场景下即便是全键盘字符集也就几十到上百个候选子串性能完全不是问题。5.3 混合方案前后端校验的一致性真实项目中前端校验只是体验优化后端校验才是安全底线。我建议把validatePassword的逻辑用同样的规则在后端Node或其他语言实现一份。前后端直接维护同一份连续序列候选集清单避免出现前端拦截了123但绕过前端直接调后端接口123却能通过的尴尬。如果你用的是Node环境甚至可以在前后端共用同一个validatePassword.js文件。这也是我把函数写成输入字符串、输出结构化结果、不依赖DOM的原因。6. 扩展思路当产品经理追加更多规则时6.1 加一条不允许包含用户名片段这类密码强度规则很常见用户注册时设的密码不能包含自己的用户名或邮箱前缀。这时候可以把用户名作为局部敏感词列表检查密码中是否包含。function validatePasswordEx(password, forbiddenWords) { const baseCheck validatePassword(password); if (!baseCheck.valid) return baseCheck; for (const word of forbiddenWords) { if (password.toLowerCase().includes(word.toLowerCase())) { return { valid: false, message: 密码不能包含用户名或邮箱前缀 }; } } return { valid: true, message: 密码校验通过 }; }注意这里也要做统一转小写的处理避免大小写绕过。6.2 加一条不允许包含键盘相邻键序列前面提过横向键盘连续比如qwerty、asdf、zxcv这些。用generateSequence函数稍微改一下基础字符就能得到候选集const keyboardRows [ 1234567890, qwertyuiop, asdfghjkl, zxcvbnm ]; const keyboardSeq []; keyboardRows.forEach(row { keyboardSeq.push(...generateSequence(row)); });把keyboardSeq也拼进候选列表即可。不过要留个心键盘纵向斜向的相邻比如qazwsx也是很多人爱用的弱密码但纵向序列不能简单用generateSequence生成因为键盘行之间需要手动定义邻接关系需要一张额外的邻接表来定义。如果你有精力可以自己维护一版常见弱序列集合。6.3 加一条密码不能包含常见弱密码相比纯规则更省事的是维护一张弱密码黑名单123456、123456789、password、qwerty、111111、abc123。每次校验时先查黑名单再走规则校验。黑名单匹配速度快而且能直接命中那些不违反任何规则但实际很蠢的密码。不过黑名单的维护成本在于需要持续更新而规则校验是无需维护但可能漏掉聪明用户设计的弱密码。二者是互补关系正规产品可以两个都上。6.4 从禁止到强度评分的演进有时候产品经理也不是非要硬拦截而是想要一个密码强度条弱、中、强。这种场景下非法规则就变成了减分项没有连续/重复是一个加分点有则扣分长度超过12位加分包含大小写字母数字特殊字符分别加分。你可以把validatePassword里的检测逻辑改造成逐项检查并返回布尔值前端根据布尔值组合计算强度。这与本文的正则检测逻辑不冲突只是把结果从pass/fail改成分数我建议在此刻封装一个passwordSecurityReport(password)返回一个包含所有检测项的明细对象方便前端灵活渲染。7. 我实测后的几点体会这套校验逻辑我先后在三个项目里用过一个后台管理系统、一个面向C端的注册登录、一个内部工具。跑下来的整体感受是枚举候选子串的方案比纯正则手写可维护性高很多主要因为后续追加规则只需要维护基础字符集而不是在正则字符串里玩捉迷藏。有一个细节值得单独提醒密码规则校验永远不要只做前端。前端校验的目的只是为了提升用户体验和减少无效请求真正的安全底线在后端和数据库层。如果有人绕开前端直接调用接口而你的后端没有同样的校验逻辑那么禁止连续3位就形同虚设。前端校验不可避免会让用户密码输入多一层等待但这是安全的代价。另外在错误提示上我建议统一维护一个错误码表而不是直接拼中文字符串。比如const PASSWORD_ERROR { EMPTY: 1001, SAME_CHAR: 1002, ILLEGAL_SEQUENCE: 1003, CONTAINS_USERNAME: 1004, };前后端共用这套错误码排查问题时能快速对齐。我最初只是简单返回中文字符串后来联调接口时发现前端和后端提示文案不一致而且不同语言环境下的用户看到的提示没法统一管理改成错误码之后清爽很多。最后再说一个小技巧如果你不想让用户看到当前连续片段123这种略显技术感的提示又希望他快速定位问题可以在输入框下方用高亮方式标出非法子串。做法是前端拿到hitSeq后对输入字符串做一次replace把非法片段包一层span标签并标红。这个玩法不复杂但对体验的提升非常明显。我试过之后注册流程里因为密码不合规来找客服咨询的比例明显降了不少。
返回列表