终于又要写技术相关了,这次搞Shiro。(其实是被Shiro搞了……)
0、起因
系统里用了shiro对用户的访问权限做了限制,主要分为两类:菜单,接口。两类权限都是以字符串的方式保存英文名称,其中接口权限的组成方式为
menuName:methodName
在接口上增加@RequiresPermissions注解,根据用户登录时获取的授权列表,交由shiro判断当前接口是否获得授权,允许用户访问。
1、问题
因为先测试页面访问,所以菜单授权提前添加了,但是接口授权是后续增加的,增加之后出现了一个问题——在没有授权的情况下,有的接口被限制了访问,提示接口未授权;有的接口没有被限制,能够正常返回数据。
测试了8个菜单页面,7个的接口都没有限制,只有1个成功了,这就有点神奇了吧。
有成功的接口,说明@RequiresPermissions生效了,但是似乎其它的接口被判定为已授权。
首先我想通过本地代码测试一下,在成功绕过限制的接口里看看当前用户的授权列表。由于获取授权列表的方法是本地重写的,我从源码里摘出了获取授权信息的方法
RealmSecurityManager rsm = (RealmSecurityManager) SecurityUtils.getSecurityManager();
AuthorizingRealm shiroRealm = (AuthorizingRealm) rsm.getRealms().iterator().next();
Cache<Object, AuthorizationInfo> authorizetionInfo = shiroRealm.getAuthorizationCache();
此时authorizetionInfo以key-value的形式存储了用户及授权列表,通过authorizetionInfo.keys()方法也确认找到了当前用户,但是通过get方法获取时返回值为null。
为什么获取不到呢?因为没有通过本地代码登录吗?这个疑问没能解决,我决定换个方法。
考虑授权列表的频繁对比,我们将授权列表写到了redis中,那我直接查看redis中的数据不就行了吗?
打开redis可视化工具,找到用户权限存储,好的,16进制汉字存储……
还是用原始朴素的命令行工具吧。
./redis-cli --raw
中文显示。对比了redis中的授权数据和实际期望的授权列表,确认一致。
那么还有一种可能就是,虽然授权列表里只有菜单,但是接口依然被shiro认为是通过验证的。那么shiro的验证方法是怎样的呢?
2、测试
简化一下,目前授权了两个菜单,分别为
MenuA
MenuB
同时有两个接口添加了限制但没有授权,分别为
MenuA:methodOne
MenuB:methodOne
(对,用的是同名接口)
目前MenuA:methodOne限制失败,可以访问;MenuB:methodOne限制成功,访问失败。
那么我们用最简单粗暴的方式,直接看判断结果
Subject subject = SecurityUtils.getSubject();
Boolean permissionA = subject.isPermitted("MenuA:methodOne");
Boolean permissionB = subject.isPermitted("MenuBs:methodOne");
第一项结果为TRUE,通过验证;第二项结果为FALSE,未通过验证,和实际访问情况一致。
等等,为什么出现了“MenuBs:methodOne”?
重新看了一下授权数据,MenuB下的接口在录入过程中多添加了一个字母s,而MenuA下的方法没有这个情况。再查看其它6个限制失败的页面,与MenuA一样。所以这就是MenuB下接口限制成功的原因?
3、源码
原因找到了,但是背后的原理呢?
在网上找到了一篇博客Shiro @RequiresPermissions是如何运转的?,展示了shiro的判断逻辑,这下看来要看看shiro源码了。
3-1、获取
首先在org.apache.shiro.realm.AuthorizingRealm中我们看到了方法名getAuthorizationInfo,非常直白地告诉我们,我是在这获取授权信息的。
protected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) {
if (principals == null) {
return null;
}
AuthorizationInfo info = null;
if (log.isTraceEnabled()) {
log.trace("Retrieving AuthorizationInfo for principals [" + principals + "]");
}
Cache<Object, AuthorizationInfo> cache = getAvailableAuthorizationCache();
if (cache != null) {
if (log.isTraceEnabled()) {
log.trace("Attempting to retrieve the AuthorizationInfo from cache.");
}
Object key = getAuthorizationCacheKey(principals);
info = cache.get(key);
if (log.isTraceEnabled()) {
if (info == null) {
log.trace("No AuthorizationInfo found in cache for principals [" + principals + "]");
} else {
log.trace("AuthorizationInfo found in cache for principals [" + principals + "]");
}
}
}
if (info == null) {
// Call template method if the info was not found in a cache
info = doGetAuthorizationInfo(principals);
// If the info is not null and the cache has been created, then cache the authorization info.
if (info != null && cache != null) {
if (log.isTraceEnabled()) {
log.trace("Caching authorization info for principals: [" + principals + "].");
}
Object key = getAuthorizationCacheKey(principals);
cache.put(key, info);
}
}
return info;
}
大概就是先去cache里找缓存数据,如果没有找到,那么就要通过doGetAuthorizationInfo(principals);获取了。接下来——
protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollectionprincipals);
嗯,抽象方法,也就是说我们要自己实现。想到我们本地重写的授权列表获取的源码……一切都水落石出了!(不是)
3-2 判定
再往下看会发现一系列的isPermitted方法,挨个看去找到了判定的最终归宿(也就是上边博文里截出来的那段)
protected boolean isPermitted(Permission permission, AuthorizationInfo info) {
Collection<Permission> perms = getPermissions(info);
if (perms != null && !perms.isEmpty()) {
for (Permission perm : perms) {
if (perm.implies(permission)) {
return true;
}
}
}
return false;
}
其中的implies方法就是重点了,那么它在哪儿呢?
根据引用我们找到了org.apache.shiro.authz.permission,好的是个接口类……
不如用implements Permission作为关键字搜索一下源码吧!
首先我们找到了org.apache.shiro.authz.permission.AllPermission;
public boolean implies(Permission p) {
return true;
}
行,这不是我们要找的。
然后就是org.apache.shiro.authz.permission.WildcardPermission了。是它!
public boolean implies(Permission p) {
// By default only supports comparisons with other WildcardPermissions
if (!(p instanceof WildcardPermission)) {
return false;
}
WildcardPermission wp = (WildcardPermission) p;
List<Set<String>> otherParts = wp.getParts();
int i = 0;
for (Set<String> otherPart : otherParts) {
// If this permission has less parts than the other permission, everything after the number of parts contained
// in this permission is automatically implied, so return true
if (getParts().size() - 1 < i) {
return true;
} else {
Set<String> part = getParts().get(i);
if (!part.contains(WILDCARD_TOKEN) && !part.containsAll(otherPart)) {
return false;
}
i++;
}
}
// If this permission has more parts than the other parts, only imply it if all of the other parts are wildcards
for (; i < getParts().size(); i++) {
Set<String> part = getParts().get(i);
if (!part.contains(WILDCARD_TOKEN)) {
return false;
}
}
return true;
}
没错!是博文里提到的!
那么这段的逻辑是怎样的呢?
首先我必须要说,this other这种起名方式是什么鬼!
简单来说,Permission会被当做字符串进行分割,一个Permission会分割为两级,第一级使用PART_DIVIDER_TOKEN(英文半角冒号:)分割,分割后的部分会被存入一个List<String>中;然后对List进行遍历,对每一部分进行二级分割,使用SUBPART_DIVIDER_TOKEN(英文半角逗号,),分割结果放在Set<String>中,也就是最后我们将得到一个List<Set<String>>作为判定依据。
假设我们需要对比授权列表中的授权PermissionAuthed和当前访问的接口授权PermissionCurrent,那么首先将两个permission进行上述分割,然后对PermissionCurrent的list进行遍历,一一对比两个list中的集合,如果PermissionAuthed的set为PermissionCurrent的set的超集,或者PermissionAuthed的set中包含通配符WILDCARD_TOKEN(英文半角星号*),那么对比继续;否则对比失败,授权没有通过验证。
如果在对比继续的情况下,二者的List长度不相等,那么——
1、PermissionCurrent长度较长,则认为包含了PermissionAuthed的授权,对比成功,授权通过验证;
2、PermissionAuthed长度较长,那么遍历PermissionAuthed的剩余部分,如果剩余部分中每一个Set的都包含通配符WILDCARD_TOKEN(英文半角星号*),那么对比成功,授权通过验证;否则对比失败,授权没有通过验证。
于是我们找到了MenuA菜单下方法通过授权验证的原因,对于上述情况
PermissionAuthed = List { Set [ menuA ] }
PermissionCurrent = List { Set [ menuA ], Set [ methodOne ] }
显然PermissionCurrent较长且通过了PermissionAuthed中的所有对比。
我们得出结论,如果PermissionA是PermissionB的子串,那么当对PermissionA授权后,PermissionB也能通过shiro的授权验证。
有一种合理但是又哪里怪怪的感觉……
另外就是我发现在对比时,最终对比的是Set,也就是说第二级分割后字符串就是无序的了,此时A,B和B,A是等价的,好像又有哪里怪怪的……
好吧,至少以后授权名称里不要出现冒号、逗号和星号就是了,也要避免两个授权间存在包含关系。
Shiro的授权判定着实有一些令人迷惑啊……