Shiro-授权(RBAC)

5,602 阅读14分钟

0. 前言

[Shiro-认证]中讲解了如何使用Shiro实现登录后访问URL, 对于大部分系统来说, 登录只是安全的第一道屏障, 系统中的某些页面需要登录后访问, 而有些是需要有特定权限才可以访问, 比如删除, 冻结, 查看账号收益等敏感的操作.

本文将带你实现基于Shiro的权限控制, Shiro中叫做授权

1. 什么是权限

系统中有A,B,C三个用户, 其中A用户是管理员, B和C是普通用户. 系统中的所有删除操作必须由管理员账号登录才能完成. 普通用户是无法删除数据甚至连删除按钮都看不见. 我们说A,B,C三个用户在系统中有不同的权限. A有删除数据的权限, B和C没有删除数据的权限. 试想一下如果没有权限设计, 所有用户都可以删除数据, 假设B是新手不小心误操作删除了数据... 后果将不堪设想.

又例如银行的金库, 如果没有权限控制所有人都可以刷卡进入, 那岂不是要乱套. 生活中权限无处不在: 进出小区刷卡, 电梯刷卡到指定楼层, 视频网站中会员不需要看广告, 这些都是权限.

2. 权限设计方案

假如你做了一个交友网站, 里面有查看异性的基本信息, 查看微信, 查看电话, 查看家庭住址几个功能, 普通的用户只能查看基本信息, 不能查看联系方式等. 充值100元可以查看微信, 充值200元可以查看电话, 充值500元可以查看家庭住址.

你必须要做权限控制, 否则用户通过其他手段(比如知道URL)就可以查看联系方式, 也就没有人给你付费了. 最初, 你可能想到这么处理权限: 用一张数据表记录每个用户可以做什么事. 当用户查看微信时找到登录用户的权限判断是否可以查看微信.

用户 基本信息 查看微信 查看电话 查看住址
张三
李四
王五

随着时间的增加会员越来越多, 有一天你新加了一个功能: 查看对方视频介绍, 只有充值500的人才能查看. 于是你需要把上表中所有用户的权限都修改一遍. 如果有几十万会员, 可能你就会累到吐血....

聪明的你想到了一个办法, 设置会员等级, 充值100为普通会员, 充值200元为VIP, 充值500为VIP中P. 给每一个会员设置会员等级. 此时你的数据表结构如下:

  • 会员等级-权限
会员等级 基本信息 查看微信 查看电话 查看住址 查看视频
充值100元: 初级会员
充值200元: VIP
充值500元: VIP中P
  • 会员-会员等级
用户名 会员等级
张三 普通会员
李四 VIP
王五 VIP中P
赵六 VIP中P

这时, 当用户查看微信时, 根据用户找到会员等级, 在找到对应的权限. 虽然多了一步操作, 但:

  • 新加入功能时, 只需要对会员等级设置相应的权限即可. 不需要对用户进行权限设置
  • 用户会员等级升级时, 修改用户的会员等级即可. 不需要额外修改会员的权限
  • 会员等级的权限需要发生变化时, 只需要修改会员等级对应的权限, 对会员没有影响

总之, 权限只针对会员等级, 和会员并无直接关联. 这里的会员等级就相当于系统中的角色, 基于角色的权限方案被很多系统所采用, 有了一个专有名词: RBAC-基于角色的权限访问控制.

通俗的说就是根据用户的角色来判断是否有权限访问某个资源或URL. RBAC的模型是经典的5张表:

  • 用户: 记录系统的用户信息, 登录时使用. 例: 张三, 李四...
  • 角色: 记录系统中存在的角色. 例: 普通会员, VIP...
  • 资源: 记录系统中的有哪些可以做的事. 在WEB中对应的就是URL, 例: 查看资料URL, 查看微信URL, 查看电话....
  • 用户角色关系: 记录用户所属的角色, 例: 张三是普通会员, 李四是VIP...
  • 角色资源关系: 记录了某个角色可以做什么事, 例: VIP可以查看资料, 查看微信...

系统预先设计好角色, 资源, 角色资源关系. 当新建用户时只需要添加用户角色关系即可实现对该用户的权限控制. 例如: 孙七注册了用户并充值200元, 我们可以直接设置孙七为VIP, 通过孙七的角色VIP就可以从角色资源关系中找到对应的可操作的URL.

3. Shiro中实现RBAC

3.0 内置过滤器

本文中的操作是基于[Shiro-认证]之上完成的, 建议先看完Shiro认证部分. Shiro的认证是通过内置的认证过滤器(authc)完成的, 同时也提供了一些授权相关的过滤器:

3.0.1 端口过滤器: port

访问的端口不是定义的端口时重定向至定义的端口,对应类为org.apache.shiro.web.filter.authz.PortFilter

filterChainDefinitionMap = [
    "/**" : "port[9090]" // 如果不是通过9090端口将会重定向至9090端口访问
]

访问http://localhost:8080/user/list, 端口为8080, 该请求被port过滤器拦截, 重定向至9090端口, 即http://localhost:9090/user/list, port过滤器适用于项目端口变更期间兼容原有用户访问或将老版本系统自动切换到新版本(8080部署老版本, 9090部署新版本)

3.0.2 SSL过滤器: ssl

非https访问443端口时, 重定向使用https访问443端口. 对应类为org.apache.shiro.web.filter.authz.SslFilter

filterChainDefinitionMap = [
    "/**" : "ssl" // 不可以设置端口号,非https访问443端口会被重定向以https方式访问443端口
]

访问http://localhost:456/user/list, 由于http方式访问456端口, 该请求被port过滤器拦截重定向至https://localhost/user/list(80,443端口默认不显示), 适用于新增SSL证书后需要https访问, 兼容原有使用http访问的用户.

3.0.3 角色过滤器: roles

用户必须具有配置的角色才可以访问. Shiro会调用Realm中查询授权信息的方法获取用户的角色. 对应类为org.apache.shiro.web.filter.authz.RolesAuthorizationFilter

filterChainDefinitionMap = [
    "/**" : "roles['admin,guest']" // 访问用户必须同时具备admin和guest角色才可以访问
]

如配置成roles["admin"]代表只要是admin角色就可以访问, 两个及以上角色代表必须同时满足.

3.0.4 权限过滤器: perms

filterChainDefinitionMap = [
    "/user/add" : "perms['user:add']" // 访问用户必须拥有user模块的add权限
]

用户必须具有配置的权限才可以访问, Shiro会调用Realm中查询授权信息的方法查询用户的权限. 对应类为org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter

user:add代表user模块的add权限, 权限设计可以按模块划分并细化到模块的每个功能点, 比如用户(user)模块中Admin角色有添加(add)用户权限, 删除(delete)用户权限, 数据库中可存储Admin拥有的权限为user:add, user:delete, 当访问/user/add请求时, Shiro会通过Realm获取对应的权限, 如果含有user:add即可访问该请求, 没有该权限禁止访问.

如shiro中只配置到模块级别可以使用user:*进行通配符验证. perms[user:*:add]代表访问权限为user模块下所有子模块(*匹配子模块)的添加(add)权限

3.0.5 REST风格权限过滤器: rest

对应类为org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter

filterChainDefinitionMap = [
    // 访问用户必须拥有user模块的对应权限, GET请求代表read
    // 已GET方式的请求必须拥有user:read权限才可以访问
    "/user/*" : "rest[user]" 
]

将请求方式与增删改查操作对应, 当以POST方式访问URL时, 过滤器认为需要对模块进行create操作, 用户必须拥有user:create权限, 不同的请求方式对应不同的权限. 具体如下表:

HTTP请求方式 Shiro对应的操作 系统中需要授予用户的权限(以user模块为例)
delete delete user:delete
head read user:read
get read user:read
put update user:update
post create user:create
mkcol create user:create
options read user:read
trace read user:read

此过滤器将http请求方式和权限进行绑定, 可以算是perms过滤器的另一种实现方式. 由于浏览器对部分HTTP请求方式支持的不友好, 此过滤器应用较少.

3.1 自定义权限过滤器

上述内置过滤器中可以支持RBAC的有roles, perms, rest, 其中roles只定义了角色, perms, rest的规则也是需要在Shiro配置文件中进行配置模块及权限. 如果系统增加功能并设置权限时还需要同步修改配置文件(修改后需要重新启动Tomcat). 有没有一种灵活的方式可以实现增加功能时不需要修改系统代码呢, 参考下面的思路:

WEB应用中所有的操作都是基于URL的, 例如: /user/add是添加用户, /article/delete是删除文章. 如果我们将URL设置给角色. 当用户访问某一个URL时, 我们只需要对比该用户拥有的权限集中是否含有该URL即可.

例: 张三的角色为部门经理, 拥有添加用户(/user/add)和编辑用户(/user/edit)权限, 当张三登录系统后访问/user/add, 通过Realm获取张三的权限后对比发现URL(/user/add)在其权限列表中, Shiro允许访问. 当访问/user/delete时由于URL不在其权限中, 因此Shiro拒绝访问.

所有的URL请求都使用上述方式实现, 配置文件中就不需要定义每个URL对应的权限了. 因此新增功能时也就不需要修改系统代码了.

Shiro并没有内置这种形式的过滤器, 需要我们自己实现, 新建类继承AuthorizationFilter类重写isAccessAllowed方法. 后面文章会讲到isAccessAllowed是Shiro过滤器的一个核心方法: 判断当前过滤器的验证是否成功, 如果成功则放行(访问控制器).

  • 认证过滤器: 验证指的是否已经登录
  • 授权过滤器: 验证指的是用户是否有权限访问
/**
 * 自定义基于URL的授权过滤器
 * 通过用户访问的URL,从数据库中查询用户是否有访问该URL的权限
 */
public class URLAuthorizationFilter extends AuthorizationFilter {

    /**
     * 是否允许访问资源
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, 
                                      ServletResponse response, 
                                      Object mappedValue) throws Exception {

        // 获取访问的URL
        String requestUrl = WebUtils.toHttp(request).getRequestURI();
        // 判断用户是否有权限访问该URL
        // 调用isPermitted方法时Shiro会通过Realm获取用户拥有的权限集合
        // 并判断URL是否在权限集合中, 如果在权限集合中返回true
        return getSubject(request, response).isPermitted(requestUrl);

    }

}

3.2 配置权限过滤器

自定义的过滤器需要在Shiro中进行定义, 并配置URL需要授权才能访问

// 配置自定义过滤器,名称为authz
authz(URLAuthorizationFilter) {
    // 无权限页面: 用户无权限时重定向至该页面
    unauthorizedUrl = "/unauthorized.jsp"
}
// 配置URL规则
// 有请求访问时Shiro会根据此规则找到对应的过滤器处理
filterChainDefinitionMap = [
    "/unauthorized.jsp" : "anon", // 未授权页不需要授权即可访问
    "/logout" : "logout", // 登出使用logout过滤器
    "/login": "authc", // 登录页不配置授权
    "/**": "authc, authz" // 其余所有页面需要认证和授权(顺序:先认证后授权)
]
  • 授权和认证的顺序, 先认证后授权, 如果用户未登录, 无法获取用户所拥有的权限信息(授权时发现未认证会跳转登录页).
  • 登录页不需要授权: authc不会处理登录页, 如果配置到authz中, 会出现死循环(authz认为未登录重定向到登录页)

3.3 Realm实现获取权限

Shiro需要使用Realm获取用户的权限集合, 因此需要在Realm中增加一个获取权限的方法

// 自定义查询用户信息的Realm
// 授权需要继承AuthorizingRealm(只认证继承AuthenticatingRealm即可)
public class UserRealm extends AuthorizingRealm {

    // 获取用户权限信息
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 获取当前登录用户的用户名
        // Shiro会将doGetAuthenticationInfo返回的用户信息保存至PrincipalCollection中
        String username = ((User) principals.getPrimaryPrincipal()).getUsername();
        // 模拟数据库查询, 根据用户名查询可以访问的权限URL集合
        Set<String> permSet = getPermissions(username);

        // 将权限URL集合设置至Shiro中,授权时会从此处获取权限URL
        SimpleAuthorizationInfo authz = new SimpleAuthorizationInfo();
        authz.setStringPermissions(permSet);

        return authz;
    }

    // 模拟根据用户名在数据库中查询用户所有的权限URL
    // 数据库中可根据用户找到角色,角色找到资源
    private Set<String> getPermissions(String username) {
        Set<String> permSet = new HashSet<String>();

        // "atd681"有下列页面的访问权限
        if ("atd681".equals(username)) {
            permSet.add("/page/a");
            permSet.add("/page/b");
        }
        // 其他用户有下列页面的访问权限
        else {
            permSet.add("/page/x");
        }

        return permSet;
    }

    // 获取用户信息的方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
        throws AuthenticationException {
       // shiro-认证中的登录逻辑
    }

    // 模拟根据用户名在数据库查询用户信息
    private User getUser(String username) {
        // shiro-认证中的模拟获取用户信息
    }

}
  • 获取权限信息的Realm必须继承AuthorizingRealm实现doGetAuthorizationInfo方法
  • 获取权限时根据关系(用户>角色>资源)找到用户所拥有的资源(可访问的URL)
  • 需要将获取的权限集合(Set)设置到SimpleAuthorizationInfo类中并返回至Shiro
  • 本例中用户atd681可以访问a,b页面, 不可访问x页面

4. 测试

启动项目, 使用atd681登录后分别访问/page/a/page/b

可以正常访问. 访问/page/x时重定向至未授权页面

5. 视图层控制权限

上述权限控制是当用户访问URL时在服务端进行授权校验. 在页面中我们并没有根据权限控制链接或按钮是否显示, 不控制链接或按钮的显示会存在以下问题:

  • 用户无权限时点击链接或按钮后无法访问, 用户体验较差.
  • 暴露了系统该功能的URL, 引起不必要的安全隐患

因此, 当用户没有某功能权限时页面中不应该显示功能对应的链接或按钮(刻意显示链接吸引用户付费等场景除外), 我们需要在JSP中对链接或按钮进行权限判断, 没有权限时不显示对应的链接或按钮.

Shiro为我们提供了一套在JSP中可以判断认证或授权的标签, 在/page/a的JSP中添加如下代码:

JSP头部增加Shiro标签的引用

<%@ tagliburi ="http://shiro.apache.org/tags" prefix="shiro"%>

JSP中使用shiro:hasPermission根据用户的权限来控制是否显示链接或按钮

<body>
	系统菜单:
	
	<!-- 
		该标签根据name值判断当前用户是否有该页面的访问权限
		无权限时不显示该链接(调用subject.isPermitted方法进行验证)
	 -->
	<shiro:hasPermission name="/page/a">
		<a href="/page/a">A</a>
	</shiro:hasPermission>
	<shiro:hasPermission name="/page/b">
		<a href="/page/b">B</a>
	</shiro:hasPermission>
	<shiro:hasPermission name="/page/x">
		<a href="/page/x">X</a>
	</shiro:hasPermission>
	
	
	<br> PAGE_A, 当前登录用户ID: ${userId}, 用户名: ${userName}

	<a href="/logout">登出</a>
</body>
  • <shiro:hasPermission>中的name属性为链接的URL, 判断用户是否有权限访问URL
  • 只有当<shiro:hasPermission>返回true的时候, 标签内的HTML才会被返回到客户端
  • 所有标签的实现代码在org.apache.shiro.web.tags目录下, 有兴趣可以自己查看

启动项目, 使用atd681登录后访问/page/a, 由于用户atd681有访问/page/a/page/b的权限, 链接A,B被显示. 没有访问/page/x的权限, 链接X没有显示.

6. 示例代码

至此, 基于Shiro授权的示例配置完成. 有兴趣的同学可以多创建几个用户测试一下.