前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《Apache Shiro 源码解析》- 12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统

《Apache Shiro 源码解析》- 12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统

原创
作者头像
大漠穷秋9527
修改2024-11-08 14:23:15
990
修改2024-11-08 14:23:15
举报
文章被收录于专栏:《Apache Shiro 源码解析》

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 框架中的基本组件,我们来设计系统的物理模型。为了获得一个更加真实的业务系统,这个设计中带有一个简单的业务场景:编写和发布文章。整体的物理模型如下图所示:

其中,橙色的表是与 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 来实现,整体代码结构如下:

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

代码语言:java
复制
//...

@Entity
@DynamicInsert
@DynamicUpdate
@Table(name = "nicefish_rbac_user")
public class UserEntity implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="user_id", updatable = false)
    private Integer userId;

    @Column(name="user_name",nullable = false,updatable = false)
    private String userName;

    @Column(name="nick_name",nullable = false)
    private String nickName;

    @Column(name="password",nullable = false)
    private String password;

    @Column(name="email")
    private String email;

    @Column(name="cellphone")
    private String cellphone;

    @Column(name="gender",columnDefinition = "int default 0")
    private Integer gender=0;

    @Column(name="city")
    private String city;

    @Column(name="education")
    private String education;

    @Temporal(TemporalType.TIMESTAMP)
    @Column(name="create_time",updatable = false)
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    @Column(name="avatar_url")
    private String avatarURL;

    @Column(name="salt")
    private String salt;

    @Column(name="status",columnDefinition = "int default 0")
    private Integer status=0;

    @Column(name="remark")
    private String remark;

    @JoinTable(
        name="nicefish_rbac_user_role",
        joinColumns={@JoinColumn(name="user_id",referencedColumnName="user_id")},
        inverseJoinColumns={@JoinColumn(name="role_id",referencedColumnName="role_id")}
    )
    @ManyToMany(fetch = FetchType.LAZY)
    private List<RoleEntity> roleEntities;

    //省略所有 getter 和 setter
}

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

代码语言:java
复制
//...

@JoinTable(
        name="nicefish_rbac_role_component",
        joinColumns={@JoinColumn(name="component_id",referencedColumnName="component_id")},
        inverseJoinColumns={@JoinColumn(name="role_id",referencedColumnName="role_id")}
)
@ManyToMany
@JsonSerialize(using = RoleListSerializer.class)
private List<RoleEntity> roleEntities;

//...

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

代码语言:java
复制
package com.nicefish.rbac.jpautils;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.nicefish.rbac.jpa.entity.RoleEntity;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class RoleListSerializer extends StdSerializer<List<RoleEntity>> {
    public RoleListSerializer() {
        this(null);
    }

    protected RoleListSerializer(Class<List<RoleEntity>> t) {
        super(t);
    }

    @Override
    public void serialize(List<RoleEntity> roleEntities, JsonGenerator generator, SerializerProvider provider) throws IOException {
        //注意这里,我们自己组装 Java 对象,避开转换成 JSON 字符串过程中的循环引用问题。
        List<Map> list = new ArrayList<>();
        for (RoleEntity roleEntity : roleEntities) {
            HashMap obj=new HashMap();
            obj.put("roleId",roleEntity.getRoleId());
            obj.put("roleName",roleEntity.getRoleName());
            obj.put("status",roleEntity.getStatus());
            obj.put("remark",roleEntity.getRemark());
            list.add(obj);
        }
        generator.writeObject(list);
    }
}

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

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

实现 Realm 和 SessionDAO

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

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

代码语言:java
复制
package com.nicefish.rbac.shiro.realm;

import com.nicefish.rbac.jpa.entity.UserEntity;
import com.nicefish.rbac.service.IUserService;
import com.nicefish.rbac.shiro.util.NiceFishSecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.Set;

/**
 * NiceFish 操作 MySQL 的 Realm 。
 * @author 大漠穷秋
 */
public class NiceFishMySQLRealm extends AuthorizingRealm {
    private static final Logger logger = LoggerFactory.getLogger(NiceFishMySQLRealm.class);

    @Autowired
    private IUserService userService;

    /**
     * 认证
     * TODO:这里仅实现了简单的用户名+密码的验证方式,需要扩展其它认证方式,例如:扫描二维码、第三方认证。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        String username = usernamePasswordToken.getUsername();
        String password = usernamePasswordToken.getPassword()!=null?new String(usernamePasswordToken.getPassword()):"";

        UserEntity userEntity;
        try {
            userEntity = userService.checkUser(username, password);
            logger.debug("UserName>"+username);
            logger.debug("Password>"+password);
        }catch (Exception e) {
            logger.error(username + "登录失败{}", e.getMessage());
            throw new AuthenticationException(e.getMessage(), e);
        }

        //用户认证成功,返回验证信息实例。
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userEntity, password, getName());
        return info;
    }

    /**
     * 授权
     * NiceFish 采用 Shiro 字符串形式的权限定义,权限不实现成 Java 类。
     * Shiro 权限字符串的匹配模式定义参考 https://shiro.apache.org/java-authorization-guide.html
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        Integer userId= NiceFishSecurityUtils.getUserId();

        //TODO:首先尝试从 Session 中获取角色和权限数据,加快授权操作的速度。
        //同时需要自己扩展 SessionListener 来同步 Session 数据。

        Set<String> permStrs=this.userService.getPermStringsByUserId(userId);
        logger.debug(permStrs.toString());

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setStringPermissions(permStrs);
        return info;
    }
}
代码语言:java
复制
package com.nicefish.rbac.shiro.session;

import com.nicefish.rbac.jpa.entity.NiceFishSessionEntity;
import com.nicefish.rbac.service.INiceFishSessionService;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.SimpleSession;
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.ObjectUtils;

import java.io.Serializable;
import java.util.Date;

/**
 * 扩展 Shiro 内置的 EnterpriseCacheSessionDAO ,操作 MySQL 中的 nicefish_rbac_session 表。
 *
 * 由于 EnterpriseCacheSessionDAO 实现了 CacheManagerAware 接口, Shiro 的 SecurityManager 会自动把
 * CacheManager 缓存实例注入到此类中,所以此类中可以直接操作 cacheManager 缓存实例。
 *
 * 此实现参考了 spring-session-jdbc 的实现,Session 中的所有 attributes 都会被提取出来并存储到 SESSION_DATA 列中,
 * 存储格式是 JSON 字符串。
 *
 * 此实现不会存储 Session 实例序列化之后的二进制数据,因为在跨业务模块共享 Session 时,如果 Session 中包含了
 * 某项目中特有的类,那么其它项目在反序列化时会因为找不到 Java 类而失败。
 *
 * @author 大漠穷秋
 */
public class NiceFishSessionDAO extends EnterpriseCacheSessionDAO {
    private static final Logger logger = LoggerFactory.getLogger(NiceFishSessionDAO.class);

    @Autowired
    private INiceFishSessionService sessionService;

    /**
     * 该方法参数中的 session 实例实际上是由 NiceFishSessionFactory.createSession 提供的。
     * 运行时调用轨迹:
     * SecurityManager -> SessionManager -> SessionFactory.createSession() -> EnterpriseCacheSessionDAO.doCreate(session)
     * @param session
     * @return
     */
    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = super.doCreate(session);

        NiceFishSessionEntity entity = new NiceFishSessionEntity();
        entity.setSessionId((String) sessionId);
        entity.setCreationTime(new Date());
        entity.setLastAccessTime(new Date());
        entity.setTimeout(session.getTimeout());

        //TODO:把用户对应的 Role 和 Permission 存储到 Session 中。

        this.sessionService.saveSession(entity);
        return sessionId;
    }

    /**
     * 从 MySQL 数据库中读取 Session ,父层实现会保证先读取缓存,然后再调用此方法。
     * @param sessionId
     * @return
     */
    @Override
    protected Session doReadSession(Serializable sessionId) {
        //把 entity 上的数据拷贝给 session 实例,TODO: 有更好的工具?
        NiceFishSessionEntity entity = sessionService.findDistinctBySessionId(sessionId.toString());
        if(ObjectUtils.isEmpty(entity)){
            return null;
        }

        SimpleSession session=new SimpleSession();
        session.setId(entity.getSessionId());
        session.setTimeout(entity.getTimeout());
        session.setStartTimestamp(entity.getCreationTime());
        session.setLastAccessTime(entity.getLastAccessTime());
        session.setHost(entity.getHost());
        session.setAttribute("appName",entity.getAppName());
        session.setAttribute("userId",entity.getUserId());
        session.setAttribute("userName",entity.getUserName());
        session.setAttribute("exprityTime",entity.getExpiryTime());
        session.setAttribute("maxInactiveInterval",entity.getMaxInactiveInteval());
        session.setExpired(entity.isExpired());
        session.setAttribute("os",entity.getOs());
        session.setAttribute("browser",entity.getBrowser());
        session.setAttribute("userAgent",entity.getUserAgent());
        session.setAttribute("sessionData",entity.getSessionData());
        return session;
    }

    /**
     * 把 Session 更新到 MySQL 数据库,父层实现会保证先更新缓存,然后再调用此方法。
     * 把 SimpleSession 上的数据拷贝给 entity ,然后借助于 entity 更新数据库记录。
     * TODO: 有更好的工具?
     * @param session 类型实际上是 Shiro 的 SimpleSession
     */
    @Override
    protected void doUpdate(Session session) {
        logger.debug("update session..."+session.toString());

        SimpleSession simpleSession=(SimpleSession)session;//Shiro 顶级 Session 接口中没有定义 isExpired() 方法,这里强转成 SimpleSession
        String sessionId=(String)simpleSession.getId();
        NiceFishSessionEntity entity=this.sessionService.findDistinctBySessionId(sessionId);
        if(ObjectUtils.isEmpty(entity)){
            entity=new NiceFishSessionEntity();
            entity.setSessionId((String)simpleSession.getId());
        }
        entity.setHost(simpleSession.getHost());
        entity.setCreationTime(simpleSession.getStartTimestamp());
        entity.setLastAccessTime(simpleSession.getLastAccessTime());
        entity.setTimeout(simpleSession.getTimeout());
        entity.setExpired(simpleSession.isExpired());
        entity.setAppName((String)simpleSession.getAttribute("appName"));
        entity.setUserId((Integer)simpleSession.getAttribute("userId"));
        entity.setUserName((String)simpleSession.getAttribute("userName"));
        entity.setExpiryTime((Date)simpleSession.getAttribute("exprityTime"));
        entity.setMaxInactiveInteval((Integer)simpleSession.getAttribute("maxInactiveInterval"));
        entity.setOs((String)simpleSession.getAttribute("os"));
        entity.setBrowser((String)simpleSession.getAttribute("browser"));
        entity.setUserAgent((String)simpleSession.getAttribute("userAgent"));
        entity.setSessionData((String)simpleSession.getAttribute("sessionData"));
        this.sessionService.saveSession(entity);
    }

    /**
     * 把 Session 从 MySQL 数据库中删除,父层实现会保证先删除缓存,然后再调用此方法。
     * NiceFish 不进行物理删除,仅仅把标志位设置成过期状态。
     * @param session 类型实际上是 Shiro 的 SimpleSession
     */
    @Override
    protected void doDelete(Session session) {
        logger.debug("delete session..."+session.toString());

        NiceFishSessionEntity entity=this.sessionService.findDistinctBySessionId((String)session.getId());
        entity.setExpired(true);
        this.sessionService.saveSession(entity);
    }
}

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

代码语言:java
复制
@Configuration
public class ShiroConfig {
  //...

  @Bean
  public NiceFishMySQLRealm nicefishRbacRealm() {
      NiceFishMySQLRealm niceFishMySQLRealm = new NiceFishMySQLRealm();
      niceFishMySQLRealm.setCachingEnabled(true);
      niceFishMySQLRealm.setAuthenticationCachingEnabled(true);
      niceFishMySQLRealm.setAuthenticationCacheName("authenticationCache");
      niceFishMySQLRealm.setAuthorizationCachingEnabled(true);
      niceFishMySQLRealm.setAuthorizationCacheName("authorizationCache");
      return niceFishMySQLRealm;
  }

  /**
   * 创建自定义的 NiceFishSessionDAO 实例
   * @return
   */
  @Bean
  public NiceFishSessionDAO sessionDAO() {
      NiceFishSessionDAO nfSessionDAO = new NiceFishSessionDAO();
      nfSessionDAO.setActiveSessionsCacheName("shiro-activeSessionCache");
      return nfSessionDAO;
  }

  /**
   * 创建自定义的 NiceFishSessionFactory 实例
   * @return NiceFishSessionFactory
   */
  @Bean
  public NiceFishSessionFactory sessionFactory() {
      NiceFishSessionFactory nfSessionFactory = new NiceFishSessionFactory();
      return nfSessionFactory;
  }

  //...
}

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

服务端 API 权限控制

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

前端页面组件的权限控制

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

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

最终效果与开源项目

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

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

本章小结

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

资源链接

版权声明

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
作者已关闭评论
0 条评论
热度
最新
推荐阅读
目录
  • 12.整合实战:基于 Shiro 框架的 RBAC 权限控制系统
    • RBAC 的基本概念
      • 设计物理模型
        • 实现 Entity 、 DAO、 Service 与 Controller
          • 实现 Realm 和 SessionDAO
            • 服务端 API 权限控制
              • 前端页面组件的权限控制
                • 最终效果与开源项目
                  • 本章小结
                    • 资源链接
                      • 版权声明
                      相关产品与服务
                      云数据库 MySQL
                      腾讯云数据库 MySQL(TencentDB for MySQL)为用户提供安全可靠,性能卓越、易于维护的企业级云数据库服务。其具备6大企业级特性,包括企业级定制内核、企业级高可用、企业级高可靠、企业级安全、企业级扩展以及企业级智能运维。通过使用腾讯云数据库 MySQL,可实现分钟级别的数据库部署、弹性扩展以及全自动化的运维管理,不仅经济实惠,而且稳定可靠,易于运维。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档