mybatis拦截器+CCJSqlParser实现解耦数据权限

前言

从工作以来经手了好多个从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层直接返回无权限即可~

posted @ 2020-03-20 16:27  LinPeng_bky  阅读(4121)  评论(1编辑  收藏  举报