欢迎你来读这篇博客,这篇博客主要是关于 Passay 和 zxcvbn 的。
这两个工具都和“密码安全”有关,但它们解决的问题不一样:
Passay 更像是 密码规则执行器 :密码必须多长?是否允许空格?是否包含用户名?是否命中弱密码字典?
zxcvbn 更像是 密码强度评估器 :这个密码站在攻击者角度看,是否容易被猜出来?是不是 P@ssw0rd123! 这种看起来复杂、实际很弱的密码?
如果只用 Passay,容易陷入传统的“8 位 + 大小写 + 数字 + 特殊字符”复杂度迷信。
如果只用 zxcvbn,又缺少企业密码策略的硬性约束,比如长度下限、禁止包含用户名、禁止使用公司名、禁止命中特定弱口令名单。
所以在真实项目中,更推荐的做法是:
Passay 做底线规则,zxcvbn 做真实强度评估,Spring Security 负责密码哈希存储。
这篇文章会从原理、对比、工程设计、Spring Boot 实战、前端实时提示、后端最终校验、测试用例和生产落地建议几个角度展开。
序言 很多系统里的密码策略长这样:
1 2 3 4 5 密码长度 8 到 20 位 必须包含大写字母 必须包含小写字母 必须包含数字 必须包含特殊字符
看起来很安全,对吧?
但用户通常会这么设置:
1 2 3 4 Password1! Admin@123 Qwer1234! Company@2026
这些密码满足“复杂度规则”,但对攻击者来说并不难猜。
这就是传统复杂度规则的问题:它检查的是字符种类,不是密码是否真的难猜。
现代密码策略更应该关注:
密码是否足够长;
是否命中常见弱口令;
是否包含用户名、邮箱、系统名、公司名等上下文信息;
是否存在明显模式,比如 123456、qwerty、aaaaaa、日期、重复片段;
用户体验是否可接受;
后端是否使用安全的哈希算法存储密码。
Passay 和 zxcvbn 正好可以组合起来解决这些问题。
一、Passay 是什么? Passay 是一个 Java 密码策略校验与密码生成库。
它的核心定位是:
Password policy enforcement for Java
也就是在 Java 项目里执行密码策略。
Passay 可以通过一组规则来判断密码是否符合要求,也可以按照规则生成随机密码。
例如:
1 2 3 4 5 6 7 8 长度至少 12 位 最多 128 位 不能包含空白字符 不能包含用户名 不能出现 5 位以上连续字母 不能出现 5 位以上连续数字 不能出现 QWERTY 键盘连续字符 至少满足 3 类字符组合
Passay 擅长的是“规则化、可解释、可配置”的密码策略。
1.1 Passay 的核心组件 Passay 主要有三个核心概念:
组件
作用
Rule
密码规则,例如长度规则、字符规则、字典规则
PasswordValidator
密码校验器,用一组规则校验密码
PasswordGenerator
密码生成器,根据规则生成密码
Passay 的规则大体分为两类:
规则类型
说明
Positive Match
密码必须满足某些条件,例如必须达到长度、必须包含数字
Negative Match
密码不能命中某些条件,例如不能包含用户名、不能包含空格、不能命中字典
1.2 Passay 常用规则
规则
作用
LengthRule
限制密码长度
CharacterRule
要求包含指定字符类型
CharacterCharacteristicsRule
要求满足 M of N 字符类别
WhitespaceRule
禁止空白字符
UsernameRule
禁止密码包含用户名
IllegalSequenceRule
禁止连续序列,例如 abcde、12345、qwert
RepeatCharactersRule
禁止重复字符序列
CharacterOccurrencesRule
限制同一字符出现次数
DictionaryRule
禁止密码完全命中字典词
DictionarySubstringRule
禁止密码包含字典词
HistoryRule
禁止复用历史密码
AllowedRegexRule
要求匹配指定正则
IllegalRegexRule
禁止匹配指定正则
1.3 Passay 适合做什么? Passay 适合做这些事情:
1 2 3 4 5 6 7 8 注册密码校验 修改密码校验 重置密码校验 管理员创建初始密码 禁止历史密码复用 禁止包含用户名 / 邮箱 / 手机号 禁止常见弱口令 生成临时密码
1.4 Passay 不适合做什么? Passay 不是密码哈希工具。
它不负责:
1 2 3 4 5 6 7 密码加密 密码哈希 登录认证 权限控制 JWT 生成 OAuth2 流程 Session 管理
这些事情应该交给:
1 2 3 4 5 6 Spring Security BCryptPasswordEncoder Argon2PasswordEncoder PBKDF2 认证中心 / UAA OAuth2 / OIDC
Passay 负责的是:
密码进入数据库之前,先判断这个明文密码是否符合策略。
典型流程如下:
flowchart LR
A[用户输入新密码] --> B[Passay 校验规则]
B -->|不通过| C[返回密码策略错误]
B -->|通过| D[zxcvbn 评估强度]
D -->|分数不足| E[返回强度不足提示]
D -->|通过| F[Spring Security 哈希]
F --> G[保存密码哈希]
二、zxcvbn 是什么? zxcvbn 是 Dropbox 开源的密码强度评估算法。
它的名字来自键盘上一串常见字符:
这个名字本身就是一个常见弱密码模式,有点黑色幽默。
zxcvbn 的核心思想是:
不要只看密码有没有大小写、数字、符号,而是模拟攻击者如何猜密码。
它会识别各种常见模式:
1 2 3 4 5 6 7 8 9 常见弱密码 常见英文单词 人名 姓氏 日期 重复字符,例如 aaaaa 连续字符,例如 abcd、1234 键盘路径,例如 qwerty、zxcvbn l33t 替换,例如 p@ssw0rd
例如:
这个密码有大写、小写、数字、特殊字符,看起来很复杂。
但 zxcvbn 会发现:
1 2 3 P@ssw0rd ≈ password 123 是常见数字序列 ! 是常见追加符号
所以它不会给这个密码很高分。
2.1 zxcvbn 的输出结果 zxcvbn 通常会返回以下信息:
字段
说明
score
0 到 4 的强度分数
guesses
估计需要多少次猜测
guesses_log10
猜测次数的数量级
crack_times_seconds
不同攻击场景下的破解时间估计
crack_times_display
可读的破解时间描述
feedback.warning
风险提示
feedback.suggestions
改进建议
sequence
被识别出来的密码模式
calc_time
计算耗时
2.2 score 分数含义 zxcvbn4j 文档中给出的 score 大致含义如下:
score
含义
大致解释
0
Weak
很弱
1
Fair
较弱
2
Good
一般
3
Strong
较强
4
Very Strong
很强
在真实业务中,可以这样设置:
系统类型
建议门槛
普通 ToC 用户系统
score >= 3
企业后台管理系统
score >= 3
财务、权限、运维类系统
score >= 4
已开启 MFA 的普通系统
score >= 2 或 3,结合业务风险决定
不要一上来全部要求 score >= 4,否则用户会骂街,骂得还挺有道理。
密码策略的目标不是折磨用户,而是降低攻击成功率。
三、Passay 和 zxcvbn 的区别 3.1 核心区别
对比项
Passay
zxcvbn
定位
密码策略规则引擎
密码强度评估器
判断方式
基于规则
基于猜测难度和模式识别
典型问题
是否符合公司规则?
是否容易被攻击者猜到?
结果
通过 / 不通过 + 错误消息
score、破解时间、建议
适合位置
后端强制校验
前端提示 + 后端二次校验
是否可配置
非常强
可配置字典,但整体是评估模型
是否适合生成密码
适合
不适合
是否负责哈希
不负责
不负责
3.2 举个例子 假设密码是:
传统复杂度规则可能认为它通过:
1 2 3 4 5 长度 >= 8:通过 有大写:通过 有小写:通过 有数字:通过 有特殊字符:通过
Passay 如果只配置这些规则,也会认为它通过。
但 zxcvbn 会发现:
1 2 3 Password 是常见弱词 1 是常见追加数字 ! 是常见追加符号
所以它可能给出较低分数。
这就是为什么不要只依赖传统复杂度规则。
3.3 推荐组合方式 最推荐的方式是:
1 2 3 Passay:负责硬性规则 zxcvbn:负责真实强度估计 Spring Security:负责密码哈希存储
完整流程:
flowchart TB
A[前端输入密码] --> B[zxcvbn-ts 实时强度提示]
B --> C[提交注册 / 改密请求]
C --> D[后端 Passay 校验硬规则]
D -->|失败| E[返回规则错误]
D -->|成功| F[后端 zxcvbn4j 评估强度]
F -->|失败| G[返回强度不足]
F -->|成功| H[BCrypt / Argon2 哈希]
H --> I[保存密码哈希]
注意:
前端 zxcvbn 只是为了用户体验,后端必须重新校验。 永远不要相信前端。前端只是建议书,后端才是法院判决书。
四、工程实战:Spring Boot 集成 Passay + zxcvbn4j 下面开始实战。
4.1 项目目标 我们实现一个密码策略模块,支持:
1 2 3 4 5 6 注册前检查密码强度 修改密码时校验密码 返回明确的错误原因 返回 zxcvbn 强度分数 返回密码优化建议 校验通过后使用 Spring Security 哈希密码
4.2 Maven 依赖 这里使用:
Java 21
Spring Boot 3.x
Passay 2.0.0
zxcvbn4j 1.9.0
Spring Security Crypto
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.springframework.security</groupId > <artifactId > spring-security-crypto</artifactId > </dependency > <dependency > <groupId > org.passay</groupId > <artifactId > passay</artifactId > <version > 2.0.0</version > </dependency > <dependency > <groupId > com.nulab-inc</groupId > <artifactId > zxcvbn</artifactId > <version > 1.9.0</version > </dependency > </dependencies >
注意:Passay 2.0.0 相比 1.6.x 有 API 破坏性变化。网上很多旧文章使用 RuleResult、org.passay.CharacterRule 这类旧写法,复制时要看版本,别复制出一锅“能看不能跑”的陈年老汤。
4.3 推荐包结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 src/main/java/com/example/security ├── SecurityApplication.java ├── config │ ├── PasswordPolicyProperties.java │ └── PasswordEncoderConfig.java ├── password │ ├── PasswordPolicyService.java │ ├── PasswordPolicyResult.java │ ├── PasswordPolicyException.java │ └── PasswordRiskLevel.java ├── web │ ├── PasswordCheckController.java │ ├── PasswordCheckRequest.java │ └── PasswordCheckResponse.java └── user └── UserPasswordService.java
五、配置密码策略 5.1 application.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 app: password-policy: min-length: 12 max-length: 128 min-zxcvbn-score: 3 require-character-categories: false reject-username: true reject-sequence: true reject-whitespace: true sequence-length: 5 forbidden-words: - demo - test - admin - root - company - password
这里我建议默认配置:
1 2 3 4 5 6 7 最小长度:12 最大长度:128 zxcvbn score:至少 3 不强制大小写数字符号组合 禁止包含用户名 禁止明显连续序列 禁止弱词
如果是强合规后台,也可以改成:
1 2 最小长度:15 zxcvbn score:至少 4
5.2 配置类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 package com.example.security.config;import org.springframework.boot.context.properties.ConfigurationProperties;import java.util.ArrayList;import java.util.List;@ConfigurationProperties(prefix = "app.password-policy") public class PasswordPolicyProperties { private int minLength = 12 ; private int maxLength = 128 ; private int minZxcvbnScore = 3 ; private boolean requireCharacterCategories = false ; private boolean rejectUsername = true ; private boolean rejectSequence = true ; private boolean rejectWhitespace = true ; private int sequenceLength = 5 ; private List<String> forbiddenWords = new ArrayList <>(); public int getMinLength () { return minLength; } public void setMinLength (int minLength) { this .minLength = minLength; } public int getMaxLength () { return maxLength; } public void setMaxLength (int maxLength) { this .maxLength = maxLength; } public int getMinZxcvbnScore () { return minZxcvbnScore; } public void setMinZxcvbnScore (int minZxcvbnScore) { this .minZxcvbnScore = minZxcvbnScore; } public boolean isRequireCharacterCategories () { return requireCharacterCategories; } public void setRequireCharacterCategories (boolean requireCharacterCategories) { this .requireCharacterCategories = requireCharacterCategories; } public boolean isRejectUsername () { return rejectUsername; } public void setRejectUsername (boolean rejectUsername) { this .rejectUsername = rejectUsername; } public boolean isRejectSequence () { return rejectSequence; } public void setRejectSequence (boolean rejectSequence) { this .rejectSequence = rejectSequence; } public boolean isRejectWhitespace () { return rejectWhitespace; } public void setRejectWhitespace (boolean rejectWhitespace) { this .rejectWhitespace = rejectWhitespace; } public int getSequenceLength () { return sequenceLength; } public void setSequenceLength (int sequenceLength) { this .sequenceLength = sequenceLength; } public List<String> getForbiddenWords () { return forbiddenWords; } public void setForbiddenWords (List<String> forbiddenWords) { this .forbiddenWords = forbiddenWords; } }
启动类启用配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.security;import com.example.security.config.PasswordPolicyProperties;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.boot.context.properties.EnableConfigurationProperties;@EnableConfigurationProperties(PasswordPolicyProperties.class) @SpringBootApplication public class SecurityApplication { public static void main (String[] args) { SpringApplication.run(SecurityApplication.class, args); } }
六、返回结果模型 6.1 风险等级 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.security.password;public enum PasswordRiskLevel { VERY_WEAK, WEAK, NORMAL, STRONG, VERY_STRONG; public static PasswordRiskLevel fromScore (int score) { return switch (score) { case 0 -> VERY_WEAK; case 1 -> WEAK; case 2 -> NORMAL; case 3 -> STRONG; default -> VERY_STRONG; }; } }
6.2 密码策略结果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.security.password;import java.util.List;import java.util.Map;public record PasswordPolicyResult ( boolean valid, int score, PasswordRiskLevel riskLevel, Double guessesLog10, Map<String, String> crackTimesDisplay, List<String> messages, List<String> suggestions ) { }
6.3 密码策略异常 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.security.password;import java.util.List;public class PasswordPolicyException extends RuntimeException { private final List<String> messages; public PasswordPolicyException (List<String> messages) { super (String.join(";" , messages)); this .messages = messages; } public List<String> getMessages () { return messages; } }
七、核心实现:PasswordPolicyService 7.1 实现思路 这个服务做三件事:
使用 Passay 校验硬规则;
使用 zxcvbn4j 评估密码强度;
汇总错误消息和建议。
7.2 完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 package com.example.security.password;import com.example.security.config.PasswordPolicyProperties;import com.nulabinc.zxcvbn.Feedback;import com.nulabinc.zxcvbn.Strength;import com.nulabinc.zxcvbn.Zxcvbn;import org.passay.DefaultPasswordValidator;import org.passay.PasswordData;import org.passay.PasswordValidator;import org.passay.ValidationResult;import org.passay.data.EnglishCharacterData;import org.passay.data.EnglishSequenceData;import org.passay.rule.CharacterCharacteristicsRule;import org.passay.rule.CharacterRule;import org.passay.rule.IllegalRegexRule;import org.passay.rule.IllegalSequenceRule;import org.passay.rule.LengthRule;import org.passay.rule.Rule;import org.passay.rule.UsernameRule;import org.passay.rule.WhitespaceRule;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;import java.math.BigDecimal;import java.util.ArrayList;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;@Service public class PasswordPolicyService { private final PasswordPolicyProperties properties; private final Zxcvbn zxcvbn; public PasswordPolicyService (PasswordPolicyProperties properties) { this .properties = properties; this .zxcvbn = new Zxcvbn (); } public PasswordPolicyResult check (String username, String email, String rawPassword) { if (!StringUtils.hasText(rawPassword)) { return new PasswordPolicyResult ( false , 0 , PasswordRiskLevel.VERY_WEAK, 0.0D , Map.of(), List.of("密码不能为空" ), List.of("请输入一个较长、容易记忆但不容易猜测的密码" ) ); } List<String> messages = new ArrayList <>(); List<String> suggestions = new ArrayList <>(); ValidationResult passayResult = validateByPassay(username, rawPassword); if (!passayResult.isValid()) { messages.addAll(passayResult.getMessages()); } List<String> userInputs = buildUserInputs(username, email); Strength strength = zxcvbn.measure(rawPassword, userInputs); int score = strength.getScore(); PasswordRiskLevel riskLevel = PasswordRiskLevel.fromScore(score); if (score < properties.getMinZxcvbnScore()) { messages.add("密码强度不足,当前 score = " + score + ",最低要求 score >= " + properties.getMinZxcvbnScore()); } Feedback feedback = strength.getFeedback(); if (feedback != null ) { if (StringUtils.hasText(feedback.getWarning())) { suggestions.add(feedback.getWarning()); } if (feedback.getSuggestions() != null ) { suggestions.addAll(feedback.getSuggestions()); } } Map<String, String> crackTimesDisplay = new LinkedHashMap <>(); if (strength.getCrackTimesDisplay() != null ) { crackTimesDisplay.put("onlineThrottling100PerHour" , strength.getCrackTimesDisplay().getOnlineThrottling100PerHour()); crackTimesDisplay.put("onlineNoThrottling10PerSecond" , strength.getCrackTimesDisplay().getOnlineNoThrottling10PerSecond()); crackTimesDisplay.put("offlineSlowHashing1e4PerSecond" , strength.getCrackTimesDisplay().getOfflineSlowHashing1e4PerSecond()); crackTimesDisplay.put("offlineFastHashing1e10PerSecond" , strength.getCrackTimesDisplay().getOfflineFastHashing1e10PerSecond()); } Double guessesLog10 = null ; BigDecimal guessesLog10Value = strength.getGuessesLog10(); if (guessesLog10Value != null ) { guessesLog10 = guessesLog10Value.doubleValue(); } boolean valid = messages.isEmpty(); return new PasswordPolicyResult ( valid, score, riskLevel, guessesLog10, crackTimesDisplay, messages, suggestions ); } public void validateOrThrow (String username, String email, String rawPassword) { PasswordPolicyResult result = check(username, email, rawPassword); if (!result.valid()) { throw new PasswordPolicyException (result.messages()); } } private ValidationResult validateByPassay (String username, String rawPassword) { PasswordValidator validator = new DefaultPasswordValidator (buildRules()); PasswordData passwordData = StringUtils.hasText(username) ? new PasswordData (username, rawPassword) : new PasswordData (rawPassword); return validator.validate(passwordData); } private List<Rule> buildRules () { List<Rule> rules = new ArrayList <>(); rules.add(new LengthRule (properties.getMinLength(), properties.getMaxLength())); if (properties.isRejectWhitespace()) { rules.add(new WhitespaceRule ()); } if (properties.isRejectUsername()) { rules.add(new UsernameRule ()); } if (properties.isRejectSequence()) { int length = properties.getSequenceLength(); rules.add(new IllegalSequenceRule (EnglishSequenceData.Alphabetical, length, false )); rules.add(new IllegalSequenceRule (EnglishSequenceData.Numerical, length, false )); rules.add(new IllegalSequenceRule (EnglishSequenceData.USQwerty, length, false )); } if (properties.isRequireCharacterCategories()) { rules.add(new CharacterCharacteristicsRule ( 3 , new CharacterRule (EnglishCharacterData.UpperCase, 1 ), new CharacterRule (EnglishCharacterData.LowerCase, 1 ), new CharacterRule (EnglishCharacterData.Digit, 1 ), new CharacterRule (EnglishCharacterData.Special, 1 ) )); } for (String word : properties.getForbiddenWords()) { if (StringUtils.hasText(word)) { rules.add(new IllegalRegexRule ("(?i).*" + escapeRegex(word) + ".*" )); } } return rules; } private List<String> buildUserInputs (String username, String email) { List<String> inputs = new ArrayList <>(); if (StringUtils.hasText(username)) { inputs.add(username); } if (StringUtils.hasText(email)) { inputs.add(email); int atIndex = email.indexOf("@" ); if (atIndex > 0 ) { inputs.add(email.substring(0 , atIndex)); } } return inputs; } private String escapeRegex (String text) { return text.replace("\\" , "\\\\" ) .replace("." , "\\." ) .replace("*" , "\\*" ) .replace("+" , "\\+" ) .replace("?" , "\\?" ) .replace("^" , "\\^" ) .replace("$" , "\\$" ) .replace("{" , "\\{" ) .replace("}" , "\\}" ) .replace("(" , "\\(" ) .replace(")" , "\\)" ) .replace("|" , "\\|" ) .replace("[" , "\\[" ) .replace("]" , "\\]" ); } }
7.3 关于代码的几点说明 第一,Passay 负责硬规则,例如:
1 2 3 4 5 长度是否合法 是否包含用户名 是否包含空格 是否包含连续序列 是否包含禁用词
第二,zxcvbn 负责真实强度评估,例如:
1 2 3 4 5 是否像常见密码 是否像日期 是否像键盘路径 是否像单词变体 是否很容易猜
第三,userInputs 很重要。
例如用户叫 mario,邮箱是:
如果用户设置密码:
zxcvbn 默认不一定知道 mario 是这个用户的上下文信息。把 username、email 前缀传进去后,它会更容易识别这种“和用户信息相关”的弱密码。
八、提供密码检查接口 8.1 请求 DTO 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.security.web;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;public record PasswordCheckRequest ( String username, String email, @NotBlank(message = "密码不能为空") @Size(max = 256, message = "密码长度不能超过 256") String password ) { }
8.2 响应 DTO 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package com.example.security.web;import com.example.security.password.PasswordPolicyResult;import com.example.security.password.PasswordRiskLevel;import java.util.List;import java.util.Map;public record PasswordCheckResponse ( boolean valid, int score, PasswordRiskLevel riskLevel, Double guessesLog10, Map<String, String> crackTimesDisplay, List<String> messages, List<String> suggestions ) { public static PasswordCheckResponse from (PasswordPolicyResult result) { return new PasswordCheckResponse ( result.valid(), result.score(), result.riskLevel(), result.guessesLog10(), result.crackTimesDisplay(), result.messages(), result.suggestions() ); } }
8.3 Controller 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.security.web;import com.example.security.password.PasswordPolicyResult;import com.example.security.password.PasswordPolicyService;import jakarta.validation.Valid;import org.springframework.web.bind.annotation.*;@RestController @RequestMapping("/api/password") public class PasswordCheckController { private final PasswordPolicyService passwordPolicyService; public PasswordCheckController (PasswordPolicyService passwordPolicyService) { this .passwordPolicyService = passwordPolicyService; } @PostMapping("/check") public PasswordCheckResponse check (@Valid @RequestBody PasswordCheckRequest request) { PasswordPolicyResult result = passwordPolicyService.check( request.username(), request.email(), request.password() ); return PasswordCheckResponse.from(result); } }
8.4 请求示例 1 2 3 4 5 6 7 curl -X POST http://localhost:8080/api/password/check \ -H "Content-Type: application/json" \ -d '{ "username": "mario", "email": "mario@example.com", "password": "Password1!" }'
8.5 响应示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "valid" : false , "score" : 1 , "riskLevel" : "WEAK" , "guessesLog10" : 4.2 , "crackTimesDisplay" : { "onlineThrottling100PerHour" : "days" , "onlineNoThrottling10PerSecond" : "minutes" , "offlineSlowHashing1e4PerSecond" : "less than a second" , "offlineFastHashing1e10PerSecond" : "less than a second" } , "messages" : [ "密码强度不足,当前 score = 1,最低要求 score >= 3" ] , "suggestions" : [ "This is similar to a commonly used password." , "Add another word or two. Uncommon words are better." ] }
九、和注册 / 改密业务整合 9.1 PasswordEncoder 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.security.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Configuration public class PasswordEncoderConfig { @Bean public PasswordEncoder passwordEncoder () { return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); } }
如果你的系统仍在大量使用 BCrypt,也可以这样:
1 2 3 4 @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (12 ); }
推荐优先级大致是:
1 Argon2id / Argon2 > BCrypt > PBKDF2 > 不要自己手写 SHA-256
不要这样做:
1 String hash = DigestUtils.sha256Hex(password);
这类快哈希不适合直接存储密码。快,对业务是美德;对密码哈希是灾难。
9.2 注册业务示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package com.example.security.user;import com.example.security.password.PasswordPolicyService;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.stereotype.Service;@Service public class UserPasswordService { private final PasswordPolicyService passwordPolicyService; private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; public UserPasswordService ( PasswordPolicyService passwordPolicyService, PasswordEncoder passwordEncoder, UserRepository userRepository ) { this .passwordPolicyService = passwordPolicyService; this .passwordEncoder = passwordEncoder; this .userRepository = userRepository; } public void register (String username, String email, String rawPassword) { passwordPolicyService.validateOrThrow(username, email, rawPassword); String encodedPassword = passwordEncoder.encode(rawPassword); UserEntity user = new UserEntity (); user.setUsername(username); user.setEmail(email); user.setPasswordHash(encodedPassword); userRepository.save(user); } public void changePassword (Long userId, String username, String email, String newRawPassword) { passwordPolicyService.validateOrThrow(username, email, newRawPassword); String encodedPassword = passwordEncoder.encode(newRawPassword); userRepository.updatePassword(userId, encodedPassword); } }
这里要注意顺序:
不能先哈希再校验,因为哈希之后就看不出密码里有没有用户名、序列、弱词了。
十、全局异常处理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.security.web;import com.example.security.password.PasswordPolicyException;import org.springframework.http.HttpStatus;import org.springframework.web.bind.annotation.*;import java.util.List;@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(PasswordPolicyException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handlePasswordPolicyException (PasswordPolicyException ex) { return new ErrorResponse ("PASSWORD_POLICY_INVALID" , ex.getMessages()); } public record ErrorResponse ( String code, List<String> messages ) { } }
十一、前端实时提示:React + zxcvbn-ts 后端一定要最终校验,但前端可以用 zxcvbn 做实时提示,让用户输入时就知道密码强度。
这里推荐现代前端使用 zxcvbn-ts。
11.1 安装依赖 1 npm install @zxcvbn-ts/core @zxcvbn-ts/language-common @zxcvbn-ts/language-en
11.2 初始化 zxcvbn 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core' import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common' import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en' const options = { translations : zxcvbnEnPackage.translations , graphs : zxcvbnCommonPackage.adjacencyGraphs , dictionary : { ...zxcvbnCommonPackage.dictionary , ...zxcvbnEnPackage.dictionary , }, } zxcvbnOptions.setOptions (options)export function checkPasswordStrength (password : string , userInputs : string [] = [] ) { return zxcvbn (password, userInputs) }
11.3 React 组件示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import { useMemo, useState } from 'react' import { checkPasswordStrength } from './security/zxcvbn' export function PasswordInput ( ) { const [password, setPassword] = useState ('' ) const [username, setUsername] = useState ('' ) const result = useMemo (() => { if (!password) { return null } return checkPasswordStrength (password, [username]) }, [password, username]) const score = result?.score ?? 0 return ( <div > <label > 用户名: <input value ={username} onChange ={event => setUsername(event.target.value)} /> </label > <label > 密码: <input type ="password" value ={password} onChange ={event => setPassword(event.target.value)} /> </label > {password && ( <div > <div > 强度分数:{score} / 4</div > <progress value ={score} max ={4} /> {result?.feedback?.warning && ( <p > {result.feedback.warning}</p > )} {result?.feedback?.suggestions?.map((item, index) => ( <p key ={index} > {item}</p > ))} </div > )} </div > ) }
11.4 前端提示策略 建议前端这样提示:
score
提示
0
密码非常弱
1
密码较弱
2
密码一般,建议继续增强
3
密码较强
4
密码很强
但注意:
前端提示永远不能替代后端校验。
用户可以绕过前端,直接调用接口。后端不校验,等于门口贴了“禁止入内”,但门没锁。
十二、测试用例 12.1 单元测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 package com.example.security.password;import com.example.security.config.PasswordPolicyProperties;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import java.util.List;import static org.assertj.core.api.Assertions.assertThat;class PasswordPolicyServiceTest { private PasswordPolicyService passwordPolicyService; @BeforeEach void setUp () { PasswordPolicyProperties properties = new PasswordPolicyProperties (); properties.setMinLength(12 ); properties.setMaxLength(128 ); properties.setMinZxcvbnScore(3 ); properties.setRejectUsername(true ); properties.setRejectSequence(true ); properties.setRejectWhitespace(true ); properties.setSequenceLength(5 ); properties.setForbiddenWords(List.of("admin" , "password" , "company" )); passwordPolicyService = new PasswordPolicyService (properties); } @Test void shouldRejectBlankPassword () { PasswordPolicyResult result = passwordPolicyService.check("mario" , "mario@example.com" , "" ); assertThat(result.valid()).isFalse(); assertThat(result.messages()).isNotEmpty(); } @Test void shouldRejectCommonPassword () { PasswordPolicyResult result = passwordPolicyService.check("mario" , "mario@example.com" , "Password1!" ); assertThat(result.valid()).isFalse(); } @Test void shouldRejectPasswordContainsUsername () { PasswordPolicyResult result = passwordPolicyService.check("mario" , "mario@example.com" , "mario-very-long-2026" ); assertThat(result.valid()).isFalse(); } @Test void shouldRejectSequencePassword () { PasswordPolicyResult result = passwordPolicyService.check("mario" , "mario@example.com" , "abcdef123456XYZ!" ); assertThat(result.valid()).isFalse(); } @Test void shouldAcceptStrongPassword () { PasswordPolicyResult result = passwordPolicyService.check( "mario" , "mario@example.com" , "river-candle-orbit-forest-79" ); assertThat(result.valid()).isTrue(); assertThat(result.score()).isGreaterThanOrEqualTo(3 ); } }
12.2 建议准备一组密码样本
密码
预期
123456
拒绝
password
拒绝
Password1!
拒绝
Admin@123456
拒绝
qwertyuiop123
拒绝
mario2026!@#
如果用户名是 mario,应拒绝
river-candle-orbit-forest-79
通常可接受
correct horse battery staple
视策略而定,通常强度较好
Tide-Garden-Moon-Vector-2026
通常可接受
这种测试很有必要。
密码策略一旦写错,就可能出现两种事故:
两边都不好,前者安全事故,后者产品事故。
十三、密码生成:使用 Passay 生成临时密码 Passay 也可以生成符合规则的密码,适合这些场景:
1 2 3 管理员创建用户时生成初始密码 重置密码生成临时密码 系统内部生成一次性凭证
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.security.password;import org.passay.data.EnglishCharacterData;import org.passay.generate.PasswordGenerator;import org.passay.rule.CharacterRule;import java.util.List;public class TemporaryPasswordGenerator { private final PasswordGenerator generator = new PasswordGenerator (); public String generate () { List<CharacterRule> rules = List.of( new CharacterRule (EnglishCharacterData.UpperCase, 1 ), new CharacterRule (EnglishCharacterData.LowerCase, 1 ), new CharacterRule (EnglishCharacterData.Digit, 1 ), new CharacterRule (EnglishCharacterData.Special, 1 ) ); return generator.generatePassword(16 , rules).toString(); } }
生成临时密码时要注意:
临时密码必须强制首次登录修改;
临时密码不要明文长期存储;
临时密码不要通过不安全渠道发送;
如果通过短信或邮件发送,要设置短有效期;
最好配合 MFA 或一次性重置链接。
十四、生产落地建议 14.1 不要只追求“复杂度” 旧式规则喜欢这样要求:
1 2 3 4 5 至少 8 位 必须大写 必须小写 必须数字 必须特殊字符
问题是用户会模式化:
1 2 3 Password1! Company2026! Admin@123
现代策略更应该:
1 2 3 4 5 6 7 8 提高长度 允许 passphrase 禁止弱口令 禁止上下文词 提供强度提示 使用安全哈希 开启登录限流 鼓励 MFA
14.2 不要禁止用户使用空格,除非你有明确原因 很多系统禁止空格,其实会伤害 passphrase:
1 correct horse battery staple
这类密码短语对用户更容易记,也可能比 P@ssw0rd1! 更安全。
但如果你的系统、移动端输入、历史逻辑、第三方系统对空格支持不好,可以暂时禁止。否则建议允许空格。
14.3 不要对登录失败暴露过多细节 注册、改密时可以告诉用户:
但登录失败时不要提示:
登录场景建议统一返回:
安全提示不是越详细越好。场景错了,细节就是弹药。
14.4 不要记录明文密码日志 严禁:
1 log.info("register username={}, password={}" , username, password);
也不要把请求体完整打印出来。
建议对敏感字段做脱敏:
1 2 3 4 password: *** newPassword: *** oldPassword: *** confirmPassword: ***
14.5 后端必须最终校验 前端 zxcvbn 只能做体验优化。
后端必须做:
1 2 3 4 5 Passay 规则校验 zxcvbn4j 强度评估 密码哈希 密码历史校验 弱口令名单校验
14.6 密码哈希不要自己造轮子 推荐:
1 2 3 4 Argon2id bcrypt PBKDF2 scrypt
不推荐:
1 2 3 4 5 6 MD5 SHA-1 SHA-256 直接 hash 加盐 SHA-256 自己拼 Base64 AES 可逆加密
密码存储要使用慢哈希,不是普通摘要。
14.7 可以接入泄露密码检查 更强一点的系统可以接入:
1 2 3 4 Have I Been Pwned Pwned Passwords 内部弱口令库 历史泄露密码库 企业自定义黑名单
但要注意隐私,不要直接把明文密码发给第三方服务。
比较常见的方式是 k-anonymity 查询,即只提交哈希前缀。
14.8 不建议强制定期改密 如果没有泄露证据,不建议强制用户每 30 天、60 天、90 天改一次密码。
这种策略经常导致用户:
1 2 3 Password202401! Password202402! Password202403!
看起来在改,实际只是攻击者更容易猜了。
更合理的是:
1 2 3 4 发现泄露时强制改密 管理员重置后强制改密 异常登录后要求改密 安全事件后批量改密
14.9 密码策略要按系统风险分级 不要所有系统一个策略。
系统
建议
普通内容系统
长度 >= 10,score >= 2 或 3
企业后台
长度 >= 12,score >= 3
财务 / 权限 / 运维系统
长度 >= 15,score >= 4,配合 MFA
超高安全系统
密码 + MFA + 设备绑定 + 风险控制
密码策略不是越严越高级。策略要服务于风险,不是服务于仪式感。
十五、推荐密码策略模板 如果你要在公司项目里落地,可以直接参考这套:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 1. 密码长度至少 12 位,最高 128 位; 2. 管理端、财务端、运维端至少 15 位; 3. 不强制大小写数字符号组合,但推荐使用更长的密码短语; 4. 禁止使用常见弱口令; 5. 禁止包含用户名、邮箱前缀、手机号、公司名、系统名; 6. 禁止明显连续序列,例如 abcde、12345、qwert; 7. 使用 zxcvbn 评分,普通系统 score >= 3,高风险系统 score >= 4; 8. 后端必须重复校验; 9. 密码使用 Argon2 或 BCrypt 存储; 10. 登录接口必须限流; 11. 不记录明文密码日志; 12. 不无理由强制定期改密; 13. 发现泄露或异常时强制改密; 14. 高风险系统必须启用 MFA。
十六、完整实践流程 最终工程流程如下:
sequenceDiagram
participant U as 用户
participant FE as 前端
participant API as 后端接口
participant PP as PasswordPolicyService
participant DB as 数据库
U->>FE: 输入密码
FE->>FE: zxcvbn-ts 实时评估
FE-->>U: 展示强度条与建议
U->>API: 提交注册 / 改密
API->>PP: 校验密码
PP->>PP: Passay 校验硬规则
PP->>PP: zxcvbn4j 评估强度
alt 密码不符合策略
PP-->>API: 返回错误原因
API-->>FE: 400 + messages
FE-->>U: 展示修改建议
else 密码符合策略
PP-->>API: 通过
API->>API: Argon2 / BCrypt 哈希
API->>DB: 保存密码哈希
API-->>FE: 成功
end
十七、常见坑 17.1 只在前端做密码强度校验 这是典型安全漏洞。
用户可以直接调用接口绕过前端。
解决方案:
17.2 只用 Passay 字符规则 例如只检查:
会放过:
1 2 3 Password1! Qwer1234! Admin@123
解决方案:
17.3 错把 zxcvbn 当成密码加密工具 zxcvbn 只评估强度,不加密、不存储、不认证。
解决方案:
1 2 3 zxcvbn 评估 Spring Security 哈希 认证框架登录
17.4 密码错误提示过于技术化 不要直接返回:
1 2 INSUFFICIENT_SPECIAL ILLEGAL_QWERTY_SEQUENCE
要转成用户能看懂的话:
1 密码包含连续键盘字符,请避免使用 qwerty、asdfg 这类模式。
17.5 强度门槛过高 如果普通用户注册也要求:
1 2 3 4 长度 20 位 score >= 4 必须大小写数字特殊字符 必须不能有任何单词
那注册转化率可能会原地去世。
安全要做,但别把用户当敌人。
十八、Passay 1.6.x 与 2.0.0 的注意事项 很多旧文章使用的是 Passay 1.x,代码大概是:
1 2 3 4 5 6 PasswordValidator validator = new PasswordValidator (List.of( new LengthRule (8 , 16 ), new CharacterRule (EnglishCharacterData.UpperCase, 1 ) ));RuleResult result = validator.validate(new PasswordData (password));
但 Passay 2.0.0 做了较大的 API 调整:
1 2 3 4 5 PasswordValidator 变成接口 DefaultPasswordValidator 是主要实现 返回结果变成 ValidationResult Rule 类移动到 org.passay.rule 包 CharacterData / SequenceData 移动到 org.passay.data 包
所以如果你使用的是 2.0.0,应该优先参考本文这种写法:
1 2 PasswordValidator validator = new DefaultPasswordValidator (rules);ValidationResult result = validator.validate(new PasswordData (username, rawPassword));
如果你公司项目暂时不想升级,也可以使用 1.6.6,但新项目建议直接评估 2.0.0。
十九、结论 Passay 和 zxcvbn 不是二选一,而是互补关系。
一句话总结:
Passay 负责“合不合规”,zxcvbn 负责“强不强”,Spring Security 负责“怎么安全存”。
在真实 Java 项目里,推荐方案是:
1 2 3 4 前端:zxcvbn-ts 实时提示 后端:Passay + zxcvbn4j 最终校验 存储:Argon2 / BCrypt 风控:登录限流 + MFA + 泄露密码检测
不要再迷信 Password1! 这种“复杂但弱”的密码了。
密码安全的关键不是让用户痛苦,而是让攻击者痛苦。
这才是好策略。
参考资料
启示录 安全不是让用户记住更痛苦的密码,而是让攻击者付出更昂贵的代价。
密码策略的最高境界,不是复杂,而是难猜、易用、可落地。