解决Zeppelin使用JdbcRealm角色查询与鉴权不生效问题

2023年 7月 31日 47.3k 0

先说结论:可能因为角色SQL判断复杂,查询不通用的特性。导致没有将JDBC方式进行角色查询与校验的相关代码放入。

一、背景

最近在调研 zeppelin(0.10.1 版本),在测试鉴权这一块的时候发现了问题(主要是在 zeppelin 开启 Shiro 后,配置完 JdbcRealm 。发现 zeppelin 的角色权限控制好像不生效。),特此记录一下发现问题以及解决方式。

1.1 数据库表

我使用了PG库存储用户表和角色表,因为是测试功能,因此表很简单:

image-20230728041733487

用户表(users)

image-20230728041200853

用户角色表(user_roles)

不要问我为什么没有角色表,因为一切从简。当然,如果有角色表,后面修改源码的地方也可以用到。

image-20230728041706201

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&currentSchema=
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 中设置权限时,只能搜索到用户,搜不到角色。

    image-20230728042654275

  • 权限控制的时候,再报错信息中发现当前用户只显示了用户名,而没有显示角色。

  • image-20230728040331592

    二、问题排查

    一开始我以为是配置文件中关于查询角色的SQL有问题,后面发现SQL可以执行。然后搜索了一圈都没什么发现。

    然道别人都不用这个方式认证吗?我是忍不了直接把账号、角色信息写在 ini 中,然后每次更新都要重启。LDAP方式因为对其了解不多,觉得后续维护不过来,可能有安全风险等。最终选择 JdbcRealm 来作为用户认证鉴权的方式,毕竟还想直接用其他业务系统的用户表。

    接着又打开浏览器 F12,发现返回的只有用户名没有角色:

    image-20230728043516017

    修改日志等级为 DEBUG,也没有看到什么报错和不合理的地方。

    然后我又把认证方式改回默认的文件配置方式,结果发现,角色生效了。

    没办法,只能看源码了。

    三、源码研究

    3.1 搜索角色失败

    3.1.1 源码排查过程

    通过 API 请求 URL:

    image-20230728044129754

    找到了请求方法入口:

    /**
     * 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 实现:

    image-20230728024845796

    对接口方法进行查看,这里仅展示关键方法:

    /**
     * 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 字段作为字段名
  • 最后拼接成查询SQL: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_REALMLDAP_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 的支持

    image-20230728031849419

    /**
     * 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,经过测试不影响,直接重命名后替换掉),然后重启程序。再次输入,可以发现已经可以搜索到角色了。

    image-20230728045437134

    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_REALMLDAP_REALMACTIVE_DIRECTORY_GROUP_REALMKnoxJwtRealm,和之前一样没有支持 JDBC_Realm ,那自然也就没有办法鉴权了。

    3.2.2 源码改造

    按照之前的改造思路,我们依然可以:

  • 让其支持对 JdbcRealm 的支持

    image-20230728051338960

    /**
     * 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 访问看看:

    image-20230728053858642

    可以看到,权限已经正确了。

    四、总结

    可能因为角色SQL判断复杂,查询不通用的特性。导致没有将JDBC方式进行角色查询与校验的相关代码放入。

    总之,遇到问题解决不了还是得看源码。使用开源软件,一定要会自己编译,不然后续需要改造代码,就没办法操作了。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论