Apache Shiro 是一个通用的 Java 安全框架,负责四件事:身份认证(Authentication) 、访问控制/授权(Authorization) 、会话管理(Session Management) 、加解密(Cryptography) 。
Authentication 身份认证 :确认“你是谁”。用到下面三个信息:
Subject :主体/当事人。发起操作的人或程序线程。
Principal :身份标识,要求唯一 (如用户名/手机号/邮箱/用户ID)。
Credential :凭证,用来证明你是这个身份(如密码、TOTP、证书)。
Authorization 访问控制 :确认“你能做什么”。三要素:
who (谁)→ user/subject,当前操作人
what (对什么)→ resource,被访问的资源(接口、菜单、记录等)
how (怎么操作)→ permission(操作许可),通常也会通过 role(角色) 来打包一组权限
permission :最小粒度的操作许可(Shiro 常用通配权限 模型)
role :角色(权限的集合 ,如 admin、editor)
它既能用于 Web(拦截请求、登录、鉴权),也能用于非 Web 程序(命令行工具、后台任务),因为它不依赖 Servlet 容器自带的 Session。
Shiro 关键组件
Subject 代表“当前主体”。你在业务代码里几乎只和它打交道:
Subject.login(token):登录
Subject.isAuthenticated():是否已认证
Subject.hasRole("admin") / checkRole(...):角色判断
Subject.isPermitted("doc:read:123") / checkPermission(...):权限判断
Subject.getPrincipal():取出当前用户标识
Subject.getSession():拿会话(无论是否 Web)
SecurityManager(安全管理器) Shiro 的核心枢纽 ,把具体工作分派给下属模块:Authenticator / Authorizer / SessionManager / … 在 Spring 环境中,它往往是一个单例 Bean(DefaultSecurityManager 或 DefaultWebSecurityManager)。
Authenticator(认证器) 负责调用一个或多个 Realm 完成认证。多 Realm 时可配置策略:
AtLeastOneSuccessfulStrategy(默认,有一个成功即可)
FirstSuccessfulStrategy(第一个成功的为准)
AllSuccessfulStrategy(全部成功才算通过)
Authorizer(授权器) 根据角色/权限判定是否允许访问。默认实现基于 WildcardPermission 处理你定义的通配权限字符串。
Realm(数据源 + 认证/授权实现) Realm 是“桥”,把你的存储系统 (数据库/LDAP/远程服务)里的用户、角色、权限数据提供给 Shiro。 典型做法:自定义一个 AuthorizingRealm,重写两件事:
doGetAuthenticationInfo(token):根据用户名查出哈希后的密码 + 盐 ,返回 AuthenticationInfo。
doGetAuthorizationInfo(principals):查出该用户的角色与权限,封装 AuthorizationInfo。
CredentialsMatcher :口令校验器。常用 HashedCredentialsMatcher 做 SHA-256/SHA-512 + 盐 + 多轮迭代。若需 bcrypt/argon2,可自定义或用社区扩展。
SessionManager / SessionDAO Shiro 自带一套与 Servlet 无关 的会话管理:
SessionManager :控制会话生命周期/超时。
SessionDAO :会话存储抽象。可落到内存、数据库、Redis 等(需要相应实现,如社区常用 shiro-redis)。
常见实现:
DefaultWebSessionManager:Web 环境下的原生 Shiro 会话(Cookie 名常见 JSESSIONID 或自定义)。
ServletContainerSessionManager:直接复用容器会话。
可以添加 SessionListener 监听创建/停用事件。
CacheManager(缓存) 给授权信息/会话 等做缓存,减少数据库压力。常见:
Ehcache、Caffeine、本地 Map 或 Redis 集成
典型用法:授权信息缓存 (AuthorizationInfo)能明显减少“每次鉴权都查库”的开销
Cryptography(密码学工具) Shiro 提供常用组件:
SimpleHash、DefaultPasswordService 等散列/密码服务
AesCipherService 等对称加解密
SecureRandomNumberGenerator 生成随机盐
用途:安全存储密码、签发/校验 RememberMe Cookie、对敏感数据做加解密 。
基本使用 1 2 3 4 5 6 7 8 9 10 11 <dependency > <groupId > commons-logging</groupId > <artifactId > commons-logging</artifactId > <version > 1.1.3</version > </dependency > <dependency > <groupId > org.apache.shiro</groupId > <artifactId > shiro-core</artifactId > <version > 1.3.2</version > </dependency >
数据源与 Realm Shiro 不关心你的用户存在哪(内存、JDBC、Redis、LDAP……),它只跟 Realm 打交道。Realm 就是 Shiro 的“数据源适配器 + 认证/授权规则执行者”。
认证时:它替你去数据源查账号 (DB/Redis/LDAP/INI/自写服务),拿回口令散列+盐 、账户状态等,让 Shiro 比对登录口令。
授权时:它替你查角色/权限 并回传给 Shiro,用来 hasRole() / isPermitted()。
我们可以自定义Realm,最常见做法:自定义一个继承 AuthorizingRealm 的类,重写两个方法:
doGetAuthenticationInfo(...) → 登陆时调用;
doGetAuthorizationInfo(...) → 首次做角色/权限判断时调用并缓存。
“数据源”与实体 首先先定义实体与“数据源”接口(可换成 JDBC/ORM):
1 2 3 4 5 6 7 8 9 10 11 public class User { public final String username; public final String passwordHash; public final String salt; public final boolean locked; public User (String u, String h, String s, boolean locked) { this .username = u; this .passwordHash = h; this .salt = s; this .locked = locked; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import java.util.*;public class UserService { private final Map<String, User> table = new HashMap <>(); public void save (User u) { table.put(u.username, u); } public User findByUsername (String u) { return table.get(u); } public Set<String> rolesOf (String username) { if ("admin" .equals(username)) return new HashSet <>(Arrays.asList("admin" , "op" )); return new HashSet <>(Collections.singletonList("user" )); } public Set<String> permsOf (String username) { if ("admin" .equals(username)) return new HashSet <>(Arrays.asList("doc:read" , "doc:write" , "user:*" )); return new HashSet <>(Collections.singletonList("doc:read" )); } }
自定义 Realm 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import org.apache.shiro.authc.*;import org.apache.shiro.authc.credential.HashedCredentialsMatcher;import org.apache.shiro.authz.*;import org.apache.shiro.realm.AuthorizingRealm;import org.apache.shiro.subject.PrincipalCollection;import org.apache.shiro.util.ByteSource;public class MyRealm extends AuthorizingRealm { private final UserService userService; public MyRealm (UserService userService) { this .userService = userService; HashedCredentialsMatcher matcher = new HashedCredentialsMatcher ("SHA-256" ); matcher.setHashIterations(1024 ); matcher.setStoredCredentialsHexEncoded(true ); setCredentialsMatcher(matcher); } @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo (); info.setRoles(userService.rolesOf(username)); info.setStringPermissions(userService.permsOf(username)); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken up = (UsernamePasswordToken) token; User u = userService.findByUsername(up.getUsername()); if (u == null ) throw new UnknownAccountException (); if (u.locked) throw new LockedAccountException (); return new SimpleAuthenticationInfo ( u.username, u.passwordHash, ByteSource.Util.bytes(u.salt), getName() ); } }
Realm 使用 我们可以通过下面的代码模拟 Shiro 的身份认证和访问控制过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import org.apache.shiro.SecurityUtils;import org.apache.shiro.authc.*;import org.apache.shiro.crypto.SecureRandomNumberGenerator;import org.apache.shiro.crypto.hash.SimpleHash;import org.apache.shiro.mgt.DefaultSecurityManager;import org.apache.shiro.subject.Subject;import org.apache.shiro.util.ByteSource;public class App { static User createUser (String username, String plain) { String salt = new SecureRandomNumberGenerator ().nextBytes().toHex(); String hash = new SimpleHash ("SHA-256" , plain, ByteSource.Util.bytes(salt), 1024 ).toHex(); return new User (username, hash, salt, false ); } public static void main (String[] args) { UserService repo = new UserService (); repo.save(createUser("admin" , "123456" )); repo.save(createUser("alice" , "hello" )); MyRealm realm = new MyRealm (repo); DefaultSecurityManager sm = new DefaultSecurityManager (realm); SecurityUtils.setSecurityManager(sm); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken ("admin" , "123456" ); try { subject.login(token); System.out.println("Login OK? " + subject.isAuthenticated()); System.out.println("hasRole(admin)? " + subject.hasRole("admin" )); System.out.println("isPermitted(doc:write)? " + subject.isPermitted("doc:write" )); subject.checkRole("admin" ); subject.checkPermission("doc:read" ); } catch (UnknownAccountException | IncorrectCredentialsException e) { System.out.println("用户名或口令错误" ); } catch (LockedAccountException e) { System.out.println("账户被锁定" ); } catch (AuthenticationException e) { System.out.println("其他认证失败: " + e.getClass().getSimpleName()); } finally { subject.logout(); } } }
身份认证 使用自定义 Realm Shiro 的认证核心是三件事:Subject(当前用户)、SecurityManager(安全总管)、Realm(拿你的用户/密码/角色/权限数据)。基本流程是:
提供 Realm (你实现或内置),它负责“查出正确答案”(口令散列/盐、账户状态)。
1 MyRealm realm = new MyRealm (repo);
准备 SecurityManager → 绑定到 SecurityUtils。
1 2 DefaultSecurityManager sm = new DefaultSecurityManager (realm);SecurityUtils.setSecurityManager(sm);
SecurityUtils 里获取 Subject → subject.login(new UsernamePasswordToken(u,p))。
1 2 3 Subject subject = SecurityUtils.getSubject();UsernamePasswordToken token = new UsernamePasswordToken ("admin" , "123456" );subject.login(token);
使用内置 Realm 不用自定义 Realm 时,可直接用 IniRealm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import org.apache.shiro.SecurityUtils;import org.apache.shiro.authc.AuthenticationException;import org.apache.shiro.authc.UsernamePasswordToken;import org.apache.shiro.config.IniSecurityManagerFactory;import org.apache.shiro.mgt.SecurityManager;import org.apache.shiro.subject.Subject;import org.apache.shiro.util.Factory;public class AuthDemo { public static void main (String[] args) { Factory<SecurityManager> factory = new IniSecurityManagerFactory ("classpath:shiro.ini" ); SecurityManager securityManager = factory.getInstance(); SecurityUtils.setSecurityManager(securityManager); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken ("admin" , "123456" ); try { subject.login(token); System.out.println("Login Success: " + subject.isAuthenticated()); } catch (AuthenticationException e) { System.out.println("Login Failure: " + e.getClass().getSimpleName()); } } }
resources/shiro.ini 中存放明文口令:
1 2 3 4 5 6 7 8 [users] admin = password123, adminRolealice = alicePwd, userRolebob = secret, userRole[roles] adminRole = *userRole = doc:read,doc:write
读取 shiro.ini 文件。如果其中有 [users] 段,IniSecurityManagerFactory 会创建一个 IniRealm (org.apache.shiro.realm.text.IniRealm),把 [users] 列表里的账号密码加载进内存;这就是“认证的数据来源”。
认证过程分析 认证过程调用栈如下:
1 2 3 4 5 6 7 8 at MyRealm.doGetAuthenticationInfo(MyRealm.java:34) at org.apache.shiro.realm.AuthenticatingRealm.getAuthenticationInfo(AuthenticatingRealm.java:568) at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doSingleRealmAuthentication(ModularRealmAuthenticator.java:180) at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doAuthenticate(ModularRealmAuthenticator.java:267) at org.apache.shiro.authc.AbstractAuthenticator.authenticate(AbstractAuthenticator.java:198) at org.apache.shiro.mgt.AuthenticatingSecurityManager.authenticate(AuthenticatingSecurityManager.java:106) at org.apache.shiro.mgt.DefaultSecurityManager.login(DefaultSecurityManager.java:270) at org.apache.shiro.subject.support.DelegatingSubject.login(DelegatingSubject.java:256)
org.apache.shiro.authc.pam.ModularRealmAuthenticator#doAuthenticate 判断是单个数据源还是多个数据源。
1 2 3 4 5 6 7 8 9 protected AuthenticationInfo doAuthenticate (AuthenticationToken authenticationToken) throws AuthenticationException { assertRealmsConfigured(); Collection<Realm> realms = getRealms(); if (realms.size() == 1 ) { return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken); } else { return doMultiRealmAuthentication(realms, authenticationToken); } }
无论是哪一种,最后都会调用 org.apache.shiro.realm.AuthenticatingRealm#getAuthenticationInfo 进行认证。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 public final AuthenticationInfo getAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { AuthenticationInfo info = getCachedAuthenticationInfo(token); if (info == null ) { info = doGetAuthenticationInfo(token); log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo" , info); if (token != null && info != null ) { cacheAuthenticationInfoIfPossible(token, info); } } else { log.debug("Using cached authentication info [{}] to perform credentials matching." , info); } if (info != null ) { assertCredentialsMatch(token, info); } else { log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null." , token); } return info; }
doGetAuthenticationInfo 返回的 SimpleAuthenticationInfo 中存储着根据用户登录信息查询到的经过哈希的凭据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public SimpleAuthenticationInfo (Object principal, Object hashedCredentials, ByteSource credentialsSalt, String realmName) { this .principals = new SimplePrincipalCollection (principal, realmName); this .credentials = hashedCredentials; this .credentialsSalt = credentialsSalt; }
assertCredentialsMatch 完成用户登录信息 AuthenticationToken 与根据用户信息查询到的 AuthenticationInfo 直接的比较。实际上是调用 CredentialsMatcher#doCredentialsMatch 进行比较的,比较时会将用户登录信息哈希计算后再跟 AuthenticationInfo 中的经过哈希的凭据比较。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 protected void assertCredentialsMatch (AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException { CredentialsMatcher cm = getCredentialsMatcher(); if (cm != null ) { if (!cm.doCredentialsMatch(token, info)) { String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials." ; throw new IncorrectCredentialsException (msg); } } else { throw new AuthenticationException ( "A CredentialsMatcher must be configured in order to verify credentials during authentication. " + "If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance." ); } }
访问控制 Shiro 的授权核心是用户(Subject)→ 角色(Role)→ 权限(Permission) 。
用户(Subject) 当前正在与系统交互的用户主体。
角色(Role) 更像“身份标签”,例如:admin、user、manager 等。
权限(Permission) 是具体的“能做什么”操作,Shiro 推荐使用 WildcardPermission 表达。
WildcardPermission 表达式规则为:资源:动作:实例,用逗号 , 表示并集,用 * 表示通配符。
例如:
"doc:read,write:*" 表示对所有文档实例都有读和写权限;
"user:*" 表示对用户资源的所有操作权限。
程序式授权(API 方式) 在现有的 App.java 中登录成功后,可以通过如下 API 实现访问控制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Subject subject = SecurityUtils.getSubject();boolean isAdmin = subject.hasRole("admin" ); boolean hasAll = subject.hasAllRoles(Arrays.asList("admin" , "op" )); boolean any = subject.hasRole("admin" ) || subject.hasRole("op" ); boolean canRead = subject.isPermitted("doc:read" ); boolean canWrite = subject.isPermitted("doc:write" ); boolean canEditOwnDoc = subject.isPermitted("doc:write:123" ); subject.checkRole("admin" ); subject.checkPermissions("doc:read" , "doc:write" );
WildcardPermission 扩展规则 :
"doc:read,write:123" 等同于同时具有 doc:read:123 和 doc:write:123 两个权限。
使用 * 表示“全部”资源或动作,例 "doc:*:*" 表示所有文档的全部权限。
注解式授权(方法/控制器上) Shiro 提供一系列注解(@RequiresAuthentication, @RequiresRoles, @RequiresPermissions 等),配合 AOP,可直接在方法或控制器入口处声明权限:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import org.apache.shiro.authz.annotation.*;@RequiresAuthentication public class DocumentService { @RequiresRoles(value = {"admin", "op"}, logical = Logical.OR) @RequiresPermissions("doc:write") public void updateDoc (String id, String content) { } @RequiresPermissions(value = {"doc:read", "doc:preview"}, logical = Logical.OR) public String view (String id) { } }
在 Spring Boot 项目中,使用 shiro-spring-boot-web-starter 时,上述注解默认已启用,无需额外配置。
Jakarta EE 环境可引入 shiro-jakarta-ee 模块,使上述注解支持 CDI/EJB。
注解中的 @RequiresRoles 和 @RequiresPermissions 默认采用 AND 语义;若需要 OR 语义,必须明确设置 logical = Logical.OR。
URL 级拦截(Filter Chain) Web 应用可通过 [urls] 配置段(INI 文件)或 Java/Spring 配置(如 ShiroFilterChainDefinition Bean)来定义 URL 与权限规则之间的关系:
shiro.ini 配置示例 1 2 3 4 5 6 7 8 9 10 11 12 [urls] /assets/** = anon /login = anon /logout = logout /api/docs/** = authc, perms["doc:read"] /api/admin/** = authc, roles[admin] /** = authc
URL 匹配规则说明:
Spring Boot 下的配置 在 Spring Boot 环境中,定义如下 Java 配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration class ShiroConfig { @Bean ShiroFilterChainDefinition shiroFilterChainDefinition () { var chain = new DefaultShiroFilterChainDefinition (); chain.addPathDefinition("/assets/**" , "anon" ); chain.addPathDefinition("/logout" , "logout" ); chain.addPathDefinition("/api/docs/**" , "authc, perms[\"doc:read\"]" ); chain.addPathDefinition("/api/admin/**" , "authc, roles[admin]" ); chain.addPathDefinition("/**" , "authc" ); return chain; } }
在使用 shiro-spring-boot-web-starter 的场景中,必须至少提供一个 ShiroFilterChainDefinition Bean 来定义 URL 拦截规则。
此 Bean 可与其他 Shiro 组件(如 Realm、SessionManager、SecurityManager)共同定义在同一个 ShiroConfig 类中。
集成到 Java Web 项目 “纯 Servlet 模式的 Shiro”和“Spring 集成下的 Shiro” 在“入口、装配方式、配置来源、生命周期”上有明显差异,但核心机制(过滤器链 + 路径匹配 + SecurityManager)是同一套 。
不管是 Servlet 还是 Spring,Shiro 都是:请求 → 按 URL 匹配链 → 顺序执行链上过滤器 → 交还原始链 。URL 特定链、链内参数(如 roles[admin])、以及“顺序即语义”这些原则完全一致。
维度
纯 Servlet(web.xml/INI)
Spring 集成(XML/JavaConfig/Boot)
主入口(Master Filter)
在 web.xml 里直接声明 org.apache.shiro.web.servlet.ShiroFilter(或更早的 IniShiroFilter);通常配合 EnvironmentLoaderListener 启动 Shiro WebEnvironment。
在 web.xml 里用 DelegatingFilterProxy 映射到 Spring 容器中的 shiroFilter Bean ;真正的 Filter 由 ShiroFilterFactoryBean 组装返回。可选 targetFilterLifecycle 参数。
配置来源
多用 shiro.ini:[main] 里声明过滤器/Realm/安全管理器等,[urls] 里写 URL → 链定义 。也可自定义 WebEnvironment。
用 Spring Bean 配置:ShiroFilterFactoryBean 接收 securityManager、filters(自定义 Filter Bean)和 filterChainDefinitionMap(URL → 链 )。Spring Boot 用 starter 自动装配,仍然走这条链路。
过滤器链的构建
INI 由 Ini* 工厂读取并构造 FilterChainResolver/Manager;路径规则与链定义在 INI 中。
ShiroFilterFactoryBean#createInstance() 内部创建 DefaultFilterChainManager:注册内置 + 自定义过滤器、应用全局属性、解析 filterChainDefinitionMap 逐条 createChain(...)。
全局过滤器(global filters)
自 1.6+ 支持全局过滤器,默认包含 invalidRequest;会作用于所有路径 (包括未配置的路径)。可在 INI 或环境中定制/关闭。
同样支持;在 Spring 集成里,ShiroFilterFactoryBean 会把全局过滤器名称下发到 FilterChainManager,创建链时会先追加全局过滤器 再追加该 URL 的链。
生命周期与初始化
容器直接管理 Filter#init/destroy;EnvironmentLoaderListener 初始化并放置 WebEnvironment(含 SecurityManager)到 ServletContext。
ShiroFilterFactoryBean 负责装配 但默认**不在此处调用自定义过滤器的 init()**(交给 Spring 生命周期);主 Filter 以 Bean 形式被 DelegatingFilterProxy 代理。
注解/AOP
可用,但需自己选择 AOP 技术(AspectJ/Guice 等)。
与 Spring 配合天然顺手,启用 Shiro 的 Spring 拦截器/Advisor 即可。
集成到 Servlet 容器 web.xml 接入把 Shiro 的环境监听器与过滤器挂上去。放在 WEB-INF/web.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <web-app xmlns ="http://java.sun.com/xml/ns/javaee" version ="3.0" > <listener > <listener-class > org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class > </listener > <context-param > <param-name > shiroConfigLocations</param-name > <param-value > classpath:shiro.ini, /WEB-INF/shiro.ini</param-value > </context-param > <filter > <filter-name > ShiroFilter</filter-name > <filter-class > org.apache.shiro.web.servlet.ShiroFilter</filter-class > </filter > <filter-mapping > <filter-name > ShiroFilter</filter-name > <url-pattern > /*</url-pattern > <dispatcher > REQUEST</dispatcher > <dispatcher > FORWARD</dispatcher > <dispatcher > INCLUDE</dispatcher > <dispatcher > ERROR</dispatcher > </filter-mapping > </web-app >
顺序要点: ShiroFilter 要在你应用里其他“需要被保护”的 Servlet/Filter 前执行(通常放最前)。Jetty 与 Tomcat 一致,因为它们都遵守 web.xml 规范。
shiro.ini 配置
[main] 里放“对象图”和全局组件;
[urls] 里放 URL 过滤链;
(可选的)[users]/[roles] 仅用于 IniRealm 快速演示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 [main] iniRealm = org.apache.shiro.realm.text.IniRealmsecurityManager.realms = $iniRealm cacheManager = org.apache.shiro.cache.ehcache.EhCacheManagercacheManager.cacheManagerConfigFile = classpath:ehcache-shiro.xmlsecurityManager.cacheManager = $cacheManager sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManagersessionManager.sessionIdUrlRewritingEnabled = false sessionIdCookie = org.apache.shiro.web.servlet.SimpleCookiesessionIdCookie.name = JSESSIONIDsessionIdCookie.httpOnly = true sessionIdCookie.path = /sessionManager.sessionIdCookie = $sessionIdCookie securityManager.sessionManager = $sessionManager rememberMeCookie = org.apache.shiro.web.servlet.SimpleCookierememberMeCookie.name = rememberMerememberMeCookie.httpOnly = true rememberMeCookie.maxAge = 2592000 rememberMeManager = org.apache.shiro.web.mgt.CookieRememberMeManagerrememberMeManager.cookie = $rememberMeCookie securityManager.rememberMeManager = $rememberMeManager authc = org.apache.shiro.web.filter.authc.FormAuthenticationFilterauthc.loginUrl = /loginlogout.redirectUrl = /loginperms.unauthorizedUrl = /403 roles.unauthorizedUrl = /403 [urls] /assets/** = anon /login = anon /logout = logout /api/docs/** = authc, perms["doc:read"] /api/admin/**= authc, roles[admin] /** = authc [users] admin = password123, adminalice = alicePwd, user[roles] admin = *user = doc:read
Servlet 中使用 Subject ShiroFilter 已经把当前请求与 Subject 绑定好,任意位置 都可:
1 2 3 4 5 6 7 import org.apache.shiro.SecurityUtils;import org.apache.shiro.subject.Subject;Subject subject = SecurityUtils.getSubject();Object principal = subject.getPrincipal(); boolean isAdmin = subject.hasRole("admin" ); boolean canRead = subject.isPermitted("doc:read" );
例如下面这段代码是自定义登录(自己处理表单 POST):
1 2 3 4 5 6 7 8 9 10 11 12 13 import org.apache.shiro.authc.*;protected void doPost (HttpServletRequest req, HttpServletResponse resp) throws IOException { String u = req.getParameter("username" ); String p = req.getParameter("password" ); Subject s = SecurityUtils.getSubject(); try { s.login(new UsernamePasswordToken (u, p)); resp.sendRedirect(req.getContextPath() + "/" ); } catch (AuthenticationException e) { resp.sendError(401 , "login failed" ); } }
除了在代码中自己实现登录逻辑,我们还可以把“登录这件事”完全交给 Shiro 自带的表单认证过滤器(FormAuthenticationFilter,简称 authc)来做 :
1 2 3 4 5 6 7 8 9 10 11 12 13 [main] authc = org.apache.shiro.web.filter.authc.FormAuthenticationFilterauthc.loginUrl = /login authc.successUrl = / authc.usernameParam = username authc.passwordParam = passwordauthc.rememberMeParam = rememberMe[urls] /assets/** = anon /logout = logout /login = authc /** = authc
对应前端提交的 form 表单应该是下面这个形式:
1 2 3 4 5 6 <form method ="post" action ="/login" > <input name ="username" > <input type ="password" name ="password" > <label > <input type ="checkbox" name ="rememberMe" > Remember me</label > <button > Login</button > </form >
集成到 Spring 项目 简单示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 ShiroDemo/ ├─ pom.xml ├─ src/ │ └─ main/ │ ├─ java/ │ │ └─ com/example/shirodemo/ │ │ ├─ ShiroDemoApplication.java # Spring Boot 启动类 │ │ ├─ ShiroConfig.java # Shiro 过滤器注册 + URL 过滤链(核心) │ │ ├─ DenyFilter.java # 自定义拦截过滤器:固定 401 │ │ └─ PingController.java # 两个演示端点:/open/ping 与 /secure/ping │ └─ resources/ │ └─ application.yml # 应用配置(端口等) └─ README.txt
效果:
1 2 curl http://localhost:8080/open/ping curl http://localhost:8080/secure/ping
pom 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd" > <modelVersion > 4.0.0</modelVersion > <groupId > com.example</groupId > <artifactId > ShiroDemo</artifactId > <version > 1.0.0</version > <packaging > jar</packaging > <properties > <java.version > 1.8</java.version > <maven.compiler.source > 1.8</maven.compiler.source > <maven.compiler.target > 1.8</maven.compiler.target > <spring-boot.version > 2.7.18</spring-boot.version > <shiro.version > 1.12.0</shiro.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-dependencies</artifactId > <version > ${spring-boot.version}</version > <type > pom</type > <scope > import</scope > </dependency > </dependencies > </dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.apache.shiro</groupId > <artifactId > shiro-spring</artifactId > <version > ${shiro.version}</version > </dependency > <dependency > <groupId > org.apache.shiro</groupId > <artifactId > shiro-web</artifactId > <version > ${shiro.version}</version > </dependency > </dependencies > <build > <finalName > ShiroDemo</finalName > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > <version > ${spring-boot.version}</version > <executions > <execution > <goals > <goal > repackage</goal > </goals > </execution > </executions > </plugin > <plugin > <artifactId > maven-compiler-plugin</artifactId > <version > 3.11.0</version > <configuration > <source > ${maven.compiler.source}</source > <target > ${maven.compiler.target}</target > <encoding > UTF-8</encoding > </configuration > </plugin > </plugins > </build > </project >
JavaConfig 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 package com.example.shirodemo;import org.apache.shiro.mgt.SecurityManager;import org.apache.shiro.spring.web.ShiroFilterFactoryBean;import org.apache.shiro.web.mgt.DefaultWebSecurityManager;import org.springframework.boot.web.servlet.FilterRegistrationBean;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.Ordered;import org.springframework.web.filter.DelegatingFilterProxy;import javax.servlet.Filter;import java.util.LinkedHashMap;import java.util.Map;@Configuration public class ShiroConfig { @Bean public SecurityManager securityManager () { return new DefaultWebSecurityManager (); } @Bean(name = "deny") public DenyFilter denyFilter () { return new DenyFilter (); } @Bean(name = "shiroFilter") public ShiroFilterFactoryBean shiroFilterFactoryBean (SecurityManager securityManager, DenyFilter deny) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean (); bean.setSecurityManager(securityManager); Map<String, Filter> filters = new LinkedHashMap <>(); filters.put("deny" , deny); bean.setFilters(filters); Map<String, String> chain = new LinkedHashMap <>(); chain.put("/open/**" , "anon" ); chain.put("/secure/**" , "deny" ); chain.put("/**" , "anon" ); bean.setFilterChainDefinitionMap(chain); return bean; } @Bean public FilterRegistrationBean<DelegatingFilterProxy> shiroFilterRegistration () { FilterRegistrationBean<DelegatingFilterProxy> reg = new FilterRegistrationBean <>(); DelegatingFilterProxy proxy = new DelegatingFilterProxy ("shiroFilter" ); proxy.setTargetFilterLifecycle(true ); reg.setFilter(proxy); reg.addUrlPatterns("/*" ); reg.setName("shiroFilter" ); reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 10 ); return reg; } }
其中 SecurityManager 是 Shiro 最为核心的安全管理器,我们可以在其中设置 Realm。
1 2 3 4 5 6 7 @Bean public SecurityManager securityManager (UserAuthorizingRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager (); securityManager.setRealm(userRealm); securityManager.setRememberMeManager(null ); return securityManager; }
Filter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.shirodemo;import org.apache.shiro.web.filter.AccessControlFilter;import javax.servlet.ServletRequest;import javax.servlet.ServletResponse;import javax.servlet.http.HttpServletResponse;public class DenyFilter extends AccessControlFilter { @Override protected boolean isAccessAllowed (ServletRequest request, ServletResponse response, Object mappedValue) { return false ; } @Override protected boolean onAccessDenied (ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse r = (HttpServletResponse) response; r.setStatus(HttpServletResponse.SC_UNAUTHORIZED); r.setContentType("text/plain;charset=UTF-8" ); r.getWriter().write("Blocked by Shiro URL filter" ); return false ; } }
Controller 1 2 3 4 5 6 7 8 9 10 11 12 13 package com.example.shirodemo;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class PingController { @GetMapping("/open/ping") public String open () { return "open ok" ; } @GetMapping("/secure/ping") public String secure () { return "secure ok (should be blocked by Shiro)" ; } }
Application 1 2 3 4 5 6 7 8 9 10 11 package com.example.shirodemo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication public class ShiroDemoApplication { public static void main (String[] args) { SpringApplication.run(ShiroDemoApplication.class, args); } }
application.yml 配置监听端口:
控制器中使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package demo;import org.apache.shiro.authz.annotation.RequiresPermissions;import org.apache.shiro.authz.annotation.RequiresRoles;import org.springframework.web.bind.annotation.*;import org.springframework.stereotype.Controller;@RestController @RequestMapping("/api") class ApiController { @RequiresPermissions("doc:read") @GetMapping("/docs/{id}") public String read (@PathVariable String id) { return "doc " + id; } @RequiresRoles("admin") @DeleteMapping("/admin/docs/{id}") public String del (@PathVariable String id) { return "deleted " + id; } } @Controller class PageController { @GetMapping("/") public String index () { return "index" ; } @GetMapping("/403") public String e403 () { return "403" ; } }
Shiro 权限绕过 https://southsea.st/Study-Unauthorized-Shiro/
CVE编号
漏洞说明
漏洞版本
CVE-2016-6802
Context Path 路径标准化导致绕过
shrio <1.3.2
CVE-2020-1957
Spring 与 Shiro 对于 “/“ 和 “;” 处理差异导致绕过
Shiro <= 1.5.1
CVE-2020-11989
Shiro 二次解码导致的绕过以及 ContextPath 使用 “;” 的绕过
shiro < 1.5.3
CVE-2020-13933
由于 Shiro 与 Spring 处理路径时 URL 解码和路径标准化顺序不一致 导致的使用 “%3b” 的绕过
shiro < 1.6.0
CVE-2020-17510
由于 Shiro 与 Spring 处理路径时 URL 解码和路径标准化顺序不一致 导致的使用 “%2e” 的绕过
Shiro < 1.7.0
CVE-2020-17523
Shiro 匹配鉴权路径时会对分隔的 token 进行 trim 操作 导致的使用 “%20” 的绕过
Shiro <1.7.1
基本概念 Servlet 路径规范
RequestURL = scheme://host:port + RequestURI RequestURI = contextPath + servletPath + pathInfo
方法
定义
典型返回
getContextPath()
Web 应用的上下文路径 (以 / 开头),如果应用部署在根上下文 ,则返回空字符串 ""(不是 /)。
"/demo" 或 ""
getServletPath()
匹配到当前 Servlet 映射的那一段路径 (不含 host、端口、contextPath,也不含 pathInfo)。取决于映射类型:路径前缀 /admin/*、精确 /admin/test、扩展 *.do、默认 /。
例:"/admin"、"/admin/test"、"/login.do"、或 ""(映射 / 时)
getPathInfo()
位于 servletPath 之后 、在查询串之前的“额外路径” ;如果没有额外部分,则返回 **null**。
例:"/test"、null
getRequestURI()
去掉协议/主机/端口 后的完整请求路径(包含 contextPath、servletPath、pathInfo),不含查询串。
例:"/demo/admin/test"
getRequestURL()
完整 URL(协议 + 主机 + 端口 + RequestURI ),不含查询串。返回值类型是 StringBuffer。
例:"http://localhost:9090/demo/admin/test"
getQueryString()
? 后面的查询参数字符串;没有则 null。
例:"q=1&x=y" 或 null
getPathInfo() 只有在前缀匹配 (/foo/*)或默认匹配 (/)且请求路径还有多出的那一截 时才会返回非 null。
精确匹配 (/foo)和后缀匹配 (*.do、*.action)下,getPathInfo() 一律返回 null。
对于前缀匹配,如果请求恰好是 /foo(无多余部分)则是 null;若是 /foo/(尾部多一个 /),在 Tomcat 等实现里常见返回 "/"。
Servlet 规范要求容器在提取 pathInfo 前对 URL 做规范化(canonicalize)。Tomcat 的 HttpServletRequest 文档也明确了这点。
示例: 非根上下文 + 路径前缀映射
部署 :应用上下文 /demo
Servlet 映射 :/admin/*
请求 :http://localhost:9090/demo/admin/test?x=1
方法
返回
getContextPath()
"/demo"
getServletPath()
"/admin"
getPathInfo()
"/test"
getRequestURI()
"/demo/admin/test"
getRequestURL()
"http://localhost:9090/demo/admin/test"
getQueryString()
"x=1"
Tomcat 的路径处理 Tomcat 的路径处理的核心逻辑位于 org.apache.catalina.connector.CoyoteAdapter#postParseRequest 函数,调用栈如下:
1 2 3 4 5 6 7 8 9 10 11 at org.apache.catalina.connector.CoyoteAdapter.postParseRequest(CoyoteAdapter.java:567) at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341) at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:617) at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:934) at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1690) at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) at java.lang.Thread.run(Thread.java:748)
其中核心处理逻辑如下(主要走的是 undecodedURI.getType() == MessageBytes.T_BYTES 分支):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 MessageBytes decodedURI = req.decodedURI();if (req.method().equals("CONNECT" )) { response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, sm.getString("coyoteAdapter.connect" )); } else { if (undecodedURI.getType() == MessageBytes.T_BYTES) { decodedURI.duplicate(undecodedURI); parsePathParameters(req, request); try { req.getURLDecoder().convert(decodedURI.getByteChunk(), connector.getEncodedSolidusHandlingInternal()); } catch (IOException ioe) { response.sendError(400 , sm.getString("coyoteAdapter.invalidURIWithMessage" , ioe.getMessage())); } if (normalize(req.decodedURI())) { convertURI(decodedURI, request); if (!checkNormalize(req.decodedURI())) { response.sendError(400 , "Invalid URI" ); } } else { response.sendError(400 , sm.getString("coyoteAdapter.invalidURI" )); } } else { decodedURI.toChars(); CharChunk uriCC = decodedURI.getCharChunk(); int semicolon = uriCC.indexOf(';' ); if (semicolon > 0 ) { decodedURI.setChars(uriCC.getBuffer(), uriCC.getStart(), semicolon); } } }
解析路径参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 protected void parsePathParameters (org.apache.coyote.Request req, Request request) { req.decodedURI().toBytes(); ByteChunk uriBC = req.decodedURI().getByteChunk(); int semicolon = uriBC.indexOf(';' , 1 ); if (semicolon == -1 ) { return ; } Charset charset = connector.getURICharset(); if (log.isTraceEnabled()) { log.trace(sm.getString("coyoteAdapter.debug" , "uriBC" , uriBC.toString())); log.trace(sm.getString("coyoteAdapter.debug" , "semicolon" , String.valueOf(semicolon))); log.trace(sm.getString("coyoteAdapter.debug" , "enc" , charset.name())); } while (semicolon > -1 ) { int start = uriBC.getStart(); int end = uriBC.getEnd(); int pathParamStart = semicolon + 1 ; int pathParamEnd = ByteChunk.findBytes(uriBC.getBuffer(), start + pathParamStart, end, new byte [] { ';' , '/' }); String pv = null ; if (pathParamEnd >= 0 ) { if (charset != null ) { pv = new String (uriBC.getBuffer(), start + pathParamStart, pathParamEnd - pathParamStart, charset); } byte [] buf = uriBC.getBuffer(); for (int i = 0 ; i < end - start - pathParamEnd; i++) { buf[start + semicolon + i] = buf[start + i + pathParamEnd]; } uriBC.setBytes(buf, start, end - start - pathParamEnd + semicolon); } else { if (charset != null ) { pv = new String (uriBC.getBuffer(), start + pathParamStart, (end - start) - pathParamStart, charset); } uriBC.setEnd(start + semicolon); } if (log.isTraceEnabled()) { log.trace(sm.getString("coyoteAdapter.debug" , "pathParamStart" , String.valueOf(pathParamStart))); log.trace(sm.getString("coyoteAdapter.debug" , "pathParamEnd" , String.valueOf(pathParamEnd))); log.trace(sm.getString("coyoteAdapter.debug" , "pv" , pv)); } if (pv != null ) { int equals = pv.indexOf('=' ); if (equals > -1 ) { String name = pv.substring(0 , equals); String value = pv.substring(equals + 1 ); request.addPathParameter(name, value); if (log.isTraceEnabled()) { log.trace(sm.getString("coyoteAdapter.debug" , "equals" , String.valueOf(equals))); log.trace(sm.getString("coyoteAdapter.debug" , "name" , name)); log.trace(sm.getString("coyoteAdapter.debug" , "value" , value)); } } } semicolon = uriBC.indexOf(';' , semicolon); } }
这段代码的主要逻辑是:
定位分号参数 :从第 1 个字符后开始找 ;(第 0 位应是 /)。没有就直接返回。
**按段取出 name=value**:每遇到一个 ;,把后面到下一个 ; 或下一个 / 之间的字节当作一个“路径参数”片段(典型是 jsessionid)。
写入请求的路径参数表 :如果是 name=value 形式,就存入 request.addPathParameter(name, value),供后续(比如 URL 会话跟踪)使用。
原地删掉分号参数 :通过把后续字节左移 ,从 req.decodedURI() 的字节缓冲里把这段 ;name=value 真正移除;如果这是最后一个参数,就把缓冲区截断 到分号处。
继续扫描 :在“已左移”的缓冲上,从当前分号位置继续找下一个 ;,直到没有为止。
“路径参数”(path parameters)就是写在 URL 路径“段(segment)”里的分号分隔的键值对 ,长这样:
1 2 3 /books;lang=zh;edition=2/chapter;id=3 ^^^^^^^^^^^^ ^^^^^ 这些都是路径参数(也叫 matrix 参数)
它跟 ? 后面的查询参数 不一样:路径参数属于某个路径段本身 ;查询参数作用于整条 URL 。
早期标准 RFC 2396(已被废弃) 明确给了语法:一个路径段可以跟随若干个以分号 ; 引入的参数(segment = *pchar *( ";" param )),并举例说明“每个路径段都可以包含以 ; 表示的一串参数”。这正是“路径参数/矩阵参数”的来源。
现行标准 RFC 3986 取消了“路径参数”这个专门语法,但保留了分号 ; 是保留字符(sub-delim)并允许出现在路径字符集中(pchar 包含 sub-delims,而 sub-delims 里有 ;)。因此, 语法层面依然允许 ; 出现在路径段里 ,至于把它当“参数”来用,属于上层协议/框架的约定。
Servlet 规范(4.0 及以后) :当浏览器不收 cookie 时,容器可以用URL 重写 把会话 ID 放进路径参数 里,名字必须是 **jsessionid**,例如 http://www.example.com/catalog/index.html;jsessionid=1234。这也是 Tomcat 在你看到的 parsePathParameters() 里要把 ;jsessionid=... 剥离并记录到 request 的原因。
Tomcat 支持通过 URL 路径中的 ;jsessionid=... 传递会话 ID(当 Cookie 不可用时)。这一步把它取出并记录,并把 ;... 从真实路径里去掉,避免影响后续的百分号解码 、规范化 (处理 ./..)和请求映射 (Host/Context/Wrapper 匹配)。
例如:
注意这是在百分号解码(%xx)之前 做的:只识别字面量 ;。如果有人用 %3B(编码后的分号),这里不会当分号参数看待;后面解码成 ; 时,它就只是普通字符了(不会再被当作路径参数)。
URI 解码 解析路径参数之后会进行 URI 解码操作。
1 2 3 4 5 6 7 8 9 10 try { req.getURLDecoder().convert(decodedURI.getByteChunk(), connector.getEncodedSolidusHandlingInternal()); } catch (IOException ioe) { response.sendError(400 , sm.getString("coyoteAdapter.invalidURIWithMessage" , ioe.getMessage())); }
因为在 Servlet/Java EE 的语义里,“谁来把原始请求行变成可用的 应用路径 ”属于应用容器的职责**。Tomcat 既是 HTTP 端点(Coyote 连接器),又是 Servlet 容器(Catalina)。无论前面有没有 Nginx/Apache 反代, Tomcat 都必须自己做一次“可控、可配置、可审计”的 URI 解码与规范化**,以保证映射、安全校验、会话跟踪等行为一致可靠。
不同前端(Apache httpd / Nginx / F5 / CDN)对 %xx 解码、编码斜杠 %2F、反斜杠 \、重复斜杠 等处理差异很大,甚至能配置成提前解码 、部分解码 或不解码 。
如果把这事完全交给前端,Tomcat 收到的请求就可能在不同环境下行为不同 → 应用不可移植 、安全审计困难 。
Tomcat 在你看到的代码里做了三件关键安全动作(都绕不开自己解码):
按自己的策略解码 :req.getURLDecoder().convert(..., encodedSolidusHandling)
明确控制 %2F(编码的 /)是解码 、拒绝 还是透传 ;
拒绝非法 %xx、NUL 字节等。
路径规范化 : 统一处理 //、/./、/../、反斜杠 \ → 防目录穿越与旁路 。
解码后再二次校验 :convertURI(...) 把字节转字符后 **再 checkNormalize()**,防止“先解码后出现新危险片段”的绕过(典型 双重编码 技巧:..%2f、%2e%2e%2f 等)。
若完全信任前端,最常见坑就是双重解码 :前端解一次、容器再解一次,/%252e%252e/ → /%2e%2e/ → /../,把原本被挡住的路径“洗”成可穿越的形式。
实际进行解码操作的 org.apache.tomcat.util.buf.UDecoder#convert 函数的具体过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 public void convert (ByteChunk mb, EncodedSolidusHandling encodedSolidusHandling) throws IOException { convert(mb, false , encodedSolidusHandling); } private void convert (ByteChunk mb, boolean query, EncodedSolidusHandling encodedSolidusHandling) throws IOException { int start = mb.getOffset(); byte [] buff = mb.getBytes(); int end = mb.getEnd(); int idx = ByteChunk.findByte(buff, start, end, (byte ) '%' ); int idx2 = -1 ; if (query) { idx2 = ByteChunk.findByte(buff, start, (idx >= 0 ? idx : end), (byte ) '+' ); } if (idx < 0 && idx2 < 0 ) { return ; } if ((idx2 >= 0 && idx2 < idx) || idx < 0 ) { idx = idx2; } for (int j = idx; j < end; j++, idx++) { if (buff[j] == '+' && query) { buff[idx] = (byte ) ' ' ; } else if (buff[j] != '%' ) { buff[idx] = buff[j]; } else { if (j + 2 >= end) { throw EXCEPTION_EOF; } byte b1 = buff[j + 1 ]; byte b2 = buff[j + 2 ]; if (!isHexDigit(b1) || !isHexDigit(b2)) { throw EXCEPTION_NOT_HEX_DIGIT; } j += 2 ; int res = x2c(b1, b2); if (res == '/' ) { switch (encodedSolidusHandling) { case DECODE: { buff[idx] = (byte ) res; break ; } case REJECT: { throw EXCEPTION_SLASH; } case PASS_THROUGH: { buff[idx++] = buff[j - 2 ]; buff[idx++] = buff[j - 1 ]; buff[idx] = buff[j]; } } } else { buff[idx] = (byte ) res; } } } mb.setEnd(idx); }
规范化 规范化阶段主要是把“已经做过一次 %xx 解码”的字节级路径,在原地规范成唯一、可比对、可映射且安全的形态 ,主要目的是挡住目录穿越/混淆,给后续“路径→应用”映射一个稳定输入。该阶段的核心函数是 normalize。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 public static boolean normalize (MessageBytes uriMB) { ByteChunk uriBC = uriMB.getByteChunk(); final byte [] b = uriBC.getBytes(); final int start = uriBC.getStart(); int end = uriBC.getEnd(); if (start == end) { return false ; } int pos = 0 ; int index = 0 ; if (b[start] != (byte ) '/' && b[start] != (byte ) '\\' ) { return false ; } for (pos = start; pos < end; pos++) { if (b[pos] == (byte ) '\\' ) { if (ALLOW_BACKSLASH) { b[pos] = (byte ) '/' ; } else { return false ; } } else if (b[pos] == (byte ) 0 ) { return false ; } } for (pos = start; pos < (end - 1 ); pos++) { if (b[pos] == (byte ) '/' ) { while ((pos + 1 < end) && (b[pos + 1 ] == (byte ) '/' )) { copyBytes(b, pos, pos + 1 , end - pos - 1 ); end--; } } } if (((end - start) >= 2 ) && (b[end - 1 ] == (byte ) '.' )) { if ((b[end - 2 ] == (byte ) '/' ) || ((b[end - 2 ] == (byte ) '.' ) && (b[end - 3 ] == (byte ) '/' ))) { b[end] = (byte ) '/' ; end++; } } uriBC.setEnd(end); index = 0 ; while (true ) { index = uriBC.indexOf("/./" , 0 , 3 , index); if (index < 0 ) { break ; } copyBytes(b, start + index, start + index + 2 , end - start - index - 2 ); end = end - 2 ; uriBC.setEnd(end); } index = 0 ; while (true ) { index = uriBC.indexOf("/../" , 0 , 4 , index); if (index < 0 ) { break ; } if (index == 0 ) { return false ; } int index2 = -1 ; for (pos = start + index - 1 ; (pos >= 0 ) && (index2 < 0 ); pos--) { if (b[pos] == (byte ) '/' ) { index2 = pos; } } copyBytes(b, start + index2, start + index + 3 , end - start - index - 3 ); end = end + index2 - index - 3 ; uriBC.setEnd(end); index = index2; } return true ; }
具体逻辑为:
入口校验
必须以 /(或 \)开头;空路径直接拒绝。
拒绝包含 NUL(\0)字节。
统一分隔符
视 ALLOW_BACKSLASH 配置:
true:把 \ 全部改成 /;
false:出现 \ 直接判为非法。
折叠连续斜杠://… 压成 /…。
补尾统一化
若以 /. 或 /.. 结尾,先在末尾补一个 /,便于下一步用同一规则处理(把 “结尾的点段”也变成标准的 "/./" 或 "/../" 形态)。
消去当前目录段
折叠父目录段
循环处理每个 "/../":回溯到它前一个 /,把 "/前一段/../" 整体删掉。
若要越过根(index==0),返回 false (拒绝)。
字符转换 这一步把前面“已经按容器规则做过一次 %xx 解码 + 规范化”的字节级路径 ,转换成字符级路径 (考虑 URIEncoding),为后续 checkNormalize() 二次校验、映射、过滤器/Servlet API 提供标准的 char 视图。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 protected void convertURI (MessageBytes uri, Request request) throws IOException { ByteChunk bc = uri.getByteChunk(); int length = bc.getLength(); CharChunk cc = uri.getCharChunk(); cc.allocate(length, -1 ); Charset charset = connector.getURICharset(); B2CConverter conv = request.getURIConverter(); if (conv == null ) { conv = new B2CConverter (charset, true ); request.setURIConverter(conv); } else { conv.recycle(); } try { conv.convert(bc, cc, true ); uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength()); } catch (IOException ioe) { request.getResponse().sendError(HttpServletResponse.SC_BAD_REQUEST); } }
规范化检查 这是解码后的二次校验 ,不做修改、只做判定;一旦返回 false,上层应当拒绝请求或报 400,防止通过“字符解码后再出现的 ../反斜杠/重复斜杠”等变体造成绕过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 public static boolean checkNormalize (MessageBytes uriMB) { CharChunk uriCC = uriMB.getCharChunk(); char [] c = uriCC.getChars(); int start = uriCC.getStart(); int end = uriCC.getEnd(); int pos = 0 ; for (pos = start; pos < end; pos++) { if (c[pos] == '\\' ) { return false ; } if (c[pos] == 0 ) { return false ; } } for (pos = start; pos < (end - 1 ); pos++) { if (c[pos] == '/' ) { if (c[pos + 1 ] == '/' ) { return false ; } } } if (((end - start) >= 2 ) && (c[end - 1 ] == '.' )) { if ((c[end - 2 ] == '/' ) || ((c[end - 2 ] == '.' ) && (c[end - 3 ] == '/' ))) { return false ; } } if (uriCC.indexOf("/./" , 0 , 3 , 0 ) >= 0 ) { return false ; } if (uriCC.indexOf("/../" , 0 , 4 , 0 ) >= 0 ) { return false ; } return true ; }
请求映射 完成 URL 解析处理之后会执行映射将请求的 URI 映射到对应的处理方法或者请求的资源上。
1 2 3 connector.getService().getMapper().map(serverName, decodedURI, version, request.getMappingData());
map 这个过程是通过 org.apache.catalina.mapper.Mapper#map 函数实现的,该函数实际调用 internalMap 函数实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public void map (MessageBytes host, MessageBytes uri, String version, MappingData mappingData) throws IOException { if (host.isNull()) { String defaultHostName = this .defaultHostName; if (defaultHostName == null ) { return ; } host.setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0 , 0 ); host.getCharChunk().append(defaultHostName); } host.toChars(); uri.toChars(); internalMap(host.getCharChunk(), uri.getCharChunk(), version, mappingData); }
internalMap internalMap 函数把“主机名 + URI”路由到具体应用与 Servlet 。先定 Host,再找最匹配的 Context(应用),再选版本(并行部署时),最后交给 internalMapWrapper 做到具体 Servlet 的匹配。
防御性检查
如果 mappingData.host 已经被填过,直接 AssertionError(避免重复/错用导致结果不一致)。
Host 映射
先对 host 做不区分大小写的精确匹配 。
不命中时,尝试通配主机 :把第一个 . 之前的 label 暂时“切掉”(如 a.example.com→example.com)再匹配。
还不行就落到 defaultHost;如果也没有,返回 (无法继续)。
URI 空检查与准备
没有 URI 就没法做 Context/Wrapper 映射,返回 。
uri.setLimit(-1):取消 CharChunk 限制,方便后面临时截断 路径做查找。
Context(应用)映射:最长前缀匹配
在该 Host 下的 contexts 列表里,找与 uri 前缀匹配 最长的 context.name。
实现细节:用 find() 定位候选;若未命中,就按 / 逐级回退 (nthSlash / lastSlash)并临时缩短 uri 再找。
如果最终没找到,且 contexts[0].name 是空串 ""(ROOT 应用),就用 ROOT;否则返回 。
命中后写出 mappingData.contextPath。
Context 版本选择(并行部署)
一个 Context 可能有多个版本(context.versions)。
若有多个版本,把所有版本对象塞到 mappingData.contexts(供上层按会话再决策)。
若 version 参数非空,尝试精确命中 该版本;否则取“最新版本” (数组最后一个)。
写出 mappingData.context 与 contextSlashCount(后续做 Servlet 匹配用)。
Wrapper(Servlet)映射
如果目标版本 未暂停 (没在 warm-up/注册中),调用 internalMapWrapper(contextVersion, uri, mappingData),按规范顺序(精确→前缀→扩展→欢迎→默认)匹配到具体 Servlet。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 @SuppressWarnings("deprecation") private void internalMap (CharChunk host, CharChunk uri, String version, MappingData mappingData) throws IOException { if (mappingData.host != null ) { throw new AssertionError (); } MappedHost[] hosts = this .hosts; MappedHost mappedHost = exactFindIgnoreCase(hosts, host); if (mappedHost == null ) { int firstDot = host.indexOf('.' ); if (firstDot > -1 ) { int offset = host.getOffset(); try { host.setOffset(firstDot + offset); mappedHost = exactFindIgnoreCase(hosts, host); } finally { host.setOffset(offset); } } if (mappedHost == null ) { mappedHost = defaultHost; if (mappedHost == null ) { return ; } } } mappingData.host = mappedHost.object; if (uri.isNull()) { return ; } uri.setLimit(-1 ); ContextList contextList = mappedHost.contextList; MappedContext[] contexts = contextList.contexts; int pos = find(contexts, uri); if (pos == -1 ) { return ; } int lastSlash = -1 ; int uriEnd = uri.getEnd(); int length = -1 ; boolean found = false ; MappedContext context = null ; while (pos >= 0 ) { context = contexts[pos]; if (uri.startsWith(context.name)) { length = context.name.length(); if (uri.getLength() == length) { found = true ; break ; } else if (uri.startsWithIgnoreCase("/" , length)) { found = true ; break ; } } if (lastSlash == -1 ) { lastSlash = nthSlash(uri, contextList.nesting + 1 ); } else { lastSlash = lastSlash(uri); } uri.setEnd(lastSlash); pos = find(contexts, uri); } uri.setEnd(uriEnd); if (!found) { if (contexts[0 ].name.equals("" )) { context = contexts[0 ]; } else { context = null ; } } if (context == null ) { return ; } mappingData.contextPath.setString(context.name); ContextVersion contextVersion = null ; ContextVersion[] contextVersions = context.versions; final int versionCount = contextVersions.length; if (versionCount > 1 ) { Context[] contextObjects = new Context [contextVersions.length]; for (int i = 0 ; i < contextObjects.length; i++) { contextObjects[i] = contextVersions[i].object; } mappingData.contexts = contextObjects; if (version != null ) { contextVersion = exactFind(contextVersions, version); } } if (contextVersion == null ) { contextVersion = contextVersions[versionCount - 1 ]; } mappingData.context = contextVersion.object; mappingData.contextSlashCount = contextVersion.slashCount; if (!contextVersion.isPaused()) { internalMapWrapper(contextVersion, uri, mappingData); } }
internalMapWrapper internalMapWrapper 把“context 内的相对路径”按 Servlet 规范的优先级,精确地路由到某个具体 Servlet(wrapper),必要时生成重定向或落到默认 Servlet。
具体过程为:
定位 servletPath 从 path 里剥掉 contextPath(用 setOffset(servletPath)),后续匹配都基于 servletPath。
规则 1:精确匹配(Exact) 在 exactWrappers 里找与 servletPath 完全相等的 url-pattern。
规则 2:前缀匹配(Wildcard,如 /foo/*) 若命中且是 JSP 通配 ,再看原始路径是否以 / 结尾:
以 / 结尾:清空当前匹配 ,转去走“欢迎页”逻辑(Bug 27664)。
否则:确定 wrapperPath,清空 pathInfo(Bug 27704)。
Context 根重定向 若仍未命中、且 servletPath 为空、且开启了 context root redirect ,把路径重定向到 / 并返回。
规则 3:扩展名匹配(Extension,如 *.jsp/*.do) 在 extensionWrappers 里按后缀匹配(允许作为欢迎资源的一部分)。
规则 4:欢迎资源(Welcome files) 仅当还没匹配到且需要检查欢迎页时(JSP 通配触发或路径以 / 结尾): 逐个把 index.html / index.jsp / index.do… 拼到路径末尾,依次尝试:
4a 精确匹配
4b 前缀匹配
4c 物理文件存在 时,再试扩展匹配;若仍无匹配且有 defaultWrapper,就用它处理并设置 requestPath/wrapperPath。 做完后把 path 的 offset/end 复原。
欢迎文件处理·第二轮(无物理文件场景) 若还没命中,再按欢迎名做一轮仅扩展匹配 (比如 index.jsf/index.do 这类“没有真实文件”的映射)。
规则 7:默认 Servlet(DefaultServlet)兜底 若仍未命中且不在 JSP 欢迎模式:
有 defaultWrapper 就用之,并设置 requestPath/wrapperPath、matchType=DEFAULT。
目录重定向 :若资源存在且是目录且路径不以 / 结尾、并开启 directory redirect ,则拼上 / 设置 redirectPath;否则直接记录路径给默认 Servlet 处理。
收尾 无论匹配到什么,最后都会把 path 的 offset/end 恢复 ,避免影响后续处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 private void internalMapWrapper (ContextVersion contextVersion, CharChunk path, MappingData mappingData) throws IOException { int pathOffset = path.getOffset(); int pathEnd = path.getEnd(); boolean noServletPath = false ; int length = contextVersion.path.length(); if (length == (pathEnd - pathOffset)) { noServletPath = true ; } int servletPath = pathOffset + length; path.setOffset(servletPath); MappedWrapper[] exactWrappers = contextVersion.exactWrappers; internalMapExactWrapper(exactWrappers, path, mappingData); boolean checkJspWelcomeFiles = false ; MappedWrapper[] wildcardWrappers = contextVersion.wildcardWrappers; if (mappingData.wrapper == null ) { internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData); if (mappingData.wrapper != null && mappingData.jspWildCard) { char [] buf = path.getBuffer(); if (buf[pathEnd - 1 ] == '/' ) { mappingData.wrapper = null ; checkJspWelcomeFiles = true ; } else { mappingData.wrapperPath.setChars(buf, path.getStart(), path.getLength()); mappingData.pathInfo.recycle(); } } } if (mappingData.wrapper == null && noServletPath && contextVersion.object.getMapperContextRootRedirectEnabled()) { path.append('/' ); pathEnd = path.getEnd(); mappingData.redirectPath.setChars(path.getBuffer(), pathOffset, pathEnd - pathOffset); path.setEnd(pathEnd - 1 ); return ; } MappedWrapper[] extensionWrappers = contextVersion.extensionWrappers; if (mappingData.wrapper == null && !checkJspWelcomeFiles) { internalMapExtensionWrapper(extensionWrappers, path, mappingData, true ); } if (mappingData.wrapper == null ) { boolean checkWelcomeFiles = checkJspWelcomeFiles; if (!checkWelcomeFiles) { char [] buf = path.getBuffer(); checkWelcomeFiles = (buf[pathEnd - 1 ] == '/' ); } if (checkWelcomeFiles) { for (int i = 0 ; (i < contextVersion.welcomeResources.length) && (mappingData.wrapper == null ); i++) { path.setOffset(pathOffset); path.setEnd(pathEnd); path.append(contextVersion.welcomeResources[i], 0 , contextVersion.welcomeResources[i].length()); path.setOffset(servletPath); internalMapExactWrapper(exactWrappers, path, mappingData); if (mappingData.wrapper == null ) { internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData); } if (mappingData.wrapper == null && contextVersion.resources != null ) { String pathStr = path.toString(); WebResource file = contextVersion.resources.getResource(pathStr); if (file != null && file.isFile()) { internalMapExtensionWrapper(extensionWrappers, path, mappingData, true ); if (mappingData.wrapper == null && contextVersion.defaultWrapper != null ) { mappingData.wrapper = contextVersion.defaultWrapper.object; mappingData.requestPath.setChars(path.getBuffer(), path.getStart(), path.getLength()); mappingData.wrapperPath.setChars(path.getBuffer(), path.getStart(), path.getLength()); mappingData.requestPath.setString(pathStr); mappingData.wrapperPath.setString(pathStr); } } } } path.setOffset(servletPath); path.setEnd(pathEnd); } } if (mappingData.wrapper == null ) { boolean checkWelcomeFiles = checkJspWelcomeFiles; if (!checkWelcomeFiles) { char [] buf = path.getBuffer(); checkWelcomeFiles = (buf[pathEnd - 1 ] == '/' ); } if (checkWelcomeFiles) { for (int i = 0 ; (i < contextVersion.welcomeResources.length) && (mappingData.wrapper == null ); i++) { path.setOffset(pathOffset); path.setEnd(pathEnd); path.append(contextVersion.welcomeResources[i], 0 , contextVersion.welcomeResources[i].length()); path.setOffset(servletPath); internalMapExtensionWrapper(extensionWrappers, path, mappingData, false ); } path.setOffset(servletPath); path.setEnd(pathEnd); } } if (mappingData.wrapper == null && !checkJspWelcomeFiles) { if (contextVersion.defaultWrapper != null ) { mappingData.wrapper = contextVersion.defaultWrapper.object; mappingData.requestPath.setChars(path.getBuffer(), path.getStart(), path.getLength()); mappingData.wrapperPath.setChars(path.getBuffer(), path.getStart(), path.getLength()); mappingData.matchType = ApplicationMappingMatch.DEFAULT; } char [] buf = path.getBuffer(); if (contextVersion.resources != null && buf[pathEnd - 1 ] != '/' ) { String pathStr = path.toString(); if (contextVersion.object.getMapperDirectoryRedirectEnabled()) { WebResource file; if (pathStr.length() == 0 ) { file = contextVersion.resources.getResource("/" ); } else { file = contextVersion.resources.getResource(pathStr); } if (file != null && file.isDirectory()) { path.setOffset(pathOffset); path.append('/' ); mappingData.redirectPath.setChars(path.getBuffer(), path.getStart(), path.getLength()); } else { mappingData.requestPath.setString(pathStr); mappingData.wrapperPath.setString(pathStr); } } else { mappingData.requestPath.setString(pathStr); mappingData.wrapperPath.setString(pathStr); } } } path.setOffset(pathOffset); path.setEnd(pathEnd); }
Shiro 解析流程 这里以 Spring 为例。
初始化流程 org.apache.shiro.spring.web.ShiroFilterFactoryBean 实现了 FactoryBean 接口,那么 Spring 在初始化的时候必然会调用 ShiroFilterFactoryBean 的 getObject() 获取实例,而 ShiroFilterFactoryBean 也在此时做了一系列初始化操作。
在 getObject() 中会调用 createInstance() 方法。
1 2 3 4 5 6 public AbstractShiroFilter getObject () throws Exception { if (instance == null ) { instance = createInstance(); } return instance; }
createInstance 实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 protected AbstractShiroFilter createInstance () throws Exception { 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(); PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver (); chainResolver.setFilterChainManager(manager); return new SpringShiroFilter ( (WebSecurityManager) securityManager, chainResolver, getShiroFilterConfiguration() ); }
这里面首先获取了我们在 ShiroConfig 中注入好参数的 SecurityManager。然后创建了一个 FilterChainManager,这个类看名字就知道是用来管理和操作过滤器执行链的,我们来看它的创建方法 createFilterChainManager。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 protected FilterChainManager createFilterChainManager () { DefaultFilterChainManager manager = new DefaultFilterChainManager (); Map<String, Filter> defaultFilters = manager.getFilters(); for (Filter filter : defaultFilters.values()) { applyGlobalPropertiesIfNecessary(filter); } Map<String, Filter> filters = getFilters(); if (!CollectionUtils.isEmpty(filters)) { for (Map.Entry<String, Filter> entry : filters.entrySet()) { String name = entry.getKey(); Filter filter = entry.getValue(); applyGlobalPropertiesIfNecessary(filter); if (filter instanceof Nameable) { ((Nameable) filter).setName(name); } manager.addFilter(name, filter, false ); } } manager.setGlobalFilters(this .globalFilters); Map<String, String> chains = getFilterChainDefinitionMap(); if (!CollectionUtils.isEmpty(chains)) { for (Map.Entry<String, String> entry : chains.entrySet()) { String url = entry.getKey(); String chainDefinition = entry.getValue(); manager.createChain(url, chainDefinition); } } manager.createDefaultChain("/**" ); return manager; }
第一步 new 了一个 DefaultFilterChainManager,在它的构造方法中将 filters 和 filterChains 两个成员变量都初始化为一个能保持插入顺序的 LinkedHashMap 了,之后再调用 addDefaultFilters 添加Shiro内置的一些过滤器。
1 2 3 4 5 6 7 8 9 10 11 12 public DefaultFilterChainManager () { this .filters = new LinkedHashMap <String, Filter>(); this .filterChains = new LinkedHashMap <String, NamedFilterList>(); this .globalFilterNames = new ArrayList <>(); addDefaultFilters(false ); } protected void addDefaultFilters (boolean init) { for (DefaultFilter defaultFilter : DefaultFilter.values()) { addFilter(defaultFilter.name(), defaultFilter.newInstance(), init, false ); } }
applyGlobalPropertiesIfNecessary 方法遍历了每一个默认的过滤器并设置一些必要的全局属性。
1 2 3 4 5 6 7 8 9 private void applyGlobalPropertiesIfNecessary (Filter filter) { applyLoginUrlIfNecessary(filter); applySuccessUrlIfNecessary(filter); applyUnauthorizedUrlIfNecessary(filter); if (filter instanceof OncePerRequestFilter) { ((OncePerRequestFilter) filter).setFilterOncePerRequest(filterConfiguration.isFilterOncePerRequest()); } }
在这个方法中调用了三个方法,三个方法逻辑是一样的,分别是设置 loginUrl、successUrl 和 unauthorizedUrl,我们就看第一个 applyLoginUrlIfNecessary。
1 2 3 4 5 6 7 8 9 10 11 12 13 public static final String DEFAULT_LOGIN_URL = "/login.jsp" ;private void applyLoginUrlIfNecessary (Filter filter) { String loginUrl = getLoginUrl(); if (StringUtils.hasText(loginUrl) && (filter instanceof AccessControlFilter)) { AccessControlFilter acFilter = (AccessControlFilter) filter; String existingLoginUrl = acFilter.getLoginUrl(); if (AccessControlFilter.DEFAULT_LOGIN_URL.equals(existingLoginUrl)) { acFilter.setLoginUrl(loginUrl); } } }
看方法名就知道是要设置 loginUrl,如果我们配置了 loginUrl,那么会将 AccessControlFilter 中默认的 loginUrl 替换为我们设置的值,默认的 loginUrl 为 /login.jsp。后面两个方法道理一样,都是将我们设置的参数替换进去,只不过第三个认证失败跳转 URL 的默认值为 null。
这里的 getLoginUrl(); 是我们 shiroFilter Bean 中 setLoginUrl 的值。
执行回到 createFilterChainManager 代码中,Map<String, Filter> filters = getFilters; 这里是获取我们自定义的过滤器,默认是为空的,如果我们配置了自定义的过滤器,那么会将其添加到 filters 中。至此 filters 中包含着 Shiro 内置的过滤器和我们配置的所有过滤器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Map<String, Filter> filters = getFilters(); if (!CollectionUtils.isEmpty(filters)) { for (Map.Entry<String, Filter> entry : filters.entrySet()) { String name = entry.getKey(); Filter filter = entry.getValue(); applyGlobalPropertiesIfNecessary(filter); if (filter instanceof Nameable) { ((Nameable) filter).setName(name); } manager.addFilter(name, filter, false ); } }
下一步,遍历 filterChainDefinitionMap,这个 filterChainDefinitionMap 就是我们在 ShiroConfig 中注入进去的拦截规则配置。这里是根据我们配置的过滤器规则创建过滤器执行链。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 manager.setGlobalFilters(this .globalFilters); Map<String, String> chains = getFilterChainDefinitionMap(); if (!CollectionUtils.isEmpty(chains)) { for (Map.Entry<String, String> entry : chains.entrySet()) { String url = entry.getKey(); String chainDefinition = entry.getValue(); manager.createChain(url, chainDefinition); } } manager.createDefaultChain("/**" );
org.apache.shiro.web.filter.mgt.DefaultFilterChainManager#createChain 的 chainName 是我们配置的过滤路径,chainDefinition 是该路径对应的过滤器,通常我们都是一对一的配置,比如:filterMap.put("/login", "anon");,但看到这个方法我们知道了一个过滤路径其实是可以通过传入 ["filter1","filter2"...] 配置多个过滤器的。在这里会根据我们配置的过滤路径和过滤器映射关系一步步配置过滤器执行链。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public void createChain (String chainName, String chainDefinition) { if (!StringUtils.hasText(chainName)) { throw new NullPointerException ("chainName cannot be null or empty." ); } if (!StringUtils.hasText(chainDefinition)) { throw new NullPointerException ("chainDefinition cannot be null or empty." ); } if (log.isDebugEnabled()) { log.debug("Creating chain [" + chainName + "] with global filters " + globalFilterNames + " and from String definition [" + chainDefinition + "]" ); } if (!CollectionUtils.isEmpty(globalFilterNames)) { globalFilterNames.stream().forEach(filterName -> addToChain(chainName, filterName)); } String[] filterTokens = splitChainDefinition(chainDefinition); for (String token : filterTokens) { String[] nameConfigPair = toNameConfigPair(token); addToChain(chainName, nameConfigPair[0 ], nameConfigPair[1 ]); } }
addToChain 先从 filters 中根据 filterName 获取对应过滤器,然后 ensureChain 会先从 filterChains 根据 chainName 获取 NamedFilterList,获取不到就创建一个并添加到 filterChains 然后返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public void addToChain (String chainName, String filterName, String chainSpecificFilterConfig) { if (!StringUtils.hasText(chainName)) { throw new IllegalArgumentException ("chainName cannot be null or empty." ); } Filter filter = getFilter(filterName); if (filter == null ) { throw new IllegalArgumentException ("There is no filter with name '" + filterName + "' to apply to chain [" + chainName + "] in the pool of available Filters. Ensure a " + "filter with that name/path has first been registered with the addFilter method(s)." ); } applyChainConfig(chainName, filter, chainSpecificFilterConfig); NamedFilterList chain = ensureChain(chainName); chain.add(filter); }
因为过滤路径和过滤器是一对多的关系,所以 ensureChain 返回的 NamedFilterList 其实就是一个有着 name 属性的 List<Filter>,这个 name 保存的就是过滤路径,List 保存着我们配置的过滤器。获取到 NamedFilterList 后在将过滤器加入其中,这样过滤路径和过滤器映射关系就初始化好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class SimpleNamedFilterList implements NamedFilterList { private String name; private List<Filter> backingList; } protected NamedFilterList ensureChain (String chainName) { NamedFilterList chain = getChain(chainName); if (chain == null ) { chain = new SimpleNamedFilterList (chainName); this .filterChains.put(chainName, chain); } return chain; }
至此,createInstance 中的 createFilterChainManager 才算执行完成,它返回了一个 FilterChainManager 实例。之后再将这个 FilterChainManager 注入 PathMatchingFilterChainResolver 中,它是一个过滤器执行链解析器。
过滤实现 org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver中的方法不多,最为重要的是这个 getChain 方法。
getChain 方法做的事就是:按你配置的 URL 模式(保持配置顺序)找出“第一个匹配当前请求路径”的那条过滤器链,基于它创建一个“代理 FilterChain”,让这条链里的 Shiro 过滤器先执行,最后再回到容器原始链。 如果一个都匹配不上 ,它就返回 null,意味着不包裹 原始链(等同于这次请求不走任何 Shiro 链)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 请求到达 Servlet 容器 ↓ (容器链中的)Shiro 主过滤器(ShiroFilter / SpringShiroFilter) ↓ 调用 resolver.getChain(request, response, originalChain) [1] 取到 FilterChainManager(它掌握了“URL → 过滤器链”的映射) [2] 如果一个链都没配置(hasChains=false),直接返回 null [3] 取当前请求在应用内的路径(去掉 contextPath),如 "/api/user/1" [4] 依配置顺序遍历每个“路径模式”(如 "/static/**"、"/api/**"、"/**") ├─ 先用原路径匹配 ├─ 不匹配再尝试“去掉末尾 /”的两边重试(解决 "/docs" vs "/docs/") └─ 一旦匹配: → 调用 manager.proxy(originalChain, 该路径模式) → 返回一个“代理 FilterChain”(先跑 Shiro 链,再跑原生链) [5] 全部不匹配 → 返回 null ↓ 主过滤器拿到返回值: ├─ 若是代理链:执行“Shiro 过滤器链 → 原生链” └─ 若是 null:直接走原生链(这次请求不走任何 Shiro 过滤器)
要点
“第一个匹配生效” :配置顺序很重要,因为底层用的是 LinkedHashMap 保存链定义。
模式匹配默认是 Ant 风格 :? 匹配单字符、* 匹配一段、** 跨多段路径。
末尾斜杠的双重尝试 :既用原样路径比一次,也在去掉路径末尾 / 后再比一次,尽量兼容 /x 和 /x/。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public FilterChain getChain (ServletRequest request, ServletResponse response, FilterChain originalChain) { FilterChainManager filterChainManager = getFilterChainManager(); if (!filterChainManager.hasChains()) { return null ; } final String requestURI = getPathWithinApplication(request); final String requestURINoTrailingSlash = removeTrailingSlash(requestURI); for (String pathPattern : filterChainManager.getChainNames()) { if (pathMatches(pathPattern, requestURI)) { if (log.isTraceEnabled()) { log.trace("路径模式 [{}] 与请求 URI [{}] 匹配,使用对应的过滤链..." , pathPattern, Encode.forHtml(requestURI)); } return filterChainManager.proxy(originalChain, pathPattern); } else { pathPattern = removeTrailingSlash(pathPattern); if (pathMatches(pathPattern, requestURINoTrailingSlash)) { if (log.isTraceEnabled()) { log.trace("路径模式 [{}] 与请求 URI [{}] 匹配,使用对应的过滤链..." , pathPattern, Encode.forHtml(requestURINoTrailingSlash)); } return filterChainManager.proxy(originalChain, pathPattern); } } } return null ; }
其中 ServletRequest/ServletResponse 这俩形参就是这次请求的对象 ,Shiro 需要从中取出“应用内路径”(例如把 /app/admin/u 变成 /admin/u)去做匹配。
这一步发生在 Shiro 的主过滤器内部 ,不是 Tomcat 直接调用 getChain。准确说:Tomcat → 你的过滤器列表 →(到 Shiro 主过滤器)→ Shiro 内部再调用 getChain 来决定走哪条链 。
filterChainManager.getChainNames() 返回的是你配置的 URL 模式列表 (比如 /admin/**、/api/**、/**)。这些规则在内部用 LinkedHashMap 存着,因此遍历顺序 = 你的定义顺序 。先匹配到谁就用谁(FIRST MATCH WINS) 。
👉 所以如果你把 /** 写在最上面,它会截胡所有请求 ,后面的规则永远匹配不到。
这个 getChain 是一个请求到达 Tomcat 时,Tomcat 以责任链的形式调用了一系列 Filter,OncePerRequestFilter 就是众多 Filter 中的一个。它所实现的 doFilter 方法调用了自身的抽象方法 doFilterInternal,这个方法在它的子类 AbstractShiroFilter 中被实现了。
AbstractShiroFilter 继承了 Shiro 自己的 OncePerRequestFilter(注意:不是 Spring 的那个重名类),它保证一次请求只执行一次主逻辑。doFilter 里会调它的 doFilterInternal,而 doFilterInternal 再去问 getChain 要不要包一条 Shiro 代理链。
1 2 3 4 5 6 7 Tomcat 调用过滤器链 └─ ...(其他 Filter) └─ DelegatingFilterProxy("shiroFilter") └─ SpringShiroFilter (AbstractShiroFilter) └─ 调用 resolver.getChain(request, response, originalChain) ├─ 命中某个 URL 规则 → 返回“代理链”(先跑该规则绑定的 Shiro 过滤器们,再回到原始链) └─ 未命中 → 返回 null(本次不跑 Shiro 过滤器,直接走原始链)
整个调用栈如下:
1 2 3 4 5 6 7 8 9 10 11 12 at org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver.getChain(PathMatchingFilterChainResolver.java:98) at org.apache.shiro.web.servlet.AbstractShiroFilter.getExecutionChain(AbstractShiroFilter.java:424) at org.apache.shiro.web.servlet.AbstractShiroFilter.executeChain(AbstractShiroFilter.java:457) at org.apache.shiro.web.servlet.AbstractShiroFilter$1.call(AbstractShiroFilter.java:373) at org.apache.shiro.subject.support.SubjectCallable.doCall(SubjectCallable.java:90) at org.apache.shiro.subject.support.SubjectCallable.call(SubjectCallable.java:83) at org.apache.shiro.subject.support.DelegatingSubject.execute(DelegatingSubject.java:387) at org.apache.shiro.web.servlet.AbstractShiroFilter.doFilterInternal(AbstractShiroFilter.java:370) at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:154) at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354) at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
PathMatchingFilterChainResolver#getChain 就是被在 doFilterInternal 中被一步步调用的调用的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 protected void doFilterInternal (ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain) throws ServletException, IOException { Throwable t = null ; try { final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain); final ServletResponse response = prepareServletResponse(request, servletResponse, chain); final Subject subject = createSubject(request, response); subject.execute(new Callable () { public Object call () throws Exception { updateSessionLastAccessTime(request, response); executeChain(request, response, chain); return null ; } }); } catch (ExecutionException ex) { t = ex.getCause(); } catch (Throwable throwable) { t = throwable; } if (t != null ) { if (t instanceof ServletException) { throw (ServletException) t; } if (t instanceof IOException) { throw (IOException) t; } throw new ServletException ("Filtered request failed." , t); } }
这里 executeChain 先调用 getExecutionChain 获取过滤器,然后调用过滤器的 doFilter 方法。
getExecutionChain 通过 getFilterChainResolver 方法就拿到了前面提到的过滤器执行链解析器 PathMatchingFilterChainResolver,然后再调用它的 getChain 匹配获取过滤器,最终过滤器在 executeChain 中被执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 protected void executeChain (ServletRequest request, ServletResponse response, FilterChain origChain) throws IOException, ServletException { FilterChain chain = getExecutionChain(request, response, origChain); chain.doFilter(request, response); } protected FilterChain getExecutionChain (ServletRequest request, ServletResponse response, FilterChain origChain) { FilterChain chain = origChain; FilterChainResolver resolver = getFilterChainResolver(); if (resolver == null ) { log.debug("未配置 FilterChainResolver,直接使用原始 FilterChain。" ); return origChain; } FilterChain resolved = resolver.getChain(request, response, origChain); if (resolved != null ) { log.trace("已为当前请求解析到配置的 FilterChain(使用代理链)。" ); chain = resolved; } else { log.trace("当前请求未匹配到任何配置的 FilterChain,继续使用原始链。" ); } return chain; }
这里用枚举列出了所有 Shiro 内置过滤器的实例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public enum DefaultFilter { anon(AnonymousFilter.class), authc(FormAuthenticationFilter.class), authcBasic(BasicHttpAuthenticationFilter.class), authcBearer(BearerHttpAuthenticationFilter.class), logout(LogoutFilter.class), noSessionCreation(NoSessionCreationFilter.class), perms(PermissionsAuthorizationFilter.class), port(PortFilter.class), rest(HttpMethodPermissionFilter.class), roles(RolesAuthorizationFilter.class), ssl(SslFilter.class), user(UserFilter.class), invalidRequest(InvalidRequestFilter.class); }
anon (AnonymousFilter):直接放行,不做任何安全检查;常用于静态资源、公开页。写法:/public/** = anon。
authc (FormAuthenticationFilter):要求用户已登录,否则按 loginUrl 走表单登录流程(默认参数名 username/password/rememberMe 可改)。写法:/account/** = authc。
authcBasic (BasicHttpAuthenticationFilter):HTTP Basic 认证,用于接口/工具;会返回 401 并带 WWW-Authenticate。务必配合 HTTPS。写法:/api/** = authcBasic。
authcBearer (BearerHttpAuthenticationFilter):HTTP Bearer 令牌(Authorization: Bearer ...),更适合无状态 API;自 Shiro 1.5 起提供。写法:/api/** = noSessionCreation, authcBearer。
invalidRequest (InvalidRequestFilter):拦截“可疑”URL(分号、反斜杠、非 ASCII 等);自 1.6 起默认作为全局过滤器 ,会套到所有路径。若中文/分号被 400 拦截,可按需调 blockNonAscii/blockSemicolon。
logout (LogoutFilter):执行 Subject.logout() 并重定向到配置的 redirectUrl;浏览器预取下建议用 POST 触发。写法:/logout = logout。
noSessionCreation (NoSessionCreationFilter):禁止在该请求中创建新会话 (适合真正的无状态 API);要放在链前面。
perms (PermissionsAuthorizationFilter):要求拥有给定全部权限 ;写法:/doc/** = perms[doc:read]。
roles (RolesAuthorizationFilter):要求拥有给定全部角色 ;写法:/admin/** = roles[admin,ops](AND 语义)。
rest (HttpMethodPermissionFilter):把 HTTP 方法映射成动词并拼成权限名(如 GET→read、POST→create);写法:/users/** = rest[user]。
port (PortFilter):要求特定端口,不符则重定向到该端口。写法:/secure/** = port[8443]。
ssl (SslFilter):要求 HTTPS(isSecure() 且端口匹配),并支持开启 HSTS 。写法:/secure/** = ssl。
user (UserFilter):“已知身份”即可放行(登录中或 rememberMe 恢复的主体都算),比 authc 宽 。写法:/profile/** = user。
CVE-2010-3863 漏洞简介
影响 :Shiro < 1.1.0(JSecurity 0.9.x 也受影响)。
根因 :在与 shiro.ini 的规则比对前未做路径 规范化,攻击者可用如 /./account/index.jsp 这类“非规范”路径逃逸规则。
漏洞分析 还是以前面 Spring 的示例项目为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Bean(name = "shiroFilter") public ShiroFilterFactoryBean shiroFilterFactoryBean (SecurityManager securityManager, DenyFilter deny) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean (); bean.setSecurityManager(securityManager); Map<String, Filter> filters = new LinkedHashMap <>(); filters.put("deny" , deny); bean.setFilters(filters); Map<String, String> chain = new LinkedHashMap <>(); chain.put("/open/**" , "anon" ); chain.put("/secure/**" , "deny" ); chain.put("/**" , "anon" ); bean.setFilterChainDefinitionMap(chain); return bean; }
Shiro 处理部分 Tomcat 处理部分 CVE-2016-6802
影响 :Shiro < 1.3.2。
场景 :Web 应用部署在非根上下文 (/app)时,Shiro 的路径解析与 Servlet 容器存在偏差,导致绕过预期 Filter 。
CVE-2020-1957
影响 :Shiro < 1.5.2。
场景 :Shiro 的路径匹配 与 Spring 的动态控制器路由 在编码/规范化 环节(以及包含/转发)上存在差异,特制请求可绕过认证 。
CVE-2020-11989
影响 :Shiro < 1.5.3。
说明 :同 CVE-2020-1957 场景的进一步修复,仍与 Spring 动态控制器的路径匹配/规范化差异相关。
CVE-2020-13933
影响 :Shiro < 1.6.0。
说明 :利用分号 (; 矩阵参数)或畸形路径导致的匹配差异,使 Shiro 的链路选择与实际路由不一致。
CVE-2020-17510
影响 :Shiro < 1.7.0(Shiro 1.7.0 同时“默认禁用会话路径重写”,并增加了反斜杠规范化的系统属性)。
CVE-2020-17523 影响 :Shiro < 1.7.1。
CVE-2021-41303 影响 :Shiro < 1.8.0,Spring Boot 场景特制请求可绕过认证。
根因 :Spring Boot 与 Shiro 对 URL 解析/匹配方式 不同,特制请求可能穿透 Shiro 的保护。
CVE-2022-32532
影响 :Shiro < 1.9.1。
根因 :当使用 RegExPatternMatcher,且正则包含点号 .,在某些 Servlet 容器 上可能被绕过(容器对路径/点号的处理差异叠加误配置)。
反序列化 [4]: