12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图1

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统

在前面的章节中,我们已经详细分析了 Shiro 的架构和源码。在本章中,我们将会用 Shiro 框架来实现一个完整的 RBAC 权限控制系统。这个系统的整体功能是:让用户可以自定义服务端 API 的权限和前端页面组件的权限。

以下是本章的内容结构:

  • RBAC 的基本概念
  • 设计物理模型
  • 实现 Entity 、 DAO、 Service 与 Controller
  • 实现 Realm 和 SessionDAO
  • 服务端 API 权限控制
  • 前端页面组件的权限控制
  • 最终效果与开源项目

RBAC 的基本概念

RBAC(Role-Based Access Control)是一种权限管理模型,这种设计思想起源于 20 世纪 70 年代,但直到 1992 年 才由 David Ferraiolo 和 Richard Kuhn 在他们的研究论文中正式提出并加以推广。

2001 年,RBAC 被美国国家标准与技术研究院(NIST)标准化,成为一种公认的访问控制模型。经过几十年的发展,RBAC 已广泛应用于企业级系统和信息安全中。

RBAC 模型的核心思想是:用户与角色关联,角色与权限关联,通过角色间接管理用户的权限。这种模型允许管理员通过管理角色而非单个用户权限,来实现更有效的权限控制。

  1. 用户(User):系统中的个人或实体,可以是实际的人或自动化系统,在 Shiro 中的概念是 Subject 。
  2. 角色(Role):一组权限的集合。角色被分配给用户,用户通过角色来获得相应的权限,在 Shiro 中也叫 Role 。
  3. 权限(Permission):对系统资源的访问控制标识,定义了用户能够执行的操作,在 Shiro 中一般会被定义成通配符权限表达式。
  4. 资源(Resource):系统中需要被保护的对象,如数据库、文件系统、网页等,在 Shiro 中会被定义成 Realm 。

设计物理模型

基于 RBAC 的概念,以及 Shiro 框架中的基本组件,我们来设计系统的物理模型。为了获得一个更加真实的业务系统,这个设计中带有一个简单的业务场景:编写和发布文章。整体的物理模型如下图所示:

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图2

其中,橙色的表是与 RBAC 相关的核心表,关键的几组关系如下:

  • nicefish_rbac_user 表与 nicefish_rbac_role 表是多对多关系,通过 nicefish_rbac_user_role 这张中间表进行关联。
  • nicefish_rbac_role 表与资源权限表进行关联,也是多对多的关系。
  • 这个系统中有两种资源需要进行保护:服务端 API 、前端页面组件。于是我们定义两张权限表: nicefish_rbac_api(定义服务端接口权限表达式) 与 nicefish_rbac_component(定义前端页面组件的权限表达式)。
  • nicefish_rbac_session 表用来持久化会话。

在设计完物理模型之后,可以导出建库脚本,本章描述的内容和示例代码都已经在 MySQL(MariaDB) 数据库上跑通,如果读者需要支持其它数据库,需要自己测试兼容性。

实现 Entity 、 DAO、 Service 与 Controller

在设计完物理模型之后,我们就很自然地获得了相关的 Entity 和 DAO ,我们基于 JPA 来实现,整体代码结构如下:

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图3

这些都是 JPA 的基本内容,没有什么特殊的写法,这里仅仅展示 UserEntity 的大致代码:

  1. //...
  2. @Entity
  3. @DynamicInsert
  4. @DynamicUpdate
  5. @Table(name = "nicefish_rbac_user")
  6. public class UserEntity implements Serializable {
  7. @Id
  8. @GeneratedValue(strategy = GenerationType.IDENTITY)
  9. @Column(name="user_id", updatable = false)
  10. private Integer userId;
  11. @Column(name="user_name",nullable = false,updatable = false)
  12. private String userName;
  13. @Column(name="nick_name",nullable = false)
  14. private String nickName;
  15. @Column(name="password",nullable = false)
  16. private String password;
  17. @Column(name="email")
  18. private String email;
  19. @Column(name="cellphone")
  20. private String cellphone;
  21. @Column(name="gender",columnDefinition = "int default 0")
  22. private Integer gender=0;
  23. @Column(name="city")
  24. private String city;
  25. @Column(name="education")
  26. private String education;
  27. @Temporal(TemporalType.TIMESTAMP)
  28. @Column(name="create_time",updatable = false)
  29. @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
  30. private Date createTime;
  31. @Column(name="avatar_url")
  32. private String avatarURL;
  33. @Column(name="salt")
  34. private String salt;
  35. @Column(name="status",columnDefinition = "int default 0")
  36. private Integer status=0;
  37. @Column(name="remark")
  38. private String remark;
  39. @JoinTable(
  40. name="nicefish_rbac_user_role",
  41. joinColumns={@JoinColumn(name="user_id",referencedColumnName="user_id")},
  42. inverseJoinColumns={@JoinColumn(name="role_id",referencedColumnName="role_id")}
  43. )
  44. @ManyToMany(fetch = FetchType.LAZY)
  45. private List<RoleEntity> roleEntities;
  46. //省略所有 getter 和 setter
  47. }

在以上代码中, @ManyToMany 是一个关键的处理,在 OO 模型中, UserEntity 和 RoleEntity 互相持有对方的实例,所以这里必须加上 fetch = FetchType.LAZY ,否则在把查询到的 Java 对象转换成 JSON 字符串的时候会出现循环依赖异常。除了采用懒加载之外,开发者还可以定义自己的序列化类,来避免这种循环依赖问题,示例代码如下:

  1. //...
  2. @JoinTable(
  3. name="nicefish_rbac_role_component",
  4. joinColumns={@JoinColumn(name="component_id",referencedColumnName="component_id")},
  5. inverseJoinColumns={@JoinColumn(name="role_id",referencedColumnName="role_id")}
  6. )
  7. @ManyToMany
  8. @JsonSerialize(using = RoleListSerializer.class)
  9. private List<RoleEntity> roleEntities;
  10. //...

以上代码中的 RoleListSerializer 是我们自己编写的序列化工具类,它的逻辑如下:

  1. package com.nicefish.rbac.jpautils;
  2. import com.fasterxml.jackson.core.JsonGenerator;
  3. import com.fasterxml.jackson.databind.SerializerProvider;
  4. import com.fasterxml.jackson.databind.ser.std.StdSerializer;
  5. import com.nicefish.rbac.jpa.entity.RoleEntity;
  6. import java.io.IOException;
  7. import java.util.ArrayList;
  8. import java.util.HashMap;
  9. import java.util.List;
  10. import java.util.Map;
  11. public class RoleListSerializer extends StdSerializer<List<RoleEntity>> {
  12. public RoleListSerializer() {
  13. this(null);
  14. }
  15. protected RoleListSerializer(Class<List<RoleEntity>> t) {
  16. super(t);
  17. }
  18. @Override
  19. public void serialize(List<RoleEntity> roleEntities, JsonGenerator generator, SerializerProvider provider) throws IOException {
  20. //注意这里,我们自己组装 Java 对象,避开转换成 JSON 字符串过程中的循环引用问题。
  21. List<Map> list = new ArrayList<>();
  22. for (RoleEntity roleEntity : roleEntities) {
  23. HashMap obj=new HashMap();
  24. obj.put("roleId",roleEntity.getRoleId());
  25. obj.put("roleName",roleEntity.getRoleName());
  26. obj.put("status",roleEntity.getStatus());
  27. obj.put("remark",roleEntity.getRemark());
  28. list.add(obj);
  29. }
  30. generator.writeObject(list);
  31. }
  32. }

相关的 Repository 是标准的 JPA 注解写法,这里不再展示代码。

编写出 Entity 和 DAO 之后,我们可以很自然地编写出对应的 Service 和 Controller ,例如:根据 userId 查询对应的角色列表、根据 userId 查询对应的权限列表、根据 userId 给用户赋予新的权限表达式,等等。这些都是普通的业务逻辑,基本上都是体力活,这里不再展示代码。

实现 Realm 和 SessionDAO

接下来,按照 Shiro 框架的架构,我们需要实现自己的 Realm 和 SessionDAO ,然后在 ShiroConfig.java 中进行配置。

在这个项目中,我们定义了 NiceFishMySQLRealm 和 NiceFishSessionDAO,它们的代码比较简短,完整展示如下:

  1. package com.nicefish.rbac.shiro.realm;
  2. import com.nicefish.rbac.jpa.entity.UserEntity;
  3. import com.nicefish.rbac.service.IUserService;
  4. import com.nicefish.rbac.shiro.util.NiceFishSecurityUtils;
  5. import org.apache.shiro.authc.*;
  6. import org.apache.shiro.authz.AuthorizationInfo;
  7. import org.apache.shiro.authz.SimpleAuthorizationInfo;
  8. import org.apache.shiro.realm.AuthorizingRealm;
  9. import org.apache.shiro.subject.PrincipalCollection;
  10. import org.slf4j.Logger;
  11. import org.slf4j.LoggerFactory;
  12. import org.springframework.beans.factory.annotation.Autowired;
  13. import java.util.Set;
  14. /**
  15. * NiceFish 操作 MySQL 的 Realm 。
  16. * @author 大漠穷秋
  17. */
  18. public class NiceFishMySQLRealm extends AuthorizingRealm {
  19. private static final Logger logger = LoggerFactory.getLogger(NiceFishMySQLRealm.class);
  20. @Autowired
  21. private IUserService userService;
  22. /**
  23. * 认证
  24. * TODO:这里仅实现了简单的用户名+密码的验证方式,需要扩展其它认证方式,例如:扫描二维码、第三方认证。
  25. */
  26. @Override
  27. protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
  28. UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
  29. String username = usernamePasswordToken.getUsername();
  30. String password = usernamePasswordToken.getPassword()!=null?new String(usernamePasswordToken.getPassword()):"";
  31. UserEntity userEntity;
  32. try {
  33. userEntity = userService.checkUser(username, password);
  34. logger.debug("UserName>"+username);
  35. logger.debug("Password>"+password);
  36. }catch (Exception e) {
  37. logger.error(username + "登录失败{}", e.getMessage());
  38. throw new AuthenticationException(e.getMessage(), e);
  39. }
  40. //用户认证成功,返回验证信息实例。
  41. SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userEntity, password, getName());
  42. return info;
  43. }
  44. /**
  45. * 授权
  46. * NiceFish 采用 Shiro 字符串形式的权限定义,权限不实现成 Java 类。
  47. * Shiro 权限字符串的匹配模式定义参考 https://shiro.apache.org/java-authorization-guide.html
  48. */
  49. @Override
  50. protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
  51. Integer userId= NiceFishSecurityUtils.getUserId();
  52. //TODO:首先尝试从 Session 中获取角色和权限数据,加快授权操作的速度。
  53. //同时需要自己扩展 SessionListener 来同步 Session 数据。
  54. Set<String> permStrs=this.userService.getPermStringsByUserId(userId);
  55. logger.debug(permStrs.toString());
  56. SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
  57. info.setStringPermissions(permStrs);
  58. return info;
  59. }
  60. }
  1. package com.nicefish.rbac.shiro.session;
  2. import com.nicefish.rbac.jpa.entity.NiceFishSessionEntity;
  3. import com.nicefish.rbac.service.INiceFishSessionService;
  4. import org.apache.shiro.session.Session;
  5. import org.apache.shiro.session.mgt.SimpleSession;
  6. import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
  7. import org.slf4j.Logger;
  8. import org.slf4j.LoggerFactory;
  9. import org.springframework.beans.factory.annotation.Autowired;
  10. import org.springframework.util.ObjectUtils;
  11. import java.io.Serializable;
  12. import java.util.Date;
  13. /**
  14. * 扩展 Shiro 内置的 EnterpriseCacheSessionDAO ,操作 MySQL 中的 nicefish_rbac_session 表。
  15. *
  16. * 由于 EnterpriseCacheSessionDAO 实现了 CacheManagerAware 接口, Shiro 的 SecurityManager 会自动把
  17. * CacheManager 缓存实例注入到此类中,所以此类中可以直接操作 cacheManager 缓存实例。
  18. *
  19. * 此实现参考了 spring-session-jdbc 的实现,Session 中的所有 attributes 都会被提取出来并存储到 SESSION_DATA 列中,
  20. * 存储格式是 JSON 字符串。
  21. *
  22. * 此实现不会存储 Session 实例序列化之后的二进制数据,因为在跨业务模块共享 Session 时,如果 Session 中包含了
  23. * 某项目中特有的类,那么其它项目在反序列化时会因为找不到 Java 类而失败。
  24. *
  25. * @author 大漠穷秋
  26. */
  27. public class NiceFishSessionDAO extends EnterpriseCacheSessionDAO {
  28. private static final Logger logger = LoggerFactory.getLogger(NiceFishSessionDAO.class);
  29. @Autowired
  30. private INiceFishSessionService sessionService;
  31. /**
  32. * 该方法参数中的 session 实例实际上是由 NiceFishSessionFactory.createSession 提供的。
  33. * 运行时调用轨迹:
  34. * SecurityManager -> SessionManager -> SessionFactory.createSession() -> EnterpriseCacheSessionDAO.doCreate(session)
  35. * @param session
  36. * @return
  37. */
  38. @Override
  39. protected Serializable doCreate(Session session) {
  40. Serializable sessionId = super.doCreate(session);
  41. NiceFishSessionEntity entity = new NiceFishSessionEntity();
  42. entity.setSessionId((String) sessionId);
  43. entity.setCreationTime(new Date());
  44. entity.setLastAccessTime(new Date());
  45. entity.setTimeout(session.getTimeout());
  46. //TODO:把用户对应的 Role 和 Permission 存储到 Session 中。
  47. this.sessionService.saveSession(entity);
  48. return sessionId;
  49. }
  50. /**
  51. * 从 MySQL 数据库中读取 Session ,父层实现会保证先读取缓存,然后再调用此方法。
  52. * @param sessionId
  53. * @return
  54. */
  55. @Override
  56. protected Session doReadSession(Serializable sessionId) {
  57. //把 entity 上的数据拷贝给 session 实例,TODO: 有更好的工具?
  58. NiceFishSessionEntity entity = sessionService.findDistinctBySessionId(sessionId.toString());
  59. if(ObjectUtils.isEmpty(entity)){
  60. return null;
  61. }
  62. SimpleSession session=new SimpleSession();
  63. session.setId(entity.getSessionId());
  64. session.setTimeout(entity.getTimeout());
  65. session.setStartTimestamp(entity.getCreationTime());
  66. session.setLastAccessTime(entity.getLastAccessTime());
  67. session.setHost(entity.getHost());
  68. session.setAttribute("appName",entity.getAppName());
  69. session.setAttribute("userId",entity.getUserId());
  70. session.setAttribute("userName",entity.getUserName());
  71. session.setAttribute("exprityTime",entity.getExpiryTime());
  72. session.setAttribute("maxInactiveInterval",entity.getMaxInactiveInteval());
  73. session.setExpired(entity.isExpired());
  74. session.setAttribute("os",entity.getOs());
  75. session.setAttribute("browser",entity.getBrowser());
  76. session.setAttribute("userAgent",entity.getUserAgent());
  77. session.setAttribute("sessionData",entity.getSessionData());
  78. return session;
  79. }
  80. /**
  81. * 把 Session 更新到 MySQL 数据库,父层实现会保证先更新缓存,然后再调用此方法。
  82. * 把 SimpleSession 上的数据拷贝给 entity ,然后借助于 entity 更新数据库记录。
  83. * TODO: 有更好的工具?
  84. * @param session 类型实际上是 Shiro 的 SimpleSession
  85. */
  86. @Override
  87. protected void doUpdate(Session session) {
  88. logger.debug("update session..."+session.toString());
  89. SimpleSession simpleSession=(SimpleSession)session;//Shiro 顶级 Session 接口中没有定义 isExpired() 方法,这里强转成 SimpleSession
  90. String sessionId=(String)simpleSession.getId();
  91. NiceFishSessionEntity entity=this.sessionService.findDistinctBySessionId(sessionId);
  92. if(ObjectUtils.isEmpty(entity)){
  93. entity=new NiceFishSessionEntity();
  94. entity.setSessionId((String)simpleSession.getId());
  95. }
  96. entity.setHost(simpleSession.getHost());
  97. entity.setCreationTime(simpleSession.getStartTimestamp());
  98. entity.setLastAccessTime(simpleSession.getLastAccessTime());
  99. entity.setTimeout(simpleSession.getTimeout());
  100. entity.setExpired(simpleSession.isExpired());
  101. entity.setAppName((String)simpleSession.getAttribute("appName"));
  102. entity.setUserId((Integer)simpleSession.getAttribute("userId"));
  103. entity.setUserName((String)simpleSession.getAttribute("userName"));
  104. entity.setExpiryTime((Date)simpleSession.getAttribute("exprityTime"));
  105. entity.setMaxInactiveInteval((Integer)simpleSession.getAttribute("maxInactiveInterval"));
  106. entity.setOs((String)simpleSession.getAttribute("os"));
  107. entity.setBrowser((String)simpleSession.getAttribute("browser"));
  108. entity.setUserAgent((String)simpleSession.getAttribute("userAgent"));
  109. entity.setSessionData((String)simpleSession.getAttribute("sessionData"));
  110. this.sessionService.saveSession(entity);
  111. }
  112. /**
  113. * 把 Session 从 MySQL 数据库中删除,父层实现会保证先删除缓存,然后再调用此方法。
  114. * NiceFish 不进行物理删除,仅仅把标志位设置成过期状态。
  115. * @param session 类型实际上是 Shiro 的 SimpleSession
  116. */
  117. @Override
  118. protected void doDelete(Session session) {
  119. logger.debug("delete session..."+session.toString());
  120. NiceFishSessionEntity entity=this.sessionService.findDistinctBySessionId((String)session.getId());
  121. entity.setExpired(true);
  122. this.sessionService.saveSession(entity);
  123. }
  124. }

然后我们在 ShiroConfig.java 中进行配置,关键代码如下:

  1. @Configuration
  2. public class ShiroConfig {
  3. //...
  4. @Bean
  5. public NiceFishMySQLRealm nicefishRbacRealm() {
  6. NiceFishMySQLRealm niceFishMySQLRealm = new NiceFishMySQLRealm();
  7. niceFishMySQLRealm.setCachingEnabled(true);
  8. niceFishMySQLRealm.setAuthenticationCachingEnabled(true);
  9. niceFishMySQLRealm.setAuthenticationCacheName("authenticationCache");
  10. niceFishMySQLRealm.setAuthorizationCachingEnabled(true);
  11. niceFishMySQLRealm.setAuthorizationCacheName("authorizationCache");
  12. return niceFishMySQLRealm;
  13. }
  14. /**
  15. * 创建自定义的 NiceFishSessionDAO 实例
  16. * @return
  17. */
  18. @Bean
  19. public NiceFishSessionDAO sessionDAO() {
  20. NiceFishSessionDAO nfSessionDAO = new NiceFishSessionDAO();
  21. nfSessionDAO.setActiveSessionsCacheName("shiro-activeSessionCache");
  22. return nfSessionDAO;
  23. }
  24. /**
  25. * 创建自定义的 NiceFishSessionFactory 实例
  26. * @return NiceFishSessionFactory
  27. */
  28. @Bean
  29. public NiceFishSessionFactory sessionFactory() {
  30. NiceFishSessionFactory nfSessionFactory = new NiceFishSessionFactory();
  31. return nfSessionFactory;
  32. }
  33. //...
  34. }

这些代码都是 Shiro 框架的基本用法,相关的机制和原理在前面的章节中都已经解释过,这里不再赘述。

服务端 API 权限控制

我们用 nicefish_rbac_api 表来维护服务端 API 的权限,以下是一组测试数据供参考:

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图4

前端页面组件的权限控制

类似地,我们用 nicefish_rbac_component 表来维护前端页面组件的权限,以下是一组测试数据:

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图5

前端组件稍有不同:页面可能会带有层级结构,所以我们用 p_id 来构建 tree 形数据结构;另外,前端组件在屏幕上显示的时候可能会有顺序要求,所以多了一个 display_order 列,用来定义组件在屏幕上的排列顺序。

最终效果与开源项目

最终,我们就获得了一个完整的项目,可以同时管理服务端 API 和前端页面组件的权限,以下是系统截图:

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图6

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图7

12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统 - 图8

项目完整的源代码位于 https://gitee.com/mumu-osc/nicefish-spring-boot ,在项目的 README 文档中包含了完整的启动步骤。

本章小结

在掌握了 Shiro 的架构,并且通读了它的源代码之后,在这一章中,我们通过一个实际的项目进行了实战,希望本文对你理解和实现 Shiro 的 RBAC 权限控制系统有所帮助。

资源链接

版权声明

本书基于 CC BY-NC-ND 4.0 许可协议发布,自由转载-非商用-非衍生-保持署名。

版权归大漠穷秋所有 © 2024 ,侵权必究。