先说结论:可能因为角色SQL判断复杂,查询不通用的特性。导致没有将JDBC方式进行角色查询与校验的相关代码放入。
一、背景
最近在调研 zeppelin(0.10.1 版本),在测试鉴权这一块的时候发现了问题(主要是在 zeppelin 开启 Shiro 后,配置完 JdbcRealm 。发现 zeppelin 的角色权限控制好像不生效。),特此记录一下发现问题以及解决方式。
1.1 数据库表
我使用了PG库存储用户表和角色表,因为是测试功能,因此表很简单:
用户表(users)
用户角色表(user_roles)
不要问我为什么没有角色表,因为一切从简。当然,如果有角色表,后面修改源码的地方也可以用到。
1.2 shiro.ini 配置文件
配置文件 %ZEPPELIN_HOME/conf/shiro.ini
部分内容如下,因为这里主要描述 JdbcRealm 因此,只展示相关内容:
[main]
# 定义 JdbcRealm
jdbcRealm = org.apache.shiro.realm.jdbc.JdbcRealm
dataSource = com.alibaba.druid.pool.DruidDataSource
dataSource.url = jdbc:postgresql://:/?characterEncoding=UTF-8&allowMultiQueries=true¤tSchema=
dataSource.username =
dataSource.password =
dataSource.driverClassName = org.postgresql.Driver
jdbcRealm.dataSource=$dataSource
# 设置用户表和用户角色关联表的查询语句
jdbcRealm.authenticationQuery = SELECT password FROM users WHERE username = ?
jdbcRealm.userRolesQuery = SELECT role_name FROM user_roles WHERE username = ?
securityManager.realms=$jdbcRealm
1.3 问题复现
在 zeppelin 开启 Shiro 后,配置完 JdbcRealm 。发现 zeppelin 的角色权限控制好像不生效。具体细节与现象如下:
在 notebook 中设置权限时,只能搜索到用户,搜不到角色。
权限控制的时候,再报错信息中发现当前用户只显示了用户名,而没有显示角色。
二、问题排查
一开始我以为是配置文件中关于查询角色的SQL有问题,后面发现SQL可以执行。然后搜索了一圈都没什么发现。
然道别人都不用这个方式认证吗?我是忍不了直接把账号、角色信息写在 ini 中,然后每次更新都要重启。LDAP方式因为对其了解不多,觉得后续维护不过来,可能有安全风险等。最终选择 JdbcRealm 来作为用户认证鉴权的方式,毕竟还想直接用其他业务系统的用户表。
接着又打开浏览器 F12,发现返回的只有用户名没有角色:
修改日志等级为 DEBUG,也没有看到什么报错和不合理的地方。
然后我又把认证方式改回默认的文件配置方式,结果发现,角色生效了。
没办法,只能看源码了。
三、源码研究
3.1 搜索角色失败
3.1.1 源码排查过程
通过 API 请求 URL:
找到了请求方法入口:
/**
* Get userlist.
*
* Returns list of all user from available realms
*
* @return 200 response
*/
@GET
@Path("userlist/{searchText}")
public Response getUserList(@PathParam("searchText") final String searchText) {
final int numUsersToFetch = 5;
List usersList = authenticationService.getMatchedUsers(searchText, numUsersToFetch);
List rolesList = authenticationService.getMatchedRoles();
// ......
}
可以发现实际上就是搜索了2次,然后把结果合并返回。然后这里有一个安全服务接口,跟进去发现有两个实现,分别是匿名实现(不做研究)和 Shiro 实现:
对接口方法进行查看,这里仅展示关键方法:
/**
* zeppelin安全接口
*/
public interface AuthenticationService {
/**
* Get current principal/username.
* 返回当前经过身份验证的用户(如果有),否则返回“匿名”。
*/
String getPrincipal();
/**
* Get roles associated with current principal
* 获取与当前主体关联的角色(也就是说通过这个方法来获取当前用户的角色)
*/
Set getAssociatedRoles();
/**
* 获取 Realm 列表(在这里即:org.apache.shiro.realm.jdbc.JdbcRealm)
*/
Collection getRealmsList();
/**
* Used for user auto-completion
* 搜索用户
* @param searchText
* @param numUsersToFetch
*/
List getMatchedUsers(String searchText, int numUsersToFetch);
/**
* Used for role auto-completion
* 搜索角色(关键字在上面对搜索用户方法中,实际在 notebook 中权限管理搜索时候会同时搜索用户和角色)
*/
List getMatchedRoles();
}
然后进入搜索用户方法:
/**
* Get candidated users based on searchText
*
* @param searchText
* @param numUsersToFetch
* @return
*/
@Override
public List getMatchedUsers(String searchText, int numUsersToFetch) {
List usersList = new ArrayList();
try {
Collection realmsList = getRealmsList();
if (realmsList != null) {
for (Realm realm : realmsList) {
String realClassName = realm.getClass().getName();
LOGGER.debug("RealmClass.getName: {}", realClassName);
if (INI_REALM.equals(realClassName)) {
usersList.addAll(getUserList((IniRealm) realm));
} else if (LDAP_GROUP_REALM.equals(realClassName)) {
usersList.addAll(getUserList((DefaultLdapRealm) realm, searchText, numUsersToFetch));
} else if (LDAP_REALM.equals(realClassName)) {
usersList.addAll(getUserList((LdapRealm) realm, searchText, numUsersToFetch));
} else if (ACTIVE_DIRECTORY_GROUP_REALM.equals(realClassName)) {
usersList.addAll(
getUserList((ActiveDirectoryGroupRealm) realm, searchText, numUsersToFetch));
} else if (JDBC_REALM.equals(realClassName)) {
usersList.addAll(getUserList((JdbcRealm) realm));
}
}
}
} catch (Exception e) {
LOGGER.error("Exception in retrieving Users from realms ", e);
}
return usersList;
}
发现有一段代码,应该就是我们本次的认证方式:
else if (JDBC_REALM.equals(realClassName)) {
usersList.addAll(getUserList((JdbcRealm) realm));
}
进入 JdbcReam 获取用户列表的方法:
/** Function to extract users from JDBCs. */
private List getUserList(JdbcRealm obj) {
List userlist = new ArrayList();
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
DataSource dataSource;
String authQuery;
String[] retval;
String tablename = "";
String username = "";
String userquery;
try {
dataSource = (DataSource) FieldUtils.readField(obj, "dataSource", true);
authQuery = (String) FieldUtils.readField(obj, "authenticationQuery", true);
LOGGER.info(authQuery);
String authQueryLowerCase = authQuery.toLowerCase();
retval = authQueryLowerCase.split("from", 2);
if (retval.length >= 2) {
retval = retval[1].split("with|where", 2);
tablename = retval[0];
retval = retval[1].split("where", 2);
if (retval.length >= 2) {
retval = retval[1].split("=", 2);
} else {
retval = retval[0].split("=", 2);
}
username = retval[0];
}
if (StringUtils.isBlank(username) || StringUtils.isBlank(tablename)) {
return userlist;
}
userquery = String.format("SELECT %s FROM %s", username, tablename);
} catch (IllegalAccessException e) {
LOGGER.error("Error while accessing dataSource for JDBC Realm", e);
return new ArrayList();
}
try {
con = dataSource.getConnection();
ps = con.prepareStatement(userquery);
rs = ps.executeQuery();
while (rs.next()) {
userlist.add(rs.getString(1).trim());
}
} catch (Exception e) {
LOGGER.error("Error retrieving User list from JDBC Realm", e);
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(ps);
JdbcUtils.closeConnection(con);
}
return userlist;
}
可以发现获取用户列表步骤其实就是:
authenticationQuery
字段值(即,我们配置的 jdbcRealm.authenticationQuery = SELECT password FROM users WHERE username = ?
)from
分割取第二部分(即,users WHERE username = ?
)where
分割,将第一部分 users
赋给 tablename
字段作为表名,将第二部分对 =
进行分割后取第一部分 username
赋给 username
字段作为字段名userquery = String.format("SELECT %s FROM %s", username, tablename)
(即,查询用户表所有用户)userlist
返回到此,我们了解了怎么搜索用户列表。接着我们继续查看搜索角色的方法:
/**
* Get matched roles.
*
* @return
*/
@Override
public List getMatchedRoles() {
List rolesList = new ArrayList();
try {
Collection realmsList = getRealmsList();
if (realmsList != null) {
for (Realm realm : realmsList) {
String name = realm.getClass().getName();
LOGGER.debug("RealmClass.getName: {}", name);
if (INI_REALM.equals(name)) {
rolesList.addAll(getRolesList((IniRealm) realm));
} else if (LDAP_REALM.equals(name)) {
rolesList.addAll(getRolesList((LdapRealm) realm));
}
}
}
} catch (Exception e) {
LOGGER.error("Exception in retrieving Users from realms ", e);
}
return rolesList;
}
在这段代码中,发现居然只支持 INI_REALM
和 LDAP_REALM
,这应该就是搜索不到角色,但是切回默认的INI配置文件方式又可以查到的原因。
这也就是为什么通过修改日志等级后打印出来的日志为:
DEBUG [2023-07-28 01:57:48,241] ({qtp1077199500-18} ShiroAuthenticationService.java[getMatchedUsers]:166) - RealmClass.getName: org.apache.shiro.realm.jdbc.JdbcRealm INFO [2023-07-28 01:57:48,242] ({qtp1077199500-18} ShiroAuthenticationService.java[getUserList]:418) - SELECT password FROM users WHERE username = ? DEBUG [2023-07-28 01:57:48,257] ({qtp1077199500-18} ShiroAuthenticationService.java[getMatchedRoles]:200) - RealmClass.getName: org.apache.shiro.realm.jdbc.JdbcRealm
只打印了匹配用户密码
jdbcRealm.authenticationQuery
的SQL语句,而没有打印我们配置获取角色jdbcRealm.userRolesQuery
的SQL语句。
3.1.2 源码改造
既然找到原因,那么接下来就是对源码进行改造:
让其支持对 JdbcRealm 的支持
/**
* Get matched roles.(支持 JdbcRealm)
*/
@Override
public List getMatchedRoles() {
List rolesList = new ArrayList();
try {
Collection realmsList = getRealmsList();
if (realmsList != null) {
for (Realm realm : realmsList) {
String name = realm.getClass().getName();
LOGGER.debug("RealmClass.getName: {}", name);
if (INI_REALM.equals(name)) {
rolesList.addAll(getRolesList((IniRealm) realm));
} else if (LDAP_REALM.equals(name)) {
rolesList.addAll(getRolesList((LdapRealm) realm));
} else if (JDBC_REALM.equals(name)) {
rolesList.addAll(getRolesList((JdbcRealm) realm));
}
}
}
} catch (Exception e) {
LOGGER.error("Exception in retrieving Users from realms ", e);
}
return rolesList;
}
添加获取角色列表方法(这里我们可以参考之前获取用户列表的方式)
/**
* 直接获取 userRolesQuery 值 where 前面的内容作为查询语句,然后从数据库中搜索角色
* 注意:
* 1.如果自定义语句比较复杂可能不符合需要自行修改(或许这也是为什么默认没有实现的原因,因为用户自定义角色查询SQL可能各种各样,没办法做到统一)
* 2.角色字段需要放在查询结果的第一个字段(同第一点,这里只是一个例子,可以根据实际情况修改)
*/
private Collection getRolesList(JdbcRealm r) {
Set roleSet = new HashSet();
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
DataSource dataSource;
String userRolesQuery;
String rolesQuery;
try {
dataSource = (DataSource) FieldUtils.readField(r, "dataSource", true);
userRolesQuery = (String) FieldUtils.readField(r, "userRolesQuery", true);
LOGGER.info(userRolesQuery);
String authQueryLowerCase = userRolesQuery.toLowerCase();
// 直接获取 where 前面的内容作为查询语句
rolesQuery = authQueryLowerCase.split("where")[0];
if (StringUtils.isBlank(rolesQuery)) {
return new ArrayList();
}
} catch (IllegalAccessException e) {
LOGGER.error("Error while accessing dataSource for JDBC Realm", e);
return new ArrayList();
}
try {
con = dataSource.getConnection();
ps = con.prepareStatement(rolesQuery);
rs = ps.executeQuery();
while (rs.next()) {
roleSet.add(rs.getString(1).trim());
}
} catch (Exception e) {
LOGGER.error("Error retrieving Role list from JDBC Realm", e);
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(ps);
JdbcUtils.closeConnection(con);
}
return roleSet;
}
然后编译源码,将生成的 zeppelin-server-0.10.1.jar
进行替换(直接下载 0.10.1
代码里面实际版本是 0.10.2-SNAPSHOT
,经过测试不影响,直接重命名后替换掉),然后重启程序。再次输入,可以发现已经可以搜索到角色了。
3.2 角色权限失效
3.2.1 源码排查过程
解决完角色搜索不到的问题后,我们继续查找角色权限校验失败的问题。
通过分析安全接口的方法,可以看到:Set getAssociatedRoles()
这个方法是用来获取与当前主体关联的角色,而主体(经过身份验证的用户)又是通过 String getPrincipal()
获取到的(根据代码分析,这个方法只返回用户名,因为能获取到说明是已经经过认证的了)。
既然这样,那是不是就意味着因为我根据用户名,搜索不到这个用户的角色信息,因此才会鉴权失败呢。那我们来看看代码细节:
/**
* Return the roles associated with the authenticated user if any otherwise returns empty set.
* TODO(prasadwagle) Find correct way to get user roles (see SHIRO-492)
*
* @return shiro roles
*/
@Override
public Set getAssociatedRoles() {
Subject subject = org.apache.shiro.SecurityUtils.getSubject();
Set roles = new HashSet();
Map allRoles = null;
if (subject.isAuthenticated()) {
Collection realmsList = getRealmsList();
for (Realm realm : realmsList) {
String name = realm.getClass().getName();
if (INI_REALM.equals(name)) {
allRoles = ((IniRealm) realm).getIni().get("roles");
break;
} else if (LDAP_REALM.equals(name)) {
try {
AuthorizationInfo auth =
((LdapRealm) realm)
.queryForAuthorizationInfo(
new SimplePrincipalCollection(subject.getPrincipal(), realm.getName()),
((LdapRealm) realm).getContextFactory());
if (auth != null) {
roles = new HashSet(auth.getRoles());
}
} catch (NamingException e) {
LOGGER.error("Can't fetch roles", e);
}
break;
} else if (ACTIVE_DIRECTORY_GROUP_REALM.equals(name)) {
allRoles = ((ActiveDirectoryGroupRealm) realm).getListRoles();
break;
} else if (realm instanceof KnoxJwtRealm) {
roles = ((KnoxJwtRealm) realm).mapGroupPrincipals(getPrincipal());
break;
}
}
if (allRoles != null) {
for (Map.Entry pair : allRoles.entrySet()) {
if (subject.hasRole(pair.getKey())) {
roles.add(pair.getKey());
}
}
}
}
return roles;
}
可以看出,这个方法只支持 INI_REALM
、LDAP_REALM
、ACTIVE_DIRECTORY_GROUP_REALM
、KnoxJwtRealm
,和之前一样没有支持 JDBC_Realm
,那自然也就没有办法鉴权了。
3.2.2 源码改造
按照之前的改造思路,我们依然可以:
让其支持对 JdbcRealm 的支持
/**
* Return the roles associated with the authenticated user if any otherwise returns empty set.
* TODO(prasadwagle) Find correct way to get user roles (see SHIRO-492)
*
* @return shiro roles
*/
@Override
public Set getAssociatedRoles() {
Subject subject = org.apache.shiro.SecurityUtils.getSubject();
Set roles = new HashSet();
Map allRoles = null;
if (subject.isAuthenticated()) {
Collection realmsList = getRealmsList();
for (Realm realm : realmsList) {
String name = realm.getClass().getName();
if (INI_REALM.equals(name)) {
allRoles = ((IniRealm) realm).getIni().get("roles");
break;
} else if (LDAP_REALM.equals(name)) {
try {
AuthorizationInfo auth =
((LdapRealm) realm)
.queryForAuthorizationInfo(
new SimplePrincipalCollection(subject.getPrincipal(), realm.getName()),
((LdapRealm) realm).getContextFactory());
if (auth != null) {
roles = new HashSet(auth.getRoles());
}
} catch (NamingException e) {
LOGGER.error("Can't fetch roles", e);
}
break;
} else if (ACTIVE_DIRECTORY_GROUP_REALM.equals(name)) {
allRoles = ((ActiveDirectoryGroupRealm) realm).getListRoles();
break;
} else if (realm instanceof KnoxJwtRealm) {
roles = ((KnoxJwtRealm) realm).mapGroupPrincipals(getPrincipal());
break;
} else if (JDBC_REALM.equals(name)) {
roles = getRolesByUser((JdbcRealm) realm, getPrincipal());
break;
}
}
if (allRoles != null) {
for (Map.Entry pair : allRoles.entrySet()) {
if (subject.hasRole(pair.getKey())) {
roles.add(pair.getKey());
}
}
}
}
return roles;
}
添加根据用户名获取角色列表的方法
private Set getRolesByUser(JdbcRealm realm, String principal) {
Set roleSet = new HashSet();
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
DataSource dataSource;
String userRolesQuery;
try {
dataSource = (DataSource) FieldUtils.readField(realm, "dataSource", true);
userRolesQuery = (String) FieldUtils.readField(realm, "userRolesQuery", true);
LOGGER.info(userRolesQuery);
if (StringUtils.isBlank(userRolesQuery)) {
return roleSet;
}
} catch (IllegalAccessException e) {
LOGGER.error("Error while accessing dataSource for JDBC Realm", e);
return new HashSet();
}
try {
con = dataSource.getConnection();
ps = con.prepareStatement(userRolesQuery);
// 占位符有且只有一个,是用户名
ps.setString(1, principal);
rs = ps.executeQuery();
while (rs.next()) {
roleSet.add(rs.getString(1).trim());
}
} catch (Exception e) {
LOGGER.error("Error retrieving user's Role list from JDBC Realm", e);
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(ps);
JdbcUtils.closeConnection(con);
}
return roleSet;
}
然后再次编译源码,替换 JAR 包,然后重启程序。再次访问,可以发现可以成功访问之前不能访问的笔记了。然后我们在设置一下不允许 user1 访问看看:
可以看到,权限已经正确了。
四、总结
可能因为角色SQL判断复杂,查询不通用的特性。导致没有将JDBC方式进行角色查询与校验的相关代码放入。
总之,遇到问题解决不了还是得看源码。使用开源软件,一定要会自己编译,不然后续需要改造代码,就没办法操作了。