前言
從工作以來經手了好多個從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層直接返回無權限即可~