Passay 与 zxcvbn:Java 项目密码策略与密码强度检测实战

欢迎你来读这篇博客,这篇博客主要是关于 Passayzxcvbn 的。

这两个工具都和“密码安全”有关,但它们解决的问题不一样:

  • 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

这些密码满足“复杂度规则”,但对攻击者来说并不难猜。

这就是传统复杂度规则的问题:它检查的是字符种类,不是密码是否真的难猜。

现代密码策略更应该关注:

  1. 密码是否足够长;
  2. 是否命中常见弱口令;
  3. 是否包含用户名、邮箱、系统名、公司名等上下文信息;
  4. 是否存在明显模式,比如 123456qwertyaaaaaa、日期、重复片段;
  5. 用户体验是否可接受;
  6. 后端是否使用安全的哈希算法存储密码。

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 禁止连续序列,例如 abcde12345qwert
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 开源的密码强度评估算法。

它的名字来自键盘上一串常见字符:

1
zxcvbn

这个名字本身就是一个常见弱密码模式,有点黑色幽默。

zxcvbn 的核心思想是:

不要只看密码有没有大小写、数字、符号,而是模拟攻击者如何猜密码。

它会识别各种常见模式:

1
2
3
4
5
6
7
8
9
常见弱密码
常见英文单词
人名
姓氏
日期
重复字符,例如 aaaaa
连续字符,例如 abcd、1234
键盘路径,例如 qwerty、zxcvbn
l33t 替换,例如 p@ssw0rd

例如:

1
P@ssw0rd123!

这个密码有大写、小写、数字、特殊字符,看起来很复杂。

但 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
Password1!

传统复杂度规则可能认为它通过:

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>
<!-- Web API -->
<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>

<!-- Spring Security 密码哈希能力 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

<!-- Passay:密码策略规则校验 -->
<dependency>
<groupId>org.passay</groupId>
<artifactId>passay</artifactId>
<version>2.0.0</version>
</dependency>

<!-- zxcvbn4j:Java 版 zxcvbn 密码强度评估 -->
<dependency>
<groupId>com.nulab-inc</groupId>
<artifactId>zxcvbn</artifactId>
<version>1.9.0</version>
</dependency>
</dependencies>

注意:Passay 2.0.0 相比 1.6.x 有 API 破坏性变化。网上很多旧文章使用 RuleResultorg.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 实现思路

这个服务做三件事:

  1. 使用 Passay 校验硬规则;
  2. 使用 zxcvbn4j 评估密码强度;
  3. 汇总错误消息和建议。

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,邮箱是:

1
mario@example.com

如果用户设置密码:

1
Mario@2026

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
先校验明文密码
再哈希密码
再保存

不能先哈希再校验,因为哈希之后就看不出密码里有没有用户名、序列、弱词了。

十、全局异常处理

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
// src/security/zxcvbn.ts
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 通常可接受

这种测试很有必要。

密码策略一旦写错,就可能出现两种事故:

1
2
太松:弱密码进库
太严:用户骂你

两边都不好,前者安全事故,后者产品事故。

十三、密码生成:使用 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();
}
}

生成临时密码时要注意:

  1. 临时密码必须强制首次登录修改;
  2. 临时密码不要明文长期存储;
  3. 临时密码不要通过不安全渠道发送;
  4. 如果通过短信或邮件发送,要设置短有效期;
  5. 最好配合 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 不要对登录失败暴露过多细节

注册、改密时可以告诉用户:

1
2
3
密码包含用户名
密码过于常见
密码强度不足

但登录失败时不要提示:

1
2
3
密码太弱
密码过期
账号存在但密码错误

登录场景建议统一返回:

1
账号或密码错误

安全提示不是越详细越好。场景错了,细节就是弹药。

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 只在前端做密码强度校验

这是典型安全漏洞。

用户可以直接调用接口绕过前端。

解决方案:

1
2
前端做体验
后端做裁判

17.2 只用 Passay 字符规则

例如只检查:

1
2
3
4
大写
小写
数字
特殊字符

会放过:

1
2
3
Password1!
Qwer1234!
Admin@123

解决方案:

1
Passay + zxcvbn 双重判断

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! 这种“复杂但弱”的密码了。

密码安全的关键不是让用户痛苦,而是让攻击者痛苦。

这才是好策略。

参考资料

启示录

安全不是让用户记住更痛苦的密码,而是让攻击者付出更昂贵的代价。

密码策略的最高境界,不是复杂,而是难猜、易用、可落地。


Passay 与 zxcvbn:Java 项目密码策略与密码强度检测实战
https://allendericdalexander.github.io/2026/06/12/java/sec/passay-zxcvbn-password-policy-blog/
作者
AtLuoFu
发布于
2026年6月12日
许可协议