Apache Shiro内部其实是通过一个过滤器链来实现认证/鉴权等流程的。浅谈其中的请求解析过程。
Apache Shiro是Java的一个安全框架,主要用于处理身份认证、授权、企业会话管理和加密等。与Spring Security一样都是一个权限安全框架,但是与Spring Security相比,在于其比较简洁易懂的认证和授权方式。
与Spring security类似,其一系列的认证以及权限校验操作主要是通过filter实现的。
shiro-web 提供了一些filter,每种filter都对应了不同的权限拦截规则:
FilterName | class |
---|---|
anon | org.apache.shiro.web.filter.authc.AnonymousFilter |
authc | org.apache.shiro.web.filter.authc.FormAuthenticationFilter |
authcBasic | org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter |
logout | org.apache.shiro.web.filter.authc.LogoutFilter |
noSessionCreation | org.apache.shiro.web.filter.session.NoSessionCreationFilter |
perms | org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter |
port | org.apache.shiro.web.filter.authz.PortFilter |
rest | org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter |
roles | org.apache.shiro.web.filter.authz.RolesAuthorizationFilter |
ssl | org.apache.shiro.web.filter.authz.SslFilter |
user | org.apache.shiro.web.filter.authc.UserFilter |
使用 Shiro 时,一般需要配置返回值为ShiroFilterFactoryBean的Bean,用于创建Shiro Filter。
例如如下的例子,这里通过setFilterChainDefinitionMap设置对应的url和过滤器匹配规则:
@Bean
ShiroFilterFactoryBean shiroFilterFactoryBean(){
ShiroFilterConfiguration shiroFilterConfiguration = new ShiroFilterConfiguration();
shiroFilterConfiguration.setStaticSecurityManagerEnabled(true);
shiroFilterConfiguration.setFilterOncePerRequest(true); ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setShiroFilterConfiguration(shiroFilterConfiguration);
bean.setSecurityManager(securityManager());
bean.setLoginUrl("/login");
bean.setSuccessUrl("/index");
bean.setUnauthorizedUrl("/unauthorizedurl");
Map<String, String> map = new LinkedHashMap<>();
map.put("/doLogin", "anon");
map.put("/admin/*", "authc");
bean.setFilterChainDefinitionMap(map);
return bean;
}
当发起HTTP请求时,Shiro 的多个过滤器形成了一条链,所有请求都必须通过这些过滤器后才能成功访问到资源。以1.10.0版本为例,简单看下Shiro拦截请求处理的过程。
查阅相关资料,shiro 发挥作用的入口是在org.apache.shiro.spring.web.ShiroFilterFactoryBean.SpringShiroFilter
中,其中它继承自 OncePerRequestFilter,从字面上看是每个请求执行一次。
在接收到请求时会先进入 OncePerRequestFilter.doFilter() ,在这里写一个断点:
这里首先会做一些简单的判断,然后org.apache.shiro.web.servlet.AbstractShiroFilter#doFilterInternal方法,首先会对request 和 response 对象进行包装,然后调用createSubject方法,这里会处理认证授权信息并进行封装:
然后在Callable修改了最近一次的访问时间,然后调用 FilterChain:
这里主要调用org.apache.shiro.web.servlet.AbstractShiroFilter#getExecutionChain
创建FilterChain,实际上调用的是org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain来获取(会根据URL路径匹配,解析出ServletRequest请求过程中要执行的过滤器链):
查看debug info,chain主要有两个Filter,一个authc(对应前面的配置map.put("/admin/*", "authc");),一个invalidRequest(主要用于拦截存在安全问题的uri并返回400状态码):
获取到filterchain后,会继续调用chain.doFilter(request, response)逐个调用对应的filter,这里实际上调用的是org.apache.shiro.web.servlet.ProxiedFilterChain#doFilter来调用对应filter的doFilter方法:
按照前面的分析,首先会调用InvalidRequestFilter进行拦截,然后再调用authc规则对应的过滤器org.apache.shiro.web.filter.authc.FormAuthenticationFilter。
首先会调用org.apache.shiro.web.servlet.OncePerRequestFilter#doFilter,此时调用的是org.apache.shiro.web.servlet.AdviceFilter#doFilterInternal(AdviceFilter 主要负责处理anon、authc 等请求的)
这里通过调用preHandle方法会进入PathMatchingFilter的调用逻辑,主要是验证filterChain是否需要继续(对请求的URI验证是否匹配,然后获取到路径上对应的配置调用isFilterChainContinued 方法验证是否满足配置):
当匹配到路径时会执行isFilterChainContinued方法,这里执行onPreHandle,根据返回值来决定是否继续允许执行后续的filter:
实际调用了org.apache.shiro.web.filter.AccessControlFilter#onPreHandle:
继续跟进这里开始调用FilterChain中InvalidRequestFilter的处理逻辑:
处理完后,返回到AdviceFilter处理逻辑,当continueChain为true时,会继续调用org.apache.shiro.web.servlet.AdviceFilter#executeChain方法(例如权限控制不通过时会返回false逻辑,此时会结束调用),此时轮到FormAuthenticationFilter调用,同样的会进入类似的调用逻辑。
同样的,当匹配成功后,会访问org.apache.shiro.web.filter.authc.FormAuthenticationFilter#onAccessDenied方法(也就是之前authc的配置):
重复执行对应的filterChain后,最后会进入业务代码。
前面简单描述了Apache Shiro接收到请求后的一个解析过程,其中还有一些关键类,这里简单的进行分析。
Shiro中对于URL的获取及匹配在org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain
方法,
其会根据URL路径匹配,解析出ServletRequest请求过程中要执行的过滤器链。以1.10.0版本为例,查看具体的解析过程:
如果没有配置的话,返回null,使用原始默认的过滤器链逻辑:
否则会进入路由解析的逻辑。
首先调用getPathWithinApplication方法获取应用程序内的URI的相对路径:
具体的实现如下:
首先通过request.getServletPath()+request.getPathInfo()方法获取URI,然后再调用removeSemicolon和normalize方法处理:
removeSemicolon方法
ASCII码59对应的是;
,这个方法主要是判断url 中是否有分号,有的话会截取分号前的url并返回:
normalize方法
首先根据replaceBackSlash的值,判断是否需要将正斜杠\
处理成反斜杠/
,如果路径是/.
直接返回/
,否则判断路径是否以/
开头,不是的话则在前面补全一个/
。
public static String normalize(String path) {
return normalize(path, Boolean.getBoolean("org.apache.shiro.web.ALLOW_BACKSLASH"));
}private static String normalize(String path, boolean replaceBackSlash) {
if (path == null) {
return null;
} else {
String normalized = path;
if (replaceBackSlash && path.indexOf(92) >= 0) {
normalized = path.replace('\\', '/');
}
if (normalized.equals("/.")) {
return "/";
} else {
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while(true) {
int index = normalized.indexOf("//");
if (index < 0) {
while(true) {
index = normalized.indexOf("/./");
if (index < 0) {
while(true) {
index = normalized.indexOf("/../");
if (index < 0) {
return normalized;
}
if (index == 0) {
return null;
}
int index2 = normalized.lastIndexOf(47, index - 1);
normalized = normalized.substring(0, index2) + normalized.substring(index + 3);
}
}
normalized = normalized.substring(0, index) + normalized.substring(index + 2);
}
}
normalized = normalized.substring(0, index) + normalized.substring(index + 1);
}
}
}
}
再往下就是对路径进行格式化处理,主要是以下几个措施:
双反斜杠处理成反斜杠(// -> /)
归一化处理/./(/./ -> /)
处理路径跳跃(/a/../b -> /b)
处理完后getPathWithinApplication方法调用结束,此时回到getChain方法,继续调用removeTrailingSlash方法对返回的requestURI进行处理,这里主要是删除路径最后的斜杠:
再往下会遍历filterChains,requestURI和pattern匹配的话会代理到 filterChainManager.proxy方法里去,如果不能匹配,会删除最后的"/" 再匹配一次:
通过调试可以看到,这里使用的PatternMatcher默认是AntPathMatcher,也就是说shiro默认是使用AntPath模式进行匹配的:
这部分处理uri的逻辑在整个Apache Shiro的漏洞维护历史中,变动是最大的,包括上面提到的归一化,匹配最后一个/
很多措施都是为了漏洞修复新增的。
在执行完Shiro对应的Filterchain后,会调用业务逻辑,也就是spring web路由解析的部分。
在Spring Framework中,在Controller里以下两个路由访问是等价的:
@GetMapping("/admin/page")
@GetMapping("admin/page")
主要原因是因为不论是AntPathMatcher还是高版本的PathPattern都会对当前的Pattern进行补全(如果不是以/
开头的话会在前面补全这个/
):
AntPathMatcher
PathPattern
假设设置对应的url和过滤器匹配规则如下:
map.put("admin/page", "authc");
按照前面的理解,按道理是能对以下Controller进行防护的:
@GetMapping("admin/page")
public String admin() {
return "admin page";
}
实际上这个配置并不会生效,还是可以访问到对应的Controller:
同样以1.10.0版本的shiro为例,查看具体的原因:
根据前面的分析,在解析时会调用org.apache.shiro.web.servlet.AbstractShiroFilter#getExecutionChain创建FilterChain,这里调用的是org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain来获取其会根据URL路径匹配,解析出ServletRequest请求过程中要执行的过滤器链:
首先调用getPathWithinApplication方法获取应用程序内的URI的相对路径,然后往下遍历filterChains,requestURI和pattern匹配的话会代理到 filterChainManager.proxy方法里去,如果不能匹配,会删除最后的"/" 再匹配一次:
shiro使用的是AntPathMatcher进行匹配的,如果请求的path和pattern没有以/,就不再进行匹配了,与Spring不同的是,shiro在匹配前并不会对pattern进行检查,补全开头的/:
这里会导致前面的对于admin/page
配置失效,可以看到filterChain仅仅返回了InvalidRequestFilter,并没有返回authc对应的Filter(权限控制失效):
所以在使用Apache Shiro配置URI层面的权限时,一定要注意对应的规则需要以/
开头。
以1.10.0版本为例:
根据前面的分析可以知道,具体的匹配是在org.apache.shiro.util.AntPathMatcher#matches方法:
实际调用的是doMatch方法,首先调用tokenizeToStringArray()方法分别将pattern和path分割成了String数组:
查看tokenizeToStringArray()的具体实现,这里其实跟spring的实现是类似的,同样是通过java.util 里面的StringTokenizer来处理字符串,同样的也存在属性trimTokens(判断是否需要消除path中的空格):
由于 1.11.0 及之前版本的 Shiro 只兼容 Spring 的ant-style路径匹配模式(pattern matching),且 2.6 及之后版本的 Spring Boot 将 Spring MVC 处理请求的路径匹配模式从AntPathMatcher更改为了PathPatternParser,当 1.11.0 及之前版本的 Apache Shiro 和 2.6 及之后版本的 Spring Boot 使用不同的路径匹配模式时,攻击者访问可绕过 Shiro 的身份验证。
对比下shiro1.11.0跟1.10.1的改动,可以发现主要是通过Spring动态的读取文件留下的扩展接口来将路径匹配模式修改为 AntPathMatcher :
此外,trimTokens属性在不同版本也存在差异。
在1.7.1版本之前,该属性被设置为true。从1.7.1版本开始,该属性默认设置为false:
https://github.com/apache/shiro/commit/0842c27fa72d0da5de0c5723a66d402fe20903df
shiro-core-1.7.0
shiro-core-1.7.1
在Spring web中,org.springframework.web.servlet.handler.AbstractHandlerMapping#initLookupPath方法中,主要用于初始化请求映射的路径:
这里有两个逻辑,主要跟Spring的匹配模式有关。当使用的是PathPattern时,this.usesPathPatterns()返回true,否则走else的逻辑,在shiro1.11.0以后,shiro会通过Spring动态的读取文件留下的扩展接口强制将路径匹配模式修改为 AntPathMatcher ,会走else的逻辑:
这里正常来说会调用org.springframework.web.util.UrlPathHelper#getPathWithinApplication方法。
但是为了保持Spring和Shiro两者逻辑一致,会通过ShiroRequestMappingConfig 类将RequestMappingHandlerMapping#urlPathHelper 设置为 ShiroUrlPathHelper:
此时调用的是org.apache.shiro.spring.web.ShiroUrlPathHelper重写的getPathWithinApplication方法,此时Spring 匹配 handler 时获取路径的逻辑就会使用 Shiro 提供的逻辑:
具体调用的是org.apache.shiro.web.util.WebUtils#getPathWithinApplication,主要是获取ServletPath和PathInfo后再调用removeSemicolon和normalize方法处理(这里跟PathMatchingFilterChainResolver的逻辑是一样的):
PS:这里有一点要注意的是,在1.11.0版本之前,Apache Shiro并没有强制Spring将路径匹配模式修改为 AntPathMatcher。当高版本Spring使用PathPattern进行解析时,并不会调用ShiroUrlPathHelper的逻辑。而是会调用Spring自身的UrlPathHelper的defaultInstance对象进行处理。
从shiro1.6开始,新增了一个InvalidRequestFilter的过滤器,用于拦截存在安全问题的uri并返回400状态码。
在org.apache.shiro.spring.web.ShiroFilterFactoryBean#createFilterChainManager中,设置了一个GlobalFilters,这个Filter就是InvalidRequestFilter:
同时配置/**
,说明每一个URL请求都会经过这个过滤器:
查看过滤器具体实现的功能,核心方法是isAccessAllowed,这里对一些特殊的内容进行了拦截:
protected boolean isAccessAllowed(ServletRequest req, ServletResponse response, Object mappedValue) throws Exception {
HttpServletRequest request = WebUtils.toHttp(req);
return this.isValid(request.getRequestURI()) && this.isValid(request.getServletPath()) && this.isValid(request.getPathInfo());
}
主要是在isValid方法判断的:
private boolean isValid(String uri) {
return !StringUtils.hasText(uri) || !this.containsSemicolon(uri) && !this.containsBackslash(uri) && !this.containsNonAsciiCharacters(uri);
}
hasText
判断uri是否非null或者是空白字符:
containsSemicolon
判断是否包含引号:
private static final List<String> SEMICOLON = Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));
containsBackslash
判断是否包含反斜杠:
private static final List<String> BACKSLASH = Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
containsNonAsciiCharacters
判断是否包含非Ascii 字符:
可以看到,相比SpringSecurity,Apache Shiro的拦截会更"宽容"一些。
链接:https://forum.butian.net/share/2231
作者:tkswifty
欢迎大家去关注作者
点击下方小卡片或扫描下方二维码观看更多技术文章
师傅们点赞、转发、在看就是最大的支持