文章目录
- 前言
- 一、数据
- 1.1、mybatis-plus 统一字段管理
- 1.2、Map 映射如何实现字段自动填充
- 1.3、数据权限
- 1.4、TenantLineInnerInterceptor 的升级改造
- 1.5、TenantLineInnerInterceptor 改造 2
- 1.6、数据权限改造 3
- 二、数据脱敏
- 三、TypeHander
- 总结
前言
网站开发功能是否全面是衡量的一个重要指标,往往我们还需要针对用户进行功能的拼接或者切割,这部分我愿称之为用户画像。在用户画像下我们还需要考虑的是如何做到精准控制。基于 Spring 我们可以轻松实现传统的 三元 管理用于精准控制用户的菜单、按钮、及资源。
再仔细思考想,难道控制住资源就是精准控制了吗?我决定将精准下方我想细化到数据层面。
一、数据
- 上面说的其实总结一句话就是我想将数据控制住,不同的角色拥有不同的权限操作不同的数据。
- 想要实现数据的权限控制,势必需要在数据中额外存储他的所属信息。我们可以将数据分成两部分: 主体信息+所属信息
- 这对于 Spring 来说非常容易,我们只需要在每张表上新增部分字段即可完成。虽然理论上是没有问题的但是实现上我们需要将现存的项目所有的表都添加字段,而且对应的实体上需要映射出来。这个工作量非常大而且重复度极高。这里注意下我的用词 重复度极高,就个人而言我非常不喜欢重复度极高的工作,因为这无疑是枯燥乏味的。
1.1、mybatis-plus 统一字段管理
- 为了规避掉单独处理每张表的问题,我们引入 mybatis-plus 的通用字段处理功能。你要问我你的项目中没有 mybatis-plus 或者你的 sql 没有遵循 mybatis 的基本要求映射该怎么办?那我只能说你必须改到 mybatis 上,或者就单独处理吧。我们这里默认是项目使用 mybatis 的。
- mybatis-plus 中提供 com.baomidou.mybatisplus.core.handlers.MetaObjectHandler 这个类专门用来处理字段填充。他的使用场景就是公共字段的额外处理。
- 点进去会发现需要我们实现的就是上面截图的两个方法。通过方法名我们也能够理解分别是控制插入是公共字段填充和更新时的字段填充的逻辑。
- 上面是实现的 MetaObjectHandler 的实现类。因为 mybatis-plus 主要是针对实现了实体映射的处理,mybatis 除了实体映射以外还提供了 Map 映射,为了能够兼容 Map 映射,所以这里我将公共字段的映射做了一层抽象,主要是为了后面实现 Map 字段管理的时候能够通用。
1.2、Map 映射如何实现字段自动填充
- 既然是组件开发,Mybatis-Plus 提供的功能已经很强大了。但是保不齐项目中就存在这些硬骨头,好在经过一番研究通过拦截器是能够实现 Map 映射的字段填充的。
-
其中使用的 Handler 实际上就是通用上面我们抽象的逻辑。具体代码见底部 github 链接。通过简单的封装我们就统一字段处理了。逻辑处理事没有问题这个时候我们需要将统一字段抽离成一个单独的实体类方便其他类继承。
-
有了这些之后,作为完美主义者我们还缺一个脚本用来更改数据库的变更。
ALTER TABLE {{table_name}} ADD create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间';
ALTER TABLE {{table_name}} ADD update_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间';
ALTER TABLE {{table_name}} ADD create_by VARCHAR(100) NULL COMMENT '创建人';
ALTER TABLE {{table_name}} ADD update_by VARCHAR(100) NULL COMMENT '更新人';
ALTER TABLE {{table_name}} ADD delete_flag INT NULL COMMENT '删除标记';
ALTER TABLE {{table_name}} ADD version INT NULL COMMENT '版本记录';
- 然后通过脚本将 table_name 变量替换掉。因为实现上的脚本涉及到其他内容,所以这里具体脚本就不提供了。我大体上是通过 linux 的 sed 进行模式匹配后替换内容。这块读者可以根据自己擅长的领域进行脚本实现,推荐使用 python 或者 go 吧。linux 太老了,也请原谅我对 linux 的偏爱。
1.3、数据权限
- 上面说了重点仅仅介绍了如何将数据所属的用户信息维护起来。本文的主题是如何进行数据权限控制,接下来我们将重点拉回本文。
- 数据权限最常见的场景就是 SAAS 化,SAAS 的产生背景就是为了实现数据隔离,这点 mybatis-plus 也帮我们实现了 com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor 实现这个类我们就可以将数据进行隔离管理。当前它的实现基础也是每张被管理的表需要有一个公共统一字段。
/**
* 新多租户插件配置,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存万一出现问题
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
List<String> ignoreTableList = new ArrayList<String>() {
{
add("t_tenant");
}
};
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new CustomTenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
try {
SaasPlusControlHandler saasPlusControlHandler = DynicApplicationUtils.getApplicationContext().getBean(SaasPlusControlHandler.class);
String customTenantId = saasPlusControlHandler.getCustomTenantId();
if (StringUtils.isNotEmpty(customTenantId)) {
return new StringValue(customTenantId);
}
} catch (Exception e) {
// 初始化启动忽略报错
}
//return new StringValue("0000-0000-0000-0000-0000-0000-0000-0001");
String tenantId = YpUserUtil.getOriginTenantId();
if (StringUtils.isEmpty(tenantId)) {
return null;
}
return new StringValue(tenantId);
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
if (ignoreTableList.contains(tableName)) {
return true;
}
//return YpUserUtil.ignoreTable(tableName);
return false;
}
}));
// 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
// 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
- 我们只需要实现一个拦截器并指明其中的表名和字段名,mybatis-plus 就会自动帮我们实现统一的权限控制。使用 mybatisplus 提供的就比较受限制,因为它的理念是通过单一字段进行过滤,简单理解为下图
- 但是往往我们的数据权限是跟角色相关,所以上述的 TenantLineInnerInterceptor 虽然可以实现数据权限功能,但是他的主要使用场景 saas 的多租户。
1.4、TenantLineInnerInterceptor 的升级改造
- TenantLineInnerInterceptor 的主要场景是多租户,但是我们的数据权限控制并不是租户级别,而是更加细粒度的用户级别,该如何改造呢?其实很简单我们仍然使用 TenantLineInnerInterceptor 将 tenantId 完全理解成 userId 来识别即可以完成用户级别的所属。
- 这样改的好处是完全不需要改动任何代码就能实现。如果我们不仅仅想实现用户级别数据权限还想事项与用户相关的比如部门权限,那么我们只需要将部门下的用户列表查出来,在 tenantId 比较的时候扩展他的 Expression 就可以了。比如默认实现的大于比较
- 这里我没有细化去实现 In 比较,既然源码中已经实现了抽象我们去扩展那就是很简单的事情了。篇幅有限这里不做介绍了。
1.5、TenantLineInnerInterceptor 改造 2
- mybatisplus 实现的原理无非就是接住拦截器进行 sql 拼接,我们完全可以自己实现一个拦截器去拼接我们的规则,基于此我们完全掌握了 sql 的主动权,这样就可以更加灵活的实现数据的权限控制。
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
@Component
@ConditionalOnProperty(prefix = "spring.tenant",name="enable",havingValue = "true")
public class PermissionInterceptor implements Interceptor {
private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();
private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();
private static final ReflectorFactory REFLECTOR_FACTORY = new DefaultReflectorFactory();
@Autowired
SaasHolderHandler saasHolderHandler;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取sql信息
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
System.out.println("原sql为: " + sql);
// 获取元数据
StatementHandler statementHandler2 = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler2);
MetaObject metaResultSetHandler = MetaObject.forObject(statementHandler, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, REFLECTOR_FACTORY);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// 获取调用方法
String id = mappedStatement.getId();
String className = id.substring(0, id.lastIndexOf("."));
String methodName = id.substring(id.lastIndexOf(".") + 1);
System.out.println("调用方法为: " + id);
if (saasHolderHandler.isAuthSaas()) {
return invocation.proceed();
}
Class<?> type = mappedStatement.getParameterMap().getType();
// 注解查询
Class clazz = Class.forName(className);
Method method = clazz.getDeclaredMethod(methodName,type);
if (method.isAnnotationPresent(TemporaryDisable.class)) {
return invocation.proceed();
}
SimpleMetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory();
MetadataReader metadataReader = metadataReaderFactory.getMetadataReader(className);
AnnotationMetadata annotationMetadata = metadataReader.getAnnotationMetadata();
if (annotationMetadata.hasAnnotation(TemporaryDisable.class.getName())) {
return invocation.proceed();
}
HashMap<String, Object> jwtMap = SecurityUtils.getJwtMap();
/*Map<String, Object> jwtMap = new HashMap<>();
jwtMap.put("startTenant", 0);
jwtMap.put("endTenant", 1);*/
String newSql = String.format("select * from (%s) `range` where tenant_id>=%s and tenant_id < %s", sql, jwtMap.getOrDefault("startTenant",0),jwtMap.getOrDefault("endTenant",1));
// String newSql = "select * from account where permission in (\"advertise\")";
System.out.println("修改后的sql为: " + newSql);
Class boundClass = boundSql.getClass();
Field field = boundClass.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
return invocation.proceed();
}
}
- 我们拦截 sql 后将他包装一层之后就可以利用查询出来的 sql 进行各种的条件判断了。用户权限、部门权限、角色权限、角色组权限等等各种稀奇古怪的权限控制我们都可以实现了。比如之前项目遇到的实现数据的委托处理,我可以将自己的数据委托给某人或者某个角色代为处理,这个时候我们就需要有一个委托字段并且在 sql 中通过这个字段进行扩展过滤,这个复杂的逻辑我们完全可以在拼接的方式中实现。
- 对比能够发现,出发的角度不同,我们控制的程度也不同,基于这种方式条件 sql 完全是我们开发者自主控制。
- 但是他也存在缺点。自定义拦截器在处理查询是非常有效,但是想自定义拦截器去管理数据的填充这还是比较麻烦的,我们需要监听增加、更新的操作并识别出扩展字段,这点比较麻烦,我还是建议使用 TenantLineInnerInterceptor 的方式来管理,查询的时候使用我们自定义的拦截器来实现权限控制。这是其中一个缺点
- 还有一个比较致命的缺点就是我们在 sql 的基础上进行条件判断,如果我们的 sql 压根没有查出来我们条件中的字段就会出现问题。
- antlr 可以解析 sql 的语法树,从而我们能够得知道数据查询的字段。基于此技术我们完全可以由针对的原生 sql 进行单表的过滤,这样实现就完全没有问题,但是笔者测试过 antlr 存在性能问题,所以该改造方案也仅仅是理论方案。但是这条思路是可以走通下去。我这里就算抛砖引玉或者寻找志同道合的人有机会可以一起按照这个思路开发下去。
1.6、数据权限改造 3
- 总结下上面两种的方法:第一种太死板;第二种存在局限性;为了能够解决上面两种缺陷,我们半定制化实现一套权限控制。
@DataFilter(deptColumnName="dept_id",deptAlias = "a",userAlias = "a",userColumnName = "user_id",mapperMethodName = {"queryPage"})
上面的注解用于在一个查询的方法上,而该方法最终的 sql 中 dept_id 表示部门 id,user_id 表示用户 Id 信息。queryPage 就是 mapper 层上真正的查询方法。
二、数据脱敏
- 上面我们从数据源管理以及数据权限两个方向分析了如何实现及相关的改造方案。在数据权限的衍生中还存在一个数据脱敏这个功能。
- 我们上面实现的数据权限是针对数据行,而数据脱敏可以说是针对数据列来操作的。比如现在有个角色可以看到所有的用户数据,这点我们条件匹配即可完成,但是这个用户属于第三方用户,我不想讲用户的关键信息暴露给他,比如说用户的手机号、邮箱、生日、密码等机密信息。这个时候我们可以接住数据脱敏功能。
- 在 mybatis-plus (mate) 中提供了 FieldEncrypt 注解用来加密我们指定的字段数据,也可以通过 SensitiveWordsProcessor 进行数据敏感检测替换。下来配置来源于网络
@Bean
public IParamsProcessor paramsProcessor() {
return new SensitiveWordsProcessor() {
/**
// 可以指定你需要拦截处理的请求地址,默认 /* 所有请求
@Override public Collection<String> getUrlPatterns() {
return super.getUrlPatterns();
}
*/
@Override
public List<String> loadSensitiveWords() {
// 这里的敏感词可以从数据库中读取,也可以本文方式获取,加载只会执行一次
return sensitiveWordsMapper.selectList(Wrappers.<SensitiveWords>lambdaQuery().select(SensitiveWords::getWord))
.stream().map(t -> t.getWord()).collect(Collectors.toList());
}
@Override
public String handle(String fieldName, String fieldValue, Collection<Emit> emits) {
if (CollectionUtils.isNotEmpty(emits)) {
try {
// 这里可以过滤直接删除敏感词,也可以返回错误,提示界面删除敏感词
System.err.println("发现敏感词(" + fieldName + " = " + fieldValue + ")" +
"存在敏感词:" + toJson(emits));
String fv = fieldValue;
for (Emit emit : emits) {
fv = fv.replaceAll(emit.getKeyword(), "");
}
return fv;
} catch (Exception e) {
e.printStackTrace();
}
}
return fieldValue;
}
};
}
- 两种都可实现我们所谓的数据脱敏。加密本身也是一种数据脱敏。但是上面的方式都过于死板,比如我想新增一个字段的加密就会需要重新开发,虽然开发量很少,但是重大项目发布周期本身就很繁琐,所以我就思考能不能实现一个热数据脱敏,可以实时生效的数据脱敏法。
三、TypeHander
-
自定义数据脱敏主要是在数据看和 java 代码映射的时候进行拦截处理,这点 TypeHandler 恰好符合。TypeHandler 主要是类型处理器,将 sql 的数据类型映射成 java 数据类型的中专站,那么我们完全可以在转换的时候将我们指定的数据进行加密即脱敏。
-
上面是 JDBC 的处理流程,我们需要拦截的就是第五步 处理运行结果。对应到 mybatis 上就是 TypeHandler。
-
那么我们只需要注册一个 TypeHandler 就可以实现映射时进行数据脱敏
@SneakyThrows
@Override
public String getNullableResult(ResultSet resultSet, String s) throws SQLException {
String result = resultSet.getString(s);
if (StringUtils.isEmpty(result)) {
return result;
}
if (ControEncrypThreadLocal.ACCESS.equals(ControEncrypThreadLocal.getAccess())) {
return result;
}
if (null == globalConfig) {
globalConfig = SpringUtils.getBean(GlobalConfig.class);
}
if (null != globalConfig && "false".equals(globalConfig.getEncryp())) {
return result;
}
EncrypService encrypService = SpringUtils.getBean(EncrypService.class);
List<Encryp> encrypList = encrypService.selectMoreEncryp(s);
if (CollectionUtils.isEmpty(encrypList)) {
return result;
}
ResultSetMetaData metaData = resultSet.getMetaData();
//((ResultSetMetaData) ((ResultSetMetaDataProxyImpl) metaData).raw).fields
ResultSetMetaDataProxyImpl metaData1 = (ResultSetMetaDataProxyImpl) metaData;
ResultSetMetaData resultSetMetaDataRaw = metaData1.getResultSetMetaDataRaw();
Class<? extends ResultSetMetaData> aClass = resultSetMetaDataRaw.getClass();
for (Field declaredField : aClass.getDeclaredFields()) {
System.out.println("declaredField.getName() = " + declaredField.getName());
}
Field fields = aClass.getDeclaredField("fields");
fields.setAccessible(true);
Object o = fields.get(resultSetMetaDataRaw);
com.mysql.cj.result.Field[] fieldArray = (com.mysql.cj.result.Field[]) o;
List<com.mysql.cj.result.Field> fieldList = Arrays.asList(fieldArray);
Map<String, com.mysql.cj.result.Field> collect = fieldList.stream().collect(Collectors.toMap(com.mysql.cj.result.Field::getOriginalName, a -> a, (k1, k2) -> k1));
if (collect.containsKey(s)) {
com.mysql.cj.result.Field field = collect.get(s);
String originalTableName = field.getOriginalTableName();
String databaseName = field.getDatabaseName();
Map<String, Encryp> encrypCollect = encrypList.stream().collect(Collectors.toMap(item -> {
return String.format("%s-%s-%s", item.getDatabaseName(), item.getTableName(), item.getColumnName());
}, a -> a, (k1, k2) -> k1));
if (encrypCollect.containsKey(String.format("%s-%s-%s",databaseName, originalTableName, s))) {
return encrypService.encryp(s);
}
}
return result;
}
- 上面哪些字段需要加密我是通过读取配置的方式进行开发的,这样就能够达成热部署的方式进行字段控制了。这段代码虽然逻辑简单但是如何获取字段却花费了好长时间,值得你收藏哦。
TypeHandler 开发好还需要指定数据库类型才能生效。
- 到此我们在 mybatis 映射时 String 字段类型的就可以通过配置进行管理是否数据脱敏了。这样对于我们的运营人员来说就方便很多了。比如每年特殊时期我们需要对某些数据进行脱敏,过了特殊时期后放开。
总结
- 本文主要是思路的剖析,并没有将每个案例都完成的代码呈现出来,因为在项目中也不可能同时使用上述所有的场景。我仅讲核心代码提供,部分辅助型的如有需要可以下方留言给你提供思路。
- mybatis-plus 功能已经很强大了。基本上我们主流的需求都存在,但是保不齐我们项目需要定制化开发。上述的功能基本上都是在 mybatis 的基础上进行额外的扩展,技术无好坏,主要是符合项目最重要。