在[Shiro-认证]中讲解了如何使用Shiro实现登录后访问URL, 对于大部分系统来说, 登录只是安全的第一道屏障, 系统中的某些页面需要登录后访问, 而有些是需要有特定权限才可以访问, 比如删除, 冻结, 查看账号收益等敏感的操作.
本文将带你实现基于Shiro的权限控制, Shiro中叫做授权
系统中有A,B,C三个用户, 其中A用户是管理员, B和C是普通用户. 系统中的所有删除操作必须由管理员账号登录才能完成. 普通用户是无法删除数据甚至连删除按钮都看不见. 我们说A,B,C三个用户在系统中有不同的权限. A有删除数据的权限, B和C没有删除数据的权限. 试想一下如果没有权限设计, 所有用户都可以删除数据, 假设B是新手不小心误操作删除了数据... 后果将不堪设想.
又例如银行的金库, 如果没有权限控制所有人都可以刷卡进入, 那岂不是要乱套. 生活中权限无处不在: 进出小区刷卡, 电梯刷卡到指定楼层, 视频网站中会员不需要看广告, 这些都是权限.
假如你做了一个交友网站, 里面有查看异性的基本信息, 查看微信, 查看电话, 查看家庭住址几个功能, 普通的用户只能查看基本信息, 不能查看联系方式等. 充值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 张表:
系统预先设计好角色, 资源, 角色资源关系. 当新建用户时只需要添加用户角色关系即可实现对该用户的权限控制. 例如: 孙七注册了用户并充值200元, 我们可以直接设置孙七为VIP, 通过孙七的角色VIP就可以从角色资源关系中找到对应的可操作的URL.
本文中的操作是基于[Shiro-认证]之上完成的, 建议先看完Shiro认证部分. Shiro的认证是通过内置的认证过滤器(authc)完成的, 同时也提供了一些授权相关的过滤器:
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部署新版本)
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访问的用户.
roles
用户必须具有配置的角色才可以访问. Shiro会调用 Realm
中查询授权信息的方法获取用户的角色. 对应类为 org.apache.shiro.web.filter.authz.RolesAuthorizationFilter
filterChainDefinitionMap = [ "/**" : "roles['admin,guest']" // 访问用户必须同时具备admin和guest角色才可以访问 ] 复制代码
如配置成 roles["admin"]
代表只要是 admin
角色就可以访问, 两个及以上角色代表必须同时满足.
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)权限
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请求方式支持的不友好, 此过滤器应用较少.
上述内置过滤器中可以支持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); } } 复制代码
自定义的过滤器需要在Shiro中进行定义, 并配置URL需要授权才能访问
// 配置自定义过滤器,名称为authz authz(URLAuthorizationFilter) { // 无权限页面: 用户无权限时重定向至该页面 unauthorizedUrl = "/unauthorized.jsp" } 复制代码
// 配置URL规则 // 有请求访问时Shiro会根据此规则找到对应的过滤器处理 filterChainDefinitionMap = [ "/unauthorized.jsp" : "anon", // 未授权页不需要授权即可访问 "/logout" : "logout", // 登出使用logout过滤器 "/login": "authc", // 登录页不配置授权 "/**": "authc, authz" // 其余所有页面需要认证和授权(顺序:先认证后授权) ] 复制代码
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-认证中的模拟获取用户信息 } } 复制代码
启动项目, 使用 atd681
登录后分别访问 /page/a
和 /page/b
可以正常访问. 访问 /page/x
时重定向至未授权页面
上述权限控制是当用户访问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没有显示.