learn-tech/专栏/周志明的架构课/30_验证:系统如何确保提交给服务的数据是安全的?.md
2024-10-16 06:37:41 +08:00

203 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
30 _ 验证:系统如何确保提交给服务的数据是安全的?
你好,我是周志明。今天是安全架构这个小章节的最后一讲,我们来讨论下“验证”这个话题,一起来看看,关于“系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险”这个问题的具体解决方案。
数据验证也很重要
数据验证与程序如何编码是密切相关的,你在做开发的时候可能都不会把它归入安全的范畴之中。但你细想一下,如果说关注“你是谁”(认证)、“你能做什么”(授权)等问题是很合理的安全,那么关注“你做的对不对”(验证)不也同样合理吗?
首先,从数量上来讲,因为数据验证不严谨而导致的安全问题,要比其他安全攻击所导致的问题多得多;其次,从风险上来讲,由于数据质量而导致的安全问题,要承受的风险可能有高有低,可当我们真的遇到了高风险的数据问题,面临的损失不一定就比被黑客拖库来得小。
当然不可否认的是,相比其他富有挑战性的安全措施,比如说,防御与攻击之间精彩的缠斗需要人们综合运用数学、心理、社会工程和计算机等跨学科知识,数据验证这项常规工作确实有点儿无聊。在日常的开发工作当中,它会贯穿于代码的各个层次,我们每个人肯定都写过。
但是,这种常见的代码反而是迫切需要被架构约束的。
这里我们要先明确一个要点:缺失的校验会影响数据质量,而过度的校验也不会让系统更加健壮,反而在某种意义上会制造垃圾代码,甚至还会有副作用。
我们来看看下面这个实际的段子:
前 端: 提交一份用户数据(姓名:某, 性别:男, 爱好:女, 签名:xxx, 手机:xxx, 邮箱:null
控制器: 发现邮箱是空的抛ValidationException("邮箱没填")
前 端: 已修改,重新提交
安 全: 发送验证码时发现手机号少一位抛RemoteInvokeException("无法发送验证码")
前 端: 已修改,重新提交
服务层: 邮箱怎么有重复啊抛BusinessRuntimeException("不允许开小号")
前 端: 已修改,重新提交
持久层: 签名字段超长了插不进去抛SQLException("插入数据库失败SQLxxx")
…… ……
前 端: 你们这些坑管挖不管埋的后端,各种异常都往前抛!
用 户: 这系统牙膏厂生产的?
你应该也知道,最基础的数据问题可以在前端做表单校验来处理,但服务端验证肯定也是要做的。那么在看完了前面这个段子以后,你可以想一想,服务端应该在哪一层去做校验呢?我想你可能会得出这样的答案:
在Controller层做在Service层不做。理由是从Service开始会有同级重用当出现ServiceA.foo(params)调用ServiceB.bar(params)的时候就会对params重复校验两次。
在Service层做在Controller层不做。理由是无业务含义的格式校验已经在前端表单验证处理过了而有业务含义的校验不应该放在Controller中毕竟页面控制器的职责是管理页面流不该承载业务。
在Controller、Service层各做各的。Controller做格式校验Service层做业务校验听起来很合理但这其实就是前面段子中被嘲笑的行为。
还有其他一些意见,比如说在持久层做校验,理由是这是最终入口,把守好写入数据库的质量最重要。
这样的讨论大概是不会有一个统一、正确的结论的但是在Java里确实是有验证的标准做法即Java Bean Validation。这也是我比较提倡的做法那就是把校验行为从分层中剥离出来不是在哪一层做而是在Bean上做。
Java Bean Validation
从2009年JSR 303的1.0到2013年JSR 349更新的1.1到目前最新的2017年发布的JSR 380Java定义了Bean验证的全套规范。这种单独将验证提取、封装的做法可以让我们获得不少好处
对于无业务含义的格式验证,可以做到预置。
对于有业务含义的业务验证可以做到重用。一个Bean作为参数或返回值被用于多个方法的情况是很常见的因此针对Bean做校验就比针对方法做校验更有价值这样利于我们集中管理比如统一认证的异常体系、统一做国际化、统一给客户端的返回格式等等。
避免对输入数据的防御污染到业务代码。如果你的代码里面有很多像是下面这样的条件判断,就应该考虑重构了:
// 一些已执行的逻辑
if (someParam == null) {
throw new RuntimeExcetpion("客官不可以!")
}
利于多个校验器统一执行,统一返回校验结果,避免用户踩地雷、挤牙膏式的试错体验。
据我所知国内的项目使用Bean Validation的并不少见但大多数程序员都只使用到它的Built-In Constraint来做一些与业务逻辑无关的通用校验也就是下面展示的这堆注解看类名我们基本上就能明白它们的含义了
@Null@NotNull@AssertTrue@AssertFalse@Min@Max@DecimalMin@DecimalMax@Negative@NegativeOrZero@Positive@PositiveOrZeor@Szie@Digits@Pass@PassOrPresent@Future@FutureOrPresent@Pattern@NotEmpty@NotBlank@Email
不过我们要知道与业务相关的校验往往才是最复杂的校验。而把简单的校验交给Bean Validation把复杂的校验留给自己这简直是买椟还珠故事的程序员版本。其实以Bean Validation的标准方式来做业务校验是非常优雅的。
接下来我就用Fenixs Bookstore项目在用户资源上的两个方法“创建新用户”和“更新用户信息”来给你举个例子
/**
* 创建新的用户
*/
@POST
public Response createUser(@Valid @UniqueAccount Account user) {
return CommonResponse.op(() -> service.createAccount(user));
}
/**
* 更新用户信息
*/
@PUT
@CacheEvict(key = "#user.username")
public Response updateUser(@Valid @AuthenticatedAccount @NotConflictAccount Account user) {
return CommonResponse.op(() -> service.updateAccount(user));
}
这里你要注意其中的三个自定义校验注解,它们的含义分别是:
@UniqueAccount:传入的用户对象必须是唯一的,不与数据库中任何已有用户的名称、手机、邮箱产生重复。
@AuthenticatedAccount:传入的用户对象必须与当前登录的用户一致。
@NotConflictAccount:传入的用户对象中的信息与其他用户是无冲突的,比如将一个注册用户的邮箱,修改成与另外一个已存在的注册用户一致的值,这便是冲突。
这里的需求我们其实很容易就能想明白:当注册新用户时,要约束其不与任何已有用户的关键信息重复;而当用户修改自己的信息时,只能与自己的信息重复,而且只能修改当前登录用户的信息。
这些约束规则不仅仅是为这两个方法服务它们还可能会在用户资源中的其他入口被使用到乃至在其他分层的代码中被使用到。而在Bean上做校验我们就能一揽子地覆盖前面提到的这些使用场景。
现在我们来看前面代码中用到的三个自定义注解对应校验器的实现类:
public static class AuthenticatedAccountValidator extends AccountValidation<AuthenticatedAccount> {
public void initialize(AuthenticatedAccount constraintAnnotation) {
predicate = c -> {
AuthenticAccount loginUser = (AuthenticAccount) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return c.getId().equals(loginUser.getId());
};
}
}
public static class UniqueAccountValidator extends AccountValidation<UniqueAccount> {
public void initialize(UniqueAccount constraintAnnotation) {
predicate = c -> !repository.existsByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(), c.getTelephone());
}
}
public static class NotConflictAccountValidator extends AccountValidation<NotConflictAccount> {
public void initialize(NotConflictAccount constraintAnnotation) {
predicate = c -> {
Collection<Account> collection = repository.findByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(), c.getTelephone());
// 将用户名、邮件、电话改成与现有完全不重复的,或者只与自己重复的,就不算冲突
return collection.isEmpty() || (collection.size() == 1 && collection.iterator().next().getId().equals(c.getId()));
};
}
}
这样,业务校验就可以和业务逻辑完全分离开,在需要校验时,你可以用@Valid注解自动触发,或者通过代码手动触发执行。你可以根据自己项目的要求,把这些注解应用于控制器、服务层、持久层等任何层次的代码之中。
而且采用Bean Validation也便于我们统一处理校验结果不满足时的提示信息。比如提供默认值、提供国际化支持这里没做、提供统一的客户端返回格式创建一个用于ConstraintViolationException的异常处理器来实现以及批量执行全部校验避免出现开篇那个段子中挤牙膏的尴尬情况。
除此之外对于Bean与Bean校验器我还想给你两条关于编码的建议。
第一条建议是,要对校验项预置好默认的提示信息,这样当校验不通过时,用户能获得明确的修正提示。这里你可以参考下面的代码示例:
/**
* 表示一个用户的信息是无冲突的
*
* “无冲突”是指该用户的敏感信息与其他用户不重合,比如将一个注册用户的邮箱,修改成与另外一个已存在的注册用户一致的值,这便是冲突
**/
@Documented
@Retention(RUNTIME)
@Target({FIELD, METHOD, PARAMETER, TYPE})
@Constraint(validatedBy = AccountValidation.NotConflictAccountValidator.class)
public @interface NotConflictAccount {
String message() default "用户名称、邮箱、手机号码与现存用户产生重复";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
第二条建议是要把不带业务含义的格式校验注解放到Bean的类定义之上把带业务逻辑的校验放到Bean的类定义的外面。
这两者的区别是,放在类定义中的注解能够自动运行,而放到类外面的业务校验需要像前面的示例代码那样,明确标出注解时才会运行。比如用户账号实体中的部分代码为:
public class Account extends BaseEntity {
@NotEmpty(message = "用户不允许为空")
private String username;
@NotEmpty(message = "用户姓名不允许为空")
private String name;
private String avatar;
@Pattern(regexp = "1\\d{10}", message = "手机号格式不正确")
private String telephone;
@Email(message = "邮箱格式不正确")
private String email
}
你可以发现这些校验注解都直接放在了类定义中每次执行校验的时候它们都会被运行。因为Bean Validation是Java的标准规范它执行的频率可能比编写代码的程序所预想的更高比如使用Hibernate来做持久化时便会自动执行Data Object上的校验注解。
对于那些不带业务含义的注解,在运行时是不需要其他外部资源的参与的,它们不会调用远程服务、访问数据库,这种校验重复执行实际上并没有什么成本。
但带业务逻辑的校验,通常就需要外部资源参与执行,这不仅仅是多消耗一点时间和运算资源的问题,因为我们很难保证依赖的每个服务都是幂等的,重复执行校验很可能会带来额外的副作用。因此应该放到外面,让使用者自行判断是否要触发。
另外还有一些“需要触发一部分校验”的非典型情况比如“新增”操作A需要执行全部校验规则“修改”操作B中希望不校验某个字段“删除”操作C中希望改变某一条校验规则这个时候我们就要启用分组校验来处理设计一套“新增”“修改”“删除”这样的标识类置入到校验注解的groups参数中去实现。
小结
这节课算是JSR 380 Bean Validation的小科普Bean验证器在Java中存在的时间已经超过了十年应用范围也非常广泛但现在还是有很多的信息系统选择自己制造轮子去解决数据验证的问题而且做的也并没有Bean验证器好。
所以在这节课里我给你总结了正确使用Bean验证器的一些最佳实践涉及到不少具体的代码建议你好好结合着代码进行学习和实践。在课程后面的实战模块中我还会给你具体展示Fenixs Bookstore的工程代码到时你也可以结合着该模块一起学习印证或增强实战学习的效果。
一课一思
你开发的系统是依靠Bean验证器完成数据验证的吗如果不是那么你的系统、或者是你知道的系统是如何做校验的呢你认为效果如何
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
好,感谢你的阅读,我们下一讲再见。