基本所有的系统都会涉及菜单访问权限和数据访问权限。对于菜单访问权限一般实现方式都差不多,用户登录时加载具有访问权限的菜单,然后进行展示,用户访问菜单时通过统一的拦截器服务器端再次判断是有具有访问权限,防止前端直接url越权访问;对于细粒度具体到按钮级别的访问控制,实现方式也差不多,一个具体到菜单访问url,一个具体到方法访问url,对于菜单访问权限设计实现此文不做过多介绍。对于数据权限,一般需要根据系统实际的业务场景进行设计实现,那些脱离业务谈数据权限都是扯淡的。参与的一个项目,需要实现如下数据权限,可以设置登录用户访问所有的数据、访问本部门的数据、访问本部门及子部门数据、访问与自己相关的数据,对此进行了设计实现,不多说,直接上代码。
一、设计思路
1、用户登录时获取到用户信息和所在部门信息,存取到缓存中
2、自定义数据权限注解,对于添加注解的方法解析拼接数据权限查询sql条件
3、自定义mybatis拦截器,对于需要数据权限过滤的重写查询sql,拼接上数据过滤sql查询条件,以达到数据访问控制的效果
说明:需要部门表上添加data_scope字段(1全部,2本部门,3部门及以下,4本人),代表数据权限范围;full_path字段,代表部门全路径,id拼接。所有的需要进行数据权限过滤的业务表添加dept_id字段,代表数据属于哪个部门;create_by字段,代表是谁创建的数据;否则不会进行数据权限过滤。
二、代码实现
1、数据权限自定义注解
/**
* 数据权限过滤注解
*
* @author wangfenglei
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataAuthScope {
/**
* 部门表的别名
*/
String deptAlias() default "";
/**
* 用户表的别名
*/
String userAlias() default "";
}
2、数据权限切面,添加数据权限注解的方法执行切面方法
import net.wfl.framework.boot.model.vo.LoginUser;
import net.wfl.framework.boot.model.vo.SysDepartModel;
import net.wfl.user.api.UserApi;
import net.wfl.user.auth.aspect.annotation.DataAuthScope;
import net.wfl.user.auth.shiro.util.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 数据过滤处理
*
* @author wangfenglei
*/
@Aspect
@Component
public class DataAuthScopeAspect {
public static final ThreadLocal DATA_AUTH_THREAD_LOCAL = new ThreadLocal();
/**
* 全部数据权限
*/
public static final Integer DATA_SCOPE_ALL = 1;
/**
* 部门数据权限
*/
public static final Integer DATA_SCOPE_DEPT = 2;
/**
* 部门及以下数据权限
*/
public static final Integer DATA_SCOPE_DEPT_AND_CHILD = 3;
/**
* 仅本人数据权限
*/
public static final Integer DATA_SCOPE_SELF = 4;
@Autowired
private UserApi userApi;
@Before("@annotation(controllerDataAuthScope)")
public void doBefore(JoinPoint point, DataAuthScope controllerDataAuthScope) throws Throwable {
DATA_AUTH_THREAD_LOCAL.remove();
handleDataAuthScope(point, controllerDataAuthScope);
}
@After("@annotation(controllerDataAuthScope)")
public void doAfter(DataAuthScope controllerDataAuthScope) throws Throwable {
//清空数据权限拼接SQL
DATA_AUTH_THREAD_LOCAL.remove();
}
/**
* 处理数据权限
*
* @param joinPoint 切面
* @param dataAuthScope 数据权限
*/
protected void handleDataAuthScope(final JoinPoint joinPoint, DataAuthScope dataAuthScope) {
// 获取当前的用户
LoginUser currentUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
if (null == currentUser) {
return;
}
List<SysDepartModel> departModelList = currentUser.getDepartList();
List<String> departIdList = new ArrayList<>();
List<String> userNameList = new ArrayList<>();
for (SysDepartModel departModel : departModelList) {
if (DATA_SCOPE_ALL.equals(departModel.getDataScope())) {
continue;
} else if (DATA_SCOPE_DEPT.equals(departModel.getDataScope())) {
departIdList.add(departModel.getId());
} else if (DATA_SCOPE_DEPT_AND_CHILD.equals(departModel.getDataScope())) {
List<String> departIds = getChildDepartIdList(departModel);
if (!CollectionUtils.isEmpty(departIds)) {
departIdList.addAll(departIds);
}
} else if (DATA_SCOPE_SELF.equals(departModel.getDataScope())) {
userNameList.add(currentUser.getUsername());
}
}
StringBuilder sqlString = new StringBuilder();
//如果需要部门权限
if (!CollectionUtils.isEmpty(departIdList)) {
sqlString.append(StringUtils.format(" OR {}dept_id IN ({})", dataAuthScope.deptAlias(), getDepartSqlStr(departIdList)));
} else if (!CollectionUtils.isEmpty(userNameList)) {
sqlString.append(StringUtils.format(" OR {}createBy = {}", dataAuthScope.userAlias(), currentUser.getUsername()));
}
if (StringUtils.isNotBlank(sqlString.toString())) {
//设置数据权限拼接sql
DATA_AUTH_THREAD_LOCAL.set(" AND (" + sqlString.substring(4) + ")");
}
}
/**
* 获取本部门和子部门ID列表
*
* @param departModel 部门信息
* @return 本部门和子部门ID列表
*/
private static List<String> getChildDepartIdList(SysDepartModel departModel) {
String id = departModel.getId();
String fullPath = departModel.getFullPath();
String childIds = fullPath.substring(fullPath.indexOf(id), fullPath.length());
String[] childArr = childIds.split(",");
return Arrays.asList(childArr);
}
/**
* 拼接部门sql
*
* @param departIdList 部门ID列表
* @return 拼接部门sql
*/
private static String getDepartSqlStr(List<String> departIdList) {
if (CollectionUtils.isEmpty(departIdList)) {
return null;
}
StringBuilder sql = new StringBuilder("'");
departIdList.forEach(data -> {
sql.append(data).append("'").append(",");
});
sql.delete(sql.length() - 1, sql.length());
return sql.toString();
}
说明:拼接的数据过滤查询sql条件存储到ThreadLocal中,在mybatis拦截器中使用
3、自定义mybatis拦截器实现
import lombok.extern.slf4j.Slf4j;
import net.wfl.user.auth.aspect.DataAuthScopeAspect;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.Properties;
/**
* 数据权限sql查询条件,拼接新的sql,实现数据权限过滤
*
* @Author wangfenglei
*/
@Slf4j
@Component
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class DataAuthInterceptor implements Interceptor {
/**
* 分组
*/
private static final String GROUP_BY = "group by";
/**
* 排序
*/
private static final String ORDER_BY = "order by";
/**
* 分组
*/
private static final String LIMIT = "limit";
/**
* from
*/
private static final String FROM = "from";
/**
* where
*/
private static final String WHERE = "where";
/**
* 分隔符
*/
private static final String SPLIT_CHARACTER = ",";
/**
* where条件拼接
*/
private static final String WHERE_CONDITION = " where 1=1 ";
@Value("${wfl.data-auth-table:null}")
private String dataAuthTable;
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
if (SqlCommandType.SELECT == sqlCommandType) {
Object dataAuthSql = DataAuthScopeAspect.DATA_AUTH_THREAD_LOCAL.get();
//如果添加数据权限
if (null != dataAuthSql) {
BoundSql boundSql = (BoundSql) invocation.getArgs()[5];
//获取到原始sql语句
String sql = boundSql.getSql();
String mSql = sql;
//多个空格替换为一个空格
mSql = mSql.replaceAll("\\s{1,}", " ").toLowerCase();
mSql = addWhere(mSql);
//获取查询主表
String queryTable = getQueryTable(mSql);
//如果查询主表可以进行数据权限过滤
if (dataAuthTable.indexOf(queryTable) >= 0) {
//重写sql
StringBuilder newSqlBuilder = new StringBuilder();
//重写sql语句 前面拼接数据权限语句
if (mSql.indexOf(GROUP_BY) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(GROUP_BY)))
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(GROUP_BY), mSql.length()));
} else if (mSql.indexOf(ORDER_BY) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(ORDER_BY)))
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(ORDER_BY), mSql.length()));
} else if (mSql.indexOf(LIMIT) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(LIMIT)))
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(LIMIT), mSql.length()));
} else if (mSql.indexOf(WHERE) > 0) {
newSqlBuilder.append(mSql)
.append(" ")
.append(dataAuthSql.toString());
} else {
newSqlBuilder.append(mSql)
.append(WHERE_CONDITION)
.append(dataAuthSql.toString());
}
log.debug("=====DataAuth sql rewrite:\nold sql:={}\nnew sql:={}", sql, newSqlBuilder.toString());
//通过反射修改sql语句
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSqlBuilder.toString());
}
}
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
/**
* 获取查询主表
*
* @param sql sql语句
* @return 主表表名
*/
private String getQueryTable(String sql) {
String tempSql = "";
if (sql.indexOf(WHERE) > 0) {
tempSql = sql.substring(sql.indexOf(FROM), sql.indexOf(WHERE)).trim();
} else {
tempSql = sql.substring(sql.indexOf(FROM), sql.length()).trim();
}
String table = tempSql.split(" ")[1];
return table.split(SPLIT_CHARACTER)[0].trim();
}
/**
* 添加where关键字
*
* @param sql sql语句
* @return sql
*/
private String addWhere(String sql) {
if (sql.indexOf(WHERE) >= 0) {
return sql;
}
StringBuilder newSqlBuilder = new StringBuilder();
if (sql.indexOf(GROUP_BY) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(GROUP_BY)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(GROUP_BY), sql.length()));
} else if (sql.indexOf(ORDER_BY) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(ORDER_BY)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(ORDER_BY), sql.length()));
} else if (sql.indexOf(LIMIT) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(LIMIT)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(LIMIT), sql.length()));
} else {
newSqlBuilder.append(sql).append(" ").append(WHERE_CONDITION);
}
return newSqlBuilder.toString();
}
}
说明: 原始sql不要通过以下方式获取,项目一般会集成mybatis-plus分页插件,这种方式获取会导致分页查询失效
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
//获取到原始sql语句
String sql = boundSql.getSql();
需要通过如果方式获取,对于未集成mybatis-plus的查询是否也如此有待验证
BoundSql boundSql = (BoundSql) invocation.getArgs()[5];
//获取到原始sql语句
String sql = boundSql.getSql();
三、此实现的不足
此设计实现通过用户所在部门的数据权限范围,自动生成数据过滤查询条件,然后通过sql拦截器组装数据过滤条件,最终达到数据权限控制的效果。
1、此实现在重写sql时未考虑union查询的情况;
2、对于超级复杂的sql也许会存在过滤失败的情况。
不过此实现面对开发人员使用,可以知道方法查询哪些数据,基本可以满足业务场景需要