前言
从工作以来经手了好多个从0-1的项目,所以也写了很多很多次权限相关的代码,但每次的数据权限实现都不理想,每接入一个新的功能页面都要针对各个接口进行数据过滤,由其是一些不清楚权限设计的同学想写个功能,还要去弄明白权限的那一堆事才可以,然后过滤的逻辑就会耦合在各个业务代码中合,简直就是被代码支配的恐惧。那有什么好的办法能做好解耦呢
方案与实现
数据权限无非就是通过sql,过滤掉无权限的数据,以及拦截那些无权限数据的操作。那我们直接拦截sql,将拦截语句拼装进去不就行啦~
使用自定义注解来标识哪些Mapper方法需要被拦截
创建mybatis intercept拦截器,检测方法是否有自定义注解
使用CCJSqlParser解析并改写SQL
覆盖原SQL
数据表设计
CREATE TABLE `data_group_ref` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`data_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '数据id',
`data_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '数据类型',
`group_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '用户组id',
......
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据与用户组的对应关系表';
在小数据量下,我们只需要一个数据与用户组的关系表即可。
data_id 对应数据表的主键id
data_type 是数据类型标识,因为系统可能会有很多数据表需要做权限,每个表都创一个关系表,就会出现表爆炸,所以全放一个关系表里用type来区分就好
group_id 是对应的用户组主键id ,每一个数据可以对应多个组,那就会有多条记录
自定义注解
@Target(value = {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataAuthSelect {
//数据类型, data_group_ref表中的 data_type 字段值
int type() default 0;
}
这里我只列出type用来告知拦截器需要过滤的数据类型,大家可以根据自己的业务进行扩展
拦截器
针对拦截器的原理,网上有好多资料,我就不再反复阐述,大家可参考
https://blog.csdn.net/weixin_39494923/article/details/91534658
@Intercepts({@Signature(method = "query", type = Executor.class,
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
@Component
public class DataAuthSelectIntercept implements Interceptor {
private static final Logger logger = LoggerFactory.getLogger(DataAuthSelectIntercept.class);
@Autowired
HttpServletRequest request;
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
//没自定义注解直接按通过算
DataAuthSelect dataAuth = getDataAuth(mappedStatement);
if (dataAuth == null) {
return invocation.proceed();
}
//没登录是异常
UserInfo user = (UserInfo) request.getSession().getAttribute("userInfo");
if (user == null) {
throw new Exception("获取用户登录信息失败");
}
//超级管理员不过滤
if (user.isSuperAdmin()) {
return invocation.proceed();
}
//根据自己的业务写更多的过滤判断.....
//如果获取用户组失败,或者为全部权限,则直接通过
String groupStr = getGroupStr(user);
if (StringUtils.isBlank(groupStr)) {
return invocation.proceed();
}
//拼装sql(这里是关键!!!)
BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]);
String orgSql = boundSql.getSql(); //获取到当前需要被执行的SQL
String authSql = makeSql(orgSql, groupStr, dataAuth); //进行数据权限过滤组装
//替换
MappedStatement newStatement = newMappedStatement(mappedStatement, new BoundSqlSqlSource(boundSql));
MetaObject msObject = MetaObject.forObject(newStatement, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(), new DefaultReflectorFactory());
msObject.setValue("sqlSource.boundSql.sql", authSql);
invocation.getArgs()[0] = newStatement;
logger.debug("baseSql:{} authSql:{}", orgSql, authSql);
return invocation.proceed();
}
/**
* 通过反射获取mapper方法是否加了自定义注解
*/
private DataAuthSelect getDataAuth(MappedStatement mappedStatement) throws ClassNotFoundException {
DataAuthSelect dataAuth = null;
String id = mappedStatement.getId();
String className = id.substring(0, id.lastIndexOf("."));
String methodName = id.substring(id.lastIndexOf(".") + 1);
final Class<?> cls = Class.forName(className);
final Method[] methods = cls.getMethods();
for (Method method : methods) {
if (method.getName().equals(methodName) && method.isAnnotationPresent(DataAuthSelect.class)) {
dataAuth = method.getAnnotation(DataAuthSelect.class);
break;
}
}
return dataAuth;
}
/**
* 获取当前登录的用户配置的用户组信息 (group_id)
*/
private String getGroupStr(UserInfo user) {
//分局自己的业务逻辑实现
return "1,2,3,4,5";
}
/**
* 核心代码: 将原SQL 进行解析并拼装 一个子查询 id in ( 数据权限过滤SQL )
*/
private String makeSql(String sql, String groupStr, DataAuthSelect dataAuth) throws JSQLParserException {
CCJSqlParserManager parserManager = new CCJSqlParserManager();
Select select = (Select) parserManager.parse(new StringReader(sql));
PlainSelect plain = (PlainSelect) select.getSelectBody();
Table fromItem = (Table) plain.getFromItem();
//有别名用别名,无别名用表名,防止字段冲突报错
String mainTableName = fromItem.getAlias() == null ? fromItem.getName() : fromItem.getAlias().getName();
//构建子查询
String dataAuthSql = mainTableName + ".id in ( select data_id from data_group_ref where " + mainTableName + ".id = data_id and data_type = " + dataAuth.type() + " and group_id in (" + groupStr + ")" + ")";
if (plain.getWhere() == null) {
plain.setWhere(CCJSqlParserUtil.parseCondExpression(dataAuthSql));
} else {
plain.setWhere(new AndExpression(plain.getWhere(), CCJSqlParserUtil.parseCondExpression(dataAuthSql)));
}
return select.toString();
}
private MappedStatement newMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
MappedStatement.Builder builder =
new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
if (ms.getKeyProperties() != null && ms.getKeyProperties().length != 0) {
StringBuilder keyProperties = new StringBuilder();
for (String keyProperty : ms.getKeyProperties()) {
keyProperties.append(keyProperty).append(",");
}
keyProperties.delete(keyProperties.length() - 1, keyProperties.length());
builder.keyProperty(keyProperties.toString());
}
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.resultSetType(ms.getResultSetType());
builder.cache(ms.getCache());
builder.flushCacheRequired(ms.isFlushCacheRequired());
builder.useCache(ms.isUseCache());
return builder.build();
}
private class BoundSqlSqlSource implements SqlSource {
private BoundSql boundSql;
public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
至此我们已经完成了数据权限所有的代码编写。
一起看下原始sql与拼装后sql的区别
select id,name from test where is_deleted = 0
select id,name from test where is_deleted = 0 and id in ( select data_id from data_group_ref where test.id = data_id and data_type = 1 and group_id in ("1,2,3") )
为什么使用子查询不用join? 因为子查询兼容更强,需要改写的sql更少,如果用join 原sql是单查询,那组装时还要对 select的字段进行加别名,能少做点事就少做点吧~
使用与规范
让我们一起来体验下解耦后的快了吧,我们只需一行代码,在对应需要做权限的mapper中加一个注解!
对应的sql就会自动被增加数据权限的过滤,不需要了解权限的设计,不需要自己反复的拼接那条一样的sql。
@Select(value = "SELECT * FROM lcp_rule WHERE id = #{id} and is_deleted = 0")
@DataAuthSelect(type = 1)
Rule selectByIdAuth(Serializable id);
虽然我们只定义了对sql的拦截,但是我们却能够实现 列表、查看、编辑、删除 等等常见数据权限限制!
列表与查看不用多说,本身就是查询。 编辑与删除,是update与delete ,我们虽然没有拦截,但通常在进行修改与删除之前,我们需要进行一个 getbyid的查询来确保数据存在。一但进行这个查询,不就又命中我们的拦截,如果返回空数据,c层直接返回无权限即可~