概述
在企业级安全开发中,密码策略往往非常复杂:禁止连续字母、连续数字、键盘序列、必须包含大小写与特殊字符,同时密码和确认密码必须一致。
如果直接在业务逻辑中硬编码这些规则,代码会迅速腐化。本文将介绍如何基于 Passay 密码策略库与 Jakarta Bean Validation 自定义注解,将密码验证逻辑彻底从业务代码中剥离,并实现类级别的复合属性校验。
纲要
Passay框架简介与依赖引入- 单个属性校验:
@PasswordConstraint注解与验证器- 使用
PasswordValidator组装多重规则 - 规则:长度、大写、小写、特殊字符、禁止连续序列、禁止键盘连续序列
- 使用
- 多属性复合校验:
@PasswordMatch类级别注解- 校验
password与confirmPassword一致性
- 校验
- DTO 层应用示例
- 自定义验证流程可视化
Passay:专业密码策略库
Passay 提供了丰富的预置规则(Rule),并允许通过 PasswordValidator 灵活组装。相比于手写正则,它更易读、易维护,且对连续序列、键盘序列等复杂规则提供了开箱即用的支持。
引入依赖
在 pom.xml 中添加 passay 和 spring-boot-starter-validation(后者提供验证框架支持):
xml
<properties>
<passay.version>1.6.0</passay.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.passay</groupId>
<artifactId>passay</artifactId>
<version>${passay.version}</version>
</dependency>
</dependencies>
自定义密码验证注解 @PasswordConstraint
为了让密码规则注解化,我们首先定义一个作用于字段的约束注解。
java
package com.example.validation.annotation;
import com.example.validation.validator.PasswordConstraintValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = PasswordConstraintValidator.class)
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordConstraint {
String message() default "密码不符合安全规则";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
验证器实现:引入 Passay 规则
验证器内部使用 PasswordValidator 组合规则,包括:
- 长度:8~30 位
- 字符规则:至少一个大写字母、一个小写字母、一个特殊字符
- 非连续序列:不允许 5 个连续字母或数字
- 键盘序列:不允许 QWERTY 键盘上的 5 个连续字符(如
qwert)
java
package com.example.validation.validator;
import com.example.validation.annotation.PasswordConstraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.passay.*;
import java.util.Arrays;
public class PasswordConstraintValidator implements ConstraintValidator<PasswordConstraint, String> {
private final PasswordValidator passwordValidator;
public PasswordConstraintValidator() {
this.passwordValidator = new PasswordValidator(Arrays.asList(
// 长度规则 8-30
new LengthRule(8, 30),
// 至少一个大写字母
new CharacterRule(EnglishCharacterData.UpperCase, 1),
// 至少一个小写字母
new CharacterRule(EnglishCharacterData.LowerCase, 1),
// 至少一个特殊字符
new CharacterRule(EnglishCharacterData.Special, 1),
// 不允许5个连续字母
new IllegalSequenceRule(EnglishSequenceData.Alphabetical, 5, false),
// 不允许5个连续数字
new IllegalSequenceRule(EnglishSequenceData.Numerical, 5, false),
// 不允许QWERTY键盘序列5个连续
new IllegalSequenceRule(EnglishSequenceData.USQwerty, 5, false),
// 不允许包含空白字符
new WhitespaceRule()
));
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null) {
return false;
}
RuleResult result = passwordValidator.validate(new PasswordData(password));
return result.isValid();
}
}
类级别复合校验 @PasswordMatch
密码与确认密码必须相等,这属于类中两个属性的复合校验。我们定义类级别的注解 @PasswordMatch。
java
package com.example.validation.annotation;
import com.example.validation.validator.PasswordMatchValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {
String message() default "密码与确认密码不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
对应的验证器通过反射获取 password 和 confirmPassword 字段值进行比较:
java
package com.example.validation.validator;
import com.example.validation.annotation.PasswordMatch;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.BeanWrapperImpl;
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
Object password = new BeanWrapperImpl(value).getPropertyValue("password");
Object confirmPassword = new BeanWrapperImpl(value).getPropertyValue("confirmPassword");
if (password == null) {
return false;
}
return password.equals(confirmPassword);
}
}
DTO 应用与验证流程
将两个注解应用于 UserDto,其中 @PasswordMatch 作用于类上,@PasswordConstraint 作用于密码字段。
java
package com.example.dto;
import com.example.validation.annotation.PasswordConstraint;
import com.example.validation.annotation.PasswordMatch;
import jakarta.validation.constraints.Email;
import lombok.Data;
@Data
@PasswordMatch
public class UserDto {
@Email(message = "邮箱格式不正确")
private String email;
@PasswordConstraint
private String password;
private String confirmPassword;
}
当请求进入 Controller 并使用 @Valid 触发校验时,执行流程如下:
Passay Validator Controller Client Passay Validator Controller Client #mermaid-svg-GRNoe8OJH5KIsYRO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GRNoe8OJH5KIsYRO .error-icon{fill:#552222;}#mermaid-svg-GRNoe8OJH5KIsYRO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GRNoe8OJH5KIsYRO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GRNoe8OJH5KIsYRO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GRNoe8OJH5KIsYRO .marker.cross{stroke:#333333;}#mermaid-svg-GRNoe8OJH5KIsYRO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GRNoe8OJH5KIsYRO p{margin:0;}#mermaid-svg-GRNoe8OJH5KIsYRO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GRNoe8OJH5KIsYRO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GRNoe8OJH5KIsYRO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GRNoe8OJH5KIsYRO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GRNoe8OJH5KIsYRO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GRNoe8OJH5KIsYRO .sequenceNumber{fill:white;}#mermaid-svg-GRNoe8OJH5KIsYRO #sequencenumber{fill:#333;}#mermaid-svg-GRNoe8OJH5KIsYRO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GRNoe8OJH5KIsYRO .messageText{fill:#333;stroke:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GRNoe8OJH5KIsYRO .labelText,#mermaid-svg-GRNoe8OJH5KIsYRO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .loopText,#mermaid-svg-GRNoe8OJH5KIsYRO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GRNoe8OJH5KIsYRO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GRNoe8OJH5KIsYRO .noteText,#mermaid-svg-GRNoe8OJH5KIsYRO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GRNoe8OJH5KIsYRO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GRNoe8OJH5KIsYRO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GRNoe8OJH5KIsYRO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GRNoe8OJH5KIsYRO .actorPopupMenu{position:absolute;}#mermaid-svg-GRNoe8OJH5KIsYRO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GRNoe8OJH5KIsYRO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GRNoe8OJH5KIsYRO .actor-man circle,#mermaid-svg-GRNoe8OJH5KIsYRO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GRNoe8OJH5KIsYRO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 委托 PasswordConstraintValidator 比较 password 与 confirmPassword alt所有约束通过存在违规 POST /register (UserDto)validate(UserDto)校验 @Email校验 @PasswordConstraintPasswordValidator.validate(password)RuleResult校验 @PasswordMatch (类级别)无违规注册成功ConstraintViolation 集合400 错误详情
项目结构概览
dir
src/main/java/com/example/
├── dto
│ └── UserDto.java
├── validation
│ ├── annotation
│ │ ├── PasswordConstraint.java
│ │ └── PasswordMatch.java
│ └── validator
│ ├── PasswordConstraintValidator.java
│ └── PasswordMatchValidator.java
总结
通过 Passay 与自定义注解的结合,我们将复杂的密码策略封装为声明式校验,完全解耦业务代码。同时演示了类级别复合验证如何优雅地处理 "密码与确认密码" 的场景。
这套模式可扩展至任何需要多属性组合校验的 DTO,有效提高代码可读性与安全性。