shiro是一个不错的java安全框架,但对restful接口支持不是很好,可以通过简单的改选,让它支持restful接口。
首先说明设计的这个安全体系是是RBAC(基于角色的权限访问控制)授权模型,即用户--角色--资源,用户不直接和权限打交道,角色拥有资源,用户拥有这个角色就有权使用角色对应用户的资源。所有这里没有权限一说,签发jwt里面也就只有用户所拥有的角色而没有权限。
为啥说是真正的restful风格集成,虽说shiro对rest不友好但他本身是有支持rest集成的filter–HttpMethodPermissionFilter
,这个shiro rest的 风格拦截器,会自动根据请求方法构建权限字符串( GET=read,POST=create,PUT=update,DELETE=delete
)构建权限字符串;eg: /users=rest[user] , 会 自动拼接出user:read,user:create,user:update,user:delete
”权限字符串进行权限匹配(所有都得匹配,isPermittedAll)。
但是这样感觉不利于基于jwt的角色的权限控制,在细粒度上验权url(即支持get,post,delete鉴别)就更没法了(个人见解)。打个比方:我们对一个用户签发的jwt写入角色列(role_admin,role_customer)。对不同request请求:url="api/resource/",httpMethod="GET"
,url="api/resource",httpMethod="POST"
,在基于角色-资源的授权模型中,这两个url相同的请求对HttpMethodPermissionFilter
是一种请求,用户对应的角色拥有的资源url=”api/resource”
,只要请求的url是”api/resource”
,不论它的请求方式是什么,都会判定通过这个请求,这在restful风格的api中肯定是不可取的,对同一资源有些角色可能只要查询的权限而没有修改增加的权限。
可能会说在jwt中再增加权限列就好了嘛,但是在基于用户-资源的授权模型中,虽然能判别是不同的请求,但是太麻烦了,对每个资源我们都要设计对应的权限列然后再塞入到jwt中,对每个用户都要单独授权资源这也是不可取的。
对shiro的改造这里自定义了一些规则:
shiro过滤器链的url=url+"=="+httpMethod
eg:对于url="api/resource/",httpMethod="GET"
的资源,其拼接出来的过滤器链匹配url=api/resource==GET
这样对相同的url而不同的访问方式,会判定为不同的资源,即资源不再简单是url,而是url和httpMethod的组合。基于角色的授权模型中,角色所拥有的资源形式为url+"=="+httpMethod
。
这里改变了过滤器的过滤匹配url规则,重写PathMatchingFilterChainResolver
的getChain方法,增加对上述规则的url的支持。
分析
首先先回顾下 Shiro 的过滤器链,一般我们都有如下配置:
/login.html = anon
/login = anon
/users = perms[user:list]
/** = authc
其中 /users 请求对应到 perms 过滤器,对应的类: org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter,其中的 onAccessDenied 方法是在没有权限时被调用的, 源码如下:
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
Subject subject = getSubject(request, response);
// 如果未登录, 则重定向到配置的 loginUrl
if (subject.getPrincipal() == null) {
saveRequestAndRedirectToLogin(request, response);
} else {
// 如果当前用户没有权限, 则跳转到 UnauthorizedUrl
// 如果没有配置 UnauthorizedUrl, 则返回 401 状态码.
String unauthorizedUrl = getUnauthorizedUrl();
if (StringUtils.hasText(unauthorizedUrl)) {
WebUtils.issueRedirect(request, response, unauthorizedUrl);
} else {
WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
return false;
}
我们可以在这里可以判断当前请求是否时 AJAX 请求,如果是,则不跳转到 logoUrl 或 UnauthorizedUrl 页面,而是返回 JSON 数据。
还有一个方法是 pathsMatch,是将当前请求的 url 与所有配置的 perms 过滤器链进行匹配,是则进行权限检查,不是则接着与下一个过滤器链进行匹配,源码如下:
protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = getPathWithinApplication(request);
log.trace("Attempting to match pattern '{}' with current requestURI '{}'...", path, requestURI);
return pathsMatch(path, requestURI);
}
方法
了解完这两个方法,我来说说如何利用这两个方法来实现功能。
我们可以从配置的过滤器链来入手,原先的配置如:
/users = perms[user:list]
我们可以改为 /user==GET,/user==POST
方式。== 用来分隔, 后面的部分指 HTTP Method。
使用这种方式还要注意一个方法,即:org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver 中的 getChain 方法,用来获取当前请求的 URL 应该使用的过滤器,源码如下:
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
// 1. 判断有没有配置过滤器链, 没有一个过滤器都没有则直接返回 null
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}
// 2. 获取当前请求的 URL
String requestURI = getPathWithinApplication(request);
// 3. 遍历所有的过滤器链
for (String pathPattern : filterChainManager.getChainNames()) {
// 4. 判断当前请求的 URL 与过滤器链中的 URL 是否匹配.
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
// 5. 如果路径匹配, 则获取其实现类.(如 perms[user:list] 或 perms[user:delete] 都返回 perms)
// 具体对 perms[user:list] 或 perms[user:delete] 的判断是在上面讲到的 PermissionsAuthorizationFilter 的 pathsMatch 方法中.
return filterChainManager.proxy(originalChain, pathPattern);
}
}
return null;
}
这里大家需要注意,第四步的判断,我们已经将过滤器链,也就是这里的 pathPattern 改为了 /xxx==GET 这种方式,而请求的 URL 却仅包含 /xxx,那么这里的 pathMatches 方法是肯定无法匹配成功,所以我们需要在第四步判断的时候,只判断前面的 URL 部分。
整个过程如下:
在过滤器链上对 restful 请求配置需要的 HTTP Method,如:/user==DELETE
。
修改 PathMatchingFilterChainResolver 的 getChain 方法,当前请求的 URL 与过滤器链匹配时,过滤器只取 URL 部分进行判断。
修改过滤器的 pathsMatch 方法,判断当前请求的 URL 与请求方式是否与过滤器链中配置的一致。
修改过滤器的 onAccessDenied 方法,当访问被拒绝时,根据普通请求和 AJAX 请求分别返回 HTML 和 JSON 数据。
实现
过滤器链添加 http method
在我的项目中是从数据库获取的过滤器链,所以有如下代码:
public Map<String, String> getUrlPermsMap() {
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/favicon.ico", "anon");
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/fonts/**", "anon");
filterChainDefinitionMap.put("/images/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/lib/**", "anon");
filterChainDefinitionMap.put("/login", "anon");
List<Menu> menus = selectAll();
for (Menu menu : menus) {
String url = menu.getUrl();
if (!"".equals(menu.getMethod())) {
url += ("==" + menu.getMethod());
}
String perms = "perms[" + menu.getPerms() + "]";
filterChainDefinitionMap.put(url, perms);
}
filterChainDefinitionMap.put("/**", "authc");
return filterChainDefinitionMap;
}
如: /xxx==GET = perms[user:list]这里的 getUrl,getMethod 和 getPerms 分别对应 /xxx,GET 和 user:list。
不过需要注意的是,如果在 XML 里配置,会被 Shiro 解析成 /xxx 和 =GET = perms[user:list],解决办法是使用其他符号代替 ==。
修改 PathMatchingFilterChainResolver 的 getChain 方法
由于 Shiro 没有提供相应的接口,且我们不能直接修改源码,所以我们需要新建一个类继承 PathMatchingFilterChainResolver 并重写 getChain 方法,然后替换掉 PathMatchingFilterChainResolver 即可。
首先继承并重写方法:
import org.apache.shiro.web.filter.mgt.FilterChainManager;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.FilterChain;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
public class RestPathMatchingFilterChainResolver extends PathMatchingFilterChainResolver {
private static final Logger log = LoggerFactory.getLogger(RestPathMatchingFilterChainResolver.class);
@Override
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}
String requestURI = getPathWithinApplication(request);
//the 'chain names' in this implementation are actually path patterns defined by the user. We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {
String[] pathPatternArray = pathPattern.split("==");
// 只用过滤器链的 URL 部分与请求的 URL 进行匹配
if (pathMatches(pathPatternArray[0], requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
return filterChainManager.proxy(originalChain, pathPattern);
}
}
return null;
}
}
然后替换掉 PathMatchingFilterChainResolver,它是在 ShiroFilterFactoryBean 的 createInstance 方法里初始化的。
image.png
所以同样的套路,继承 ShiroFilterFactoryBean 并重写 createInstance 方法,将 new PathMatchingFilterChainResolver(); 改为 new RestPathMatchingFilterChainResolver(); 即可。
代码如下:
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.FilterChainManager;
import org.apache.shiro.web.filter.mgt.FilterChainResolver;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanInitializationException;
public class RestShiroFilterFactoryBean extends ShiroFilterFactoryBean {
private static final Logger log = LoggerFactory.getLogger(RestShiroFilterFactoryBean.class);
@Override
protected AbstractShiroFilter createInstance() {
log.debug("Creating Shiro Filter instance.");
SecurityManager securityManager = getSecurityManager();
if (securityManager == null) {
String msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
}
if (!(securityManager instanceof WebSecurityManager)) {
String msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
}
FilterChainManager manager = createFilterChainManager();
//Expose the constructed FilterChainManager by first wrapping it in a
// FilterChainResolver implementation. The AbstractShiroFilter implementations
// do not know about FilterChainManagers - only resolvers:
PathMatchingFilterChainResolver chainResolver = new RestPathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
//Now create a concrete ShiroFilter instance and apply the acquired SecurityManager and built
//FilterChainResolver. It doesn't matter that the instance is an anonymous inner class
//here - we're just using it because it is a concrete AbstractShiroFilter instance that accepts
//injection of the SecurityManager and FilterChainResolver:
return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}
private static final class SpringShiroFilter extends AbstractShiroFilter {
protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
super();
if (webSecurityManager == null) {
throw new IllegalArgumentException("WebSecurityManager property cannot be null.");
}
setSecurityManager(webSecurityManager);
if (resolver != null) {
setFilterChainResolver(resolver);
}
}
}
}
最后记得将 ShiroFilterFactoryBean 改为 RestShiroFilterFactoryBean
XML 方式:
<bean id="shiroFilter" class="im.zhaojun.shiro.RestShiroFilterFactoryBean">
<!-- 参数配置略 -->
</bean>
Bean 方式:
@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
// 参数配置略
return shiroFilterFactoryBean;
}
修改过滤器的 pathsMatch 方法
同样新建一个类继承原有的 PermissionsAuthorizationFilter 并重写 pathsMatch 方法:
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.StringUtils;
import org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 修改后的 perms 过滤器, 添加对 AJAX 请求的支持.
*/
public class RestAuthorizationFilter extends PermissionsAuthorizationFilter {
private static final Logger log = LoggerFactory
.getLogger(RestAuthorizationFilter.class);
@Override
protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = this.getPathWithinApplication(request);
String[] strings = path.split("==");
if (strings.length <= 1) {
// 普通的 URL, 正常处理
return this.pathsMatch(strings[0], requestURI);
} else {
// 获取当前请求的 http method.
String httpMethod = WebUtils.toHttp(request).getMethod().toUpperCase();
// 匹配当前请求的 http method 与 过滤器链中的的是否一致
return httpMethod.equals(strings[1].toUpperCase()) && this.pathsMatch(strings[0], requestURI);
}
}
}
修改过滤器的 onAccessDenied 方法
同样是上一步的类,重写 onAccessDenied 方法即可:
/**
* 当没有权限被拦截时:
* 如果是 AJAX 请求, 则返回 JSON 数据.
* 如果是普通请求, 则跳转到配置 UnauthorizedUrl 页面.
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
Subject subject = getSubject(request, response);
// 如果未登录
if (subject.getPrincipal() == null) {
// AJAX 请求返回 JSON
if (im.zhaojun.util.WebUtils.isAjaxRequest(WebUtils.toHttp(request))) {
if (log.isDebugEnabled()) {
log.debug("用户: [{}] 请求 restful url : {}, 未登录被拦截.", subject.getPrincipal(), this.getPathWithinApplication(request)); }
Map<String, Object> map = new HashMap<>();
map.put("code", -1);
im.zhaojun.util.WebUtils.writeJson(map, response);
} else {
// 其他请求跳转到登陆页面
saveRequestAndRedirectToLogin(request, response);
}
} else {
// 如果已登陆, 但没有权限
// 对于 AJAX 请求返回 JSON
if (im.zhaojun.util.WebUtils.isAjaxRequest(WebUtils.toHttp(request))) {
if (log.isDebugEnabled()) {
log.debug("用户: [{}] 请求 restful url : {}, 无权限被拦截.", subject.getPrincipal(), this.getPathWithinApplication(request));
}
Map<String, Object> map = new HashMap<>();
map.put("code", -2);
map.put("msg", "没有权限啊!");
im.zhaojun.util.WebUtils.writeJson(map, response);
} else {
// 对于普通请求, 跳转到配置的 UnauthorizedUrl 页面.
// 如果未设置 UnauthorizedUrl, 则返回 401 状态码
String unauthorizedUrl = getUnauthorizedUrl();
if (StringUtils.hasText(unauthorizedUrl)) {
WebUtils.issueRedirect(request, response, unauthorizedUrl);
} else {
WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
return false;
}
重写完 pathsMatch 和 onAccessDenied 方法后,将这个类替换原有的 perms 过滤器的类:
XML 方式:
<bean id="shiroFilter" class="im.zhaojun.shiro.RestShiroFilterFactoryBean">
<!-- 参数配置略 -->
<property name="filters">
<map>
<entry key="perms" value-ref="restAuthorizationFilter"/>
</map>
</property>
</bean>
<bean id="restAuthorizationFilter" class="im.zhaojun.shiro.filter.RestAuthorizationFilter"/>
Bean 方式:
@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
Map<String, Filter> filters = shiroFilterFactoryBean.getFilters();
filters.put("perms", new RestAuthorizationFilter());
// 其他配置略
return shiroFilterFactoryBean;
}
这里只改了 perms 过滤器,对于其他过滤器也是同样的道理,重写过滤器的 pathsMatch 和 onAccessDenied 方法,并覆盖原有过滤器即可。
网友评论