Java Shiro

sky123

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:角色(权限的集合,如 admineditor

它既能用于 Web(拦截请求、登录、鉴权),也能用于非 Web 程序(命令行工具、后台任务),因为它不依赖 Servlet 容器自带的 Session。

Shiro 关键组件

Apache Shiro Architecture

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(DefaultSecurityManagerDefaultWebSecurityManager)。

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 提供常用组件:

  • SimpleHashDefaultPasswordService 等散列/密码服务
  • 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
// User.java
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
// UserService.java(演示:内存模拟,换成 JDBC 也行)
import java.util.*;

public class UserService {
// 假装是数据库:用户名 -> User
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
// MyRealm.java
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;
// 配置口令校验(SHA-256 + 1024 次迭代,十六进制存储)
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
// App.java
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"));

// 安装 SecurityManager
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()); // true

// 访问控制
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(拿你的用户/密码/角色/权限数据)。基本流程是:

  1. 提供 Realm(你实现或内置),它负责“查出正确答案”(口令散列/盐、账户状态)。

    1
    MyRealm realm = new MyRealm(repo);
  2. 准备 SecurityManager → 绑定到 SecurityUtils

    1
    2
    DefaultSecurityManager sm = new DefaultSecurityManager(realm);
    SecurityUtils.setSecurityManager(sm);
  3. SecurityUtils 里获取 Subjectsubject.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, adminRole
alice = alicePwd, userRole
bob = secret, userRole

[roles]
adminRole = *
userRole = doc:read,doc:write

读取 shiro.ini 文件。如果其中有 [users] 段,IniSecurityManagerFactory 会创建一个 IniRealmorg.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
/**
* 实现流程说明:
* <ol>
* <li>首先尝试从缓存中获取与给定 {@link AuthenticationToken} 对应的
* {@link AuthenticationInfo}。如果命中,将直接使用该信息进行凭据匹配,
* 从而避免访问后端数据源。</li>
* <li>若缓存未命中,则委托调用
* {@link #doGetAuthenticationInfo(org.apache.shiro.authc.AuthenticationToken)}
* 执行实际查找;若启用了认证信息缓存且允许缓存,则会调用
* {@link #cacheAuthenticationInfoIfPossible(org.apache.shiro.authc.AuthenticationToken, org.apache.shiro.authc.AuthenticationInfo)}
* 将查到的结果写入缓存,以便后续复用。</li>
* <li>如果无论缓存还是查找都未得到 AuthenticationInfo,则返回 {@code null},
* 表示找不到该账户。</li>
* <li>一旦获得 AuthenticationInfo(无论来自缓存还是查找),将使用
* {@link #getCredentialsMatcher() credentialsMatcher} 对提交的
* AuthenticationToken 中的凭据与期望凭据进行匹配校验。也就是说,
* 每次认证尝试都会进行凭据校验。</li>
* </ol>
*
* @param token 提交的账户标识与凭据(例如用户名/口令等)。
* @return 与给定 {@code token} 对应的 AuthenticationInfo;若未找到则返回 {@code null}。
* @throws AuthenticationException 当认证失败(例如凭据不匹配、账户状态异常等)时抛出。
*/
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

// 1) 优先从认证信息缓存中获取(例如 Ehcache/Redis 等实现)
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
// 2) 缓存未命中:执行实际的数据源查找(由具体 Realm 的 doGetAuthenticationInfo 实现)
info = doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
// 3) 若启用并允许缓存,则将查到的认证信息写入缓存,便于后续快速命中
cacheAuthenticationInfoIfPossible(token, info);
}
} else {
// 缓存命中:直接复用缓存的认证信息进行后续凭据匹配
log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}

if (info != null) {
// 4) 无论来源(缓存/查找),只要拿到了 AuthenticationInfo,就执行凭据匹配校验
// 实际匹配逻辑由 CredentialsMatcher(如 HashedCredentialsMatcher)完成
assertCredentialsMatch(token, info);
} else {
// 未找到账户:返回 null(上层通常会据此抛 UnknownAccountException 等)
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
/**
* 构造函数:接收账户的“主身份(primary principal)”、其对应的已散列凭据(hashed credentials),
* 用于散列的盐(salt),以及与该身份关联的 Realm 名称。
* <p>
* 这是一个便捷构造器:会基于传入的 {@code principal} 与 {@code realmName}
* 自动构造 {@link PrincipalCollection} 实例。
* <p>
* 注意:
* <ul>
* <li>{@code hashedCredentials} 必须是“已散列后的凭据”(非明文),常见为十六进制或 Base64 编码的散列值;</li>
* <li>{@code credentialsSalt} 为散列时使用的盐值({@link ByteSource}),应与注册/入库时使用的盐一致;</li>
* <li>与 {@link org.apache.shiro.authc.credential.HashedCredentialsMatcher HashedCredentialsMatcher}
* 配合使用时,需确保算法、迭代次数、编码方式与存储策略一致。</li>
* </ul>
*
* @param principal 与指定 Realm 关联的“主身份”对象(例如用户名、用户ID等)
* @param hashedCredentials 用于校验该身份的“已散列凭据”(非明文;通常为 hex 或 Base64 字符串,也可为字节数组)
* @param credentialsSalt 计算 {@code hashedCredentials} 时使用的盐({@link ByteSource})
* @param realmName 该 principal 与凭据来源的 Realm 名称
* @since 1.1
*/
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
/**
* 断言提交的 {@code AuthenticationToken} 中的凭据与账户已存储的
* {@code AuthenticationInfo} 中的凭据相匹配;若不匹配则抛出 {@link AuthenticationException}。
*
* <p>实现要点:
* <ol>
* <li>通过 {@link #getCredentialsMatcher()} 获取当前配置的 {@link CredentialsMatcher};</li>
* <li>若已配置匹配器,则调用其 {@link CredentialsMatcher#doCredentialsMatch(AuthenticationToken, AuthenticationInfo)}
* 对提交凭据与存储凭据进行比对;</li>
* <li>比对失败时抛出 {@link IncorrectCredentialsException};</li>
* <li>若未配置任何匹配器,则抛出 {@link AuthenticationException},提示必须配置
* {@code CredentialsMatcher}(若希望跳过校验,可使用 {@link AllowAllCredentialsMatcher})。</li>
* </ol>
*
* @param token 提交的认证令牌(包含主体标识与原始凭据,如密码)
* @param info 与该 {@code token} 对应的账户认证信息(通常包含散列后凭据与盐等)
* @throws AuthenticationException 当未配置匹配器或凭据不匹配时抛出;
* 其中不匹配场景具体为 {@link IncorrectCredentialsException}。
*/
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
// 取出当前配置的凭据匹配器(例如 HashedCredentialsMatcher)
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) 更像“身份标签”,例如:adminusermanager 等。
  • 权限(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")); // AND
boolean any = subject.hasRole("admin") || subject.hasRole("op"); // OR(手写)

// 权限判断(WildcardPermission 字符串)
boolean canRead = subject.isPermitted("doc:read"); // 资源:动作
boolean canWrite = subject.isPermitted("doc:write"); // 资源:动作
boolean canEditOwnDoc = subject.isPermitted("doc:write:123"); // 到实例级

// 强制校验(不满足直接抛 AuthorizationException)
subject.checkRole("admin");
subject.checkPermissions("doc:read", "doc:write");

WildcardPermission 扩展规则

  • "doc:read,write:123" 等同于同时具有 doc:read:123doc: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 {

// 角色 OR 权限 AND(默认语义)
@RequiresRoles(value = {"admin", "op"}, logical = Logical.OR)
@RequiresPermissions("doc:write")
public void updateDoc(String id, String content) {
// 更新文档逻辑
}

// 权限 OR 判断(至少具备一种即可)
@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 匹配规则说明:

  • URL 匹配采用“先声明先匹配(First Match Wins)”原则,更具体的规则应写在前面。

  • Shiro 内置的常用过滤器:

    • anon:匿名访问
    • authc:认证后可访问(需要登录)
    • user:登录或记住我后可访问
    • roles[...]:需要特定角色(多个角色默认 AND 语义)
    • perms[...]:需要特定权限(多个权限默认 AND 语义)
    • logout:登出处理

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 接收 securityManagerfilters(自定义 Filter Bean)和 filterChainDefinitionMapURL → 链)。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/destroyEnvironmentLoaderListener 初始化并放置 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">

<!-- 1) 加载 Shiro WebEnvironment(默认会找 classpath:/shiro.ini 或 /WEB-INF/shiro.ini) -->
<listener>
<listener-class>org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class>
</listener>

<!-- 可选:指定 ini 配置位置(多个以逗号分隔),不写则走默认查找规则 -->
<context-param>
<param-name>shiroConfigLocations</param-name>
<param-value>classpath:shiro.ini, /WEB-INF/shiro.ini</param-value>
</context-param>

<!-- 2) 所有请求交给 ShiroFilter 做 URL 级访问控制 -->
<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 前执行(通常放最前)。
JettyTomcat 一致,因为它们都遵守 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]
# =========== Realm ===========
# 方案A:临时演示用(把用户/角色写在下面 [users]/[roles])
iniRealm = org.apache.shiro.realm.text.IniRealm
# 方案B:换成你的自定义 Realm(推荐生产)
# myRealm = com.example.security.MyRealm
# securityManager.realms = $myRealm
securityManager.realms = $iniRealm

# =========== 缓存(可选) ===========
cacheManager = org.apache.shiro.cache.ehcache.EhCacheManager
cacheManager.cacheManagerConfigFile = classpath:ehcache-shiro.xml
securityManager.cacheManager = $cacheManager

# =========== 会话管理 ===========
# 默认用 Shiro “原生”会话(非容器 HttpSession),可配置 Cookie/过期等
sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager
# 禁止 URL 追加 jsessionid,避免泄露
sessionManager.sessionIdUrlRewritingEnabled = false
# 使用 Cookie 传递会话标识
sessionIdCookie = org.apache.shiro.web.servlet.SimpleCookie
sessionIdCookie.name = JSESSIONID
sessionIdCookie.httpOnly = true
sessionIdCookie.path = /
sessionManager.sessionIdCookie = $sessionIdCookie
securityManager.sessionManager = $sessionManager

# 如需直接复用容器 HttpSession(Tomcat/Jetty 自带会话),改为:
# servletSessionManager = org.apache.shiro.web.session.mgt.ServletContainerSessionManager
# securityManager.sessionManager = $servletSessionManager

# =========== RememberMe(可选) ===========
rememberMeCookie = org.apache.shiro.web.servlet.SimpleCookie
rememberMeCookie.name = rememberMe
rememberMeCookie.httpOnly = true
rememberMeCookie.maxAge = 2592000 # 30 天

rememberMeManager = org.apache.shiro.web.mgt.CookieRememberMeManager
rememberMeManager.cookie = $rememberMeCookie
# 强烈建议:自定义密钥(在 Java 配置里设置 byte[] 更稳妥;ini 难写字节)
# rememberMeManager.cipherKey = <自定义字节数组>
securityManager.rememberMeManager = $rememberMeManager

# =========== 常用内置过滤器属性 ===========
authc = org.apache.shiro.web.filter.authc.FormAuthenticationFilter
authc.loginUrl = /login
# 登录成功后默认跳转,可在代码里覆盖
# authc.successUrl = /
logout.redirectUrl = /login

# 未授权时跳转/返回(配合前后端分离场景你可以自定义 JSON 版 Filter,见下文)
perms.unauthorizedUrl = /403
roles.unauthorizedUrl = /403

# =========== URL 过滤链 ===========
[urls]
/assets/** = anon
/login = anon
/logout = logout

/api/docs/** = authc, perms["doc:read"]
/api/admin/**= authc, roles[admin]

/** = authc

# (可选)内联的用户与角色,仅配合 iniRealm 快速试验
[users]
admin = password123, admin
alice = 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)); // 成功后 Shiro 建立会话
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.FormAuthenticationFilter
authc.loginUrl = /login # 登录页路径(GET 会放行到你的页面/控制器)
authc.successUrl = / # 没有 SavedRequest 时的登录成功跳转
authc.usernameParam = username # 表单字段名可改
authc.passwordParam = password
authc.rememberMeParam = rememberMe

[urls]
/assets/** = anon
/logout = logout
/login = authc # 关键:让 /login 由表单过滤器处理
/** = authc # 其他都需要登录
  • authc 就是 FormAuthenticationFilter 的别名。

  • [urls] 里写 /login = authc 后,所有访问 /login 的请求都会先经过这个过滤器。

    • GET /login:过滤器判断“这是登录页访问”,放行给你的视图/控制器去渲染页面(不拦)。

    • POST /login:过滤器从表单里取 username / password(字段名可改),自动执行 subject.login(...) 完成认证。

      • 成功:自动重定向到原先想去的地址(SavedRequest),如果没有,则跳到你配置的 successUrl(或默认根路径)。
      • 失败:把异常类名放到 request 属性 shiroLoginFailure,并回到 loginUrl(让你在页面上提示错误)。

对应前端提交的 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   # 200 "open ok"
curl http://localhost:8080/secure/ping # 401 "Blocked by Shiro URL filter"

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>

<!-- Shiro (JDK8, javax) -->
<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;

/** Shiro configured for URL filtering only: no login, no realms. */
@Configuration
public class ShiroConfig {

@Bean
public SecurityManager securityManager() {
// No realms: we do not authenticate in this lab.
return new DefaultWebSecurityManager();
}

@Bean(name = "deny")
public DenyFilter denyFilter() { return new DenyFilter(); }

/** Main Shiro filter factory; bean name must be 'shiroFilter'. */
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, DenyFilter deny) {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager);

// Register custom filters
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("deny", deny);
bean.setFilters(filters);

// URL chain — edit to test patterns and order
Map<String, String> chain = new LinkedHashMap<>();
chain.put("/open/**", "anon"); // allow
chain.put("/secure/**", "deny"); // always block (401)
chain.put("/**", "anon"); // fallback allow
bean.setFilterChainDefinitionMap(chain);

return bean;
}

/** Bridge Boot's filter pipeline to the Shiro filter 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); // early but after encoding/cors if any
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;

/** Always deny and return 401 (no login involved). */
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
server:
port: 8080

控制器中使用

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"; } // templates/index.html
@GetMapping("/403") public String e403() { return "403"; } // templates/403.html
}

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
// 从请求对象中取得“已解码URI”的承载容器(MessageBytes 可在字节/字符之间切换)
MessageBytes decodedURI = req.decodedURI();

// 过滤 CONNECT 方法(常用于代理隧道)。此处不支持,直接返回 501 Not Implemented
if (req.method().equals("CONNECT")) {
response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, sm.getString("coyoteAdapter.connect"));
} else {
// 非 CONNECT 请求才会处理具体的路径 URI

// 如果 undecodedURI 以“字节数组”形式存在,意味着是未解码、未规范化的原始 URI
if (undecodedURI.getType() == MessageBytes.T_BYTES) {
// 将原始 URI 复制到 decodedURI,后续在该容器上完成解码和规范化
decodedURI.duplicate(undecodedURI);

// 解析并剔除路径参数(例如 /path;jsessionid=xxx 之类的分号参数)
parsePathParameters(req, request);

// —— URI 解码流程 ——
// 对 URL 执行 %xx 百分号解码(按字节级进行)
try {
// 第二个参数决定如何处理已编码的斜杠 %2F(由 connector 配置策略决定)
req.getURLDecoder().convert(decodedURI.getByteChunk(),
connector.getEncodedSolidusHandlingInternal());
} catch (IOException ioe) {
// 解码异常(如非法转义序列),返回 400,并附带错误信息
response.sendError(400, sm.getString("coyoteAdapter.invalidURIWithMessage", ioe.getMessage()));
}

// —— 规范化流程 ——
// 对路径进行规范化(去除 /./、/../、重复斜杠等;非法则返回 400)
if (normalize(req.decodedURI())) {
// 将字节级 URI 按请求字符集转换为字符(例如 UTF-8 字节 -> Java char)
convertURI(decodedURI, request);

// 字符转换后再次检查是否仍保持规范化(防止编码转换产生绕过)
if (!checkNormalize(req.decodedURI())) {
response.sendError(400, "Invalid URI");
}
} else {
// 初次规范化失败,直接返回 400
response.sendError(400, sm.getString("coyoteAdapter.invalidURI"));
}

} else {
/*
* 若 URI 已是 chars 或 String,说明使用了内存协议处理器传递,
* 默认约定:
* - req.requestURI():原始(未解码、未规范化)的 URI
* - req.decodedURI():对 requestURI 进行了解码且已规范化的结果
*/
// 将 URI 表示切换为字符形式,后续以 CharChunk 操作
decodedURI.toChars();

// 移除所有路径参数(以分号 ; 分隔的部分)
// 按规范,真正需要的参数应通过 request 对象设置,而不是放在 URL 里
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
/**
* 从请求路径中提取形如 /path;name=value;name2=value2/... 的“路径参数”(path params)。
* 实际上主要关心的是 JSESSIONID 这类参数,其他的可以忽略。
*
* @param req Coyote 层的请求对象(底层协议解析)
* @param request Servlet 层的请求对象(高层语义)
*/
protected void parsePathParameters(org.apache.coyote.Request req, Request request) {

// 确保使用“字节视图”处理(默认如此,因此通常是 NO-OP)
req.decodedURI().toBytes();

ByteChunk uriBC = req.decodedURI().getByteChunk();
// 约定 URL 第一字符必须是 '/',所以从位置 1 开始找分号。
// 若第一个字符就是 ';',规范化阶段会判定 URI 非法。
int semicolon = uriBC.indexOf(';', 1);

// 性能优化:没有 ';' 直接返回,说明没有路径参数
if (semicolon == -1) {
return;
}

// 显式指定用于把字节切片解码成字符串的编码(某些平台默认编码会异常,比如 z/OS)
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; // 保存 "name=value" 文本

if (pathParamEnd >= 0) {
// 中间有分隔符(; 或 /):把 [pathParamStart, pathParamEnd) 视作一个路径参数
if (charset != null) {
pv = new String(uriBC.getBuffer(), start + pathParamStart, pathParamEnd - pathParamStart, charset);
}
// 将该路径参数从 URI 中移除:把从 pathParamEnd 开始的后续字节整体左移到 semicolon 位置
byte[] buf = uriBC.getBuffer();
for (int i = 0; i < end - start - pathParamEnd; i++) {
buf[start + semicolon + i] = buf[start + i + pathParamEnd];
}
// 更新 ByteChunk 的有效范围:长度减少 (pathParamEnd - semicolon)
uriBC.setBytes(buf, start, end - start - pathParamEnd + semicolon);
} else {
// 没有再遇到 ';' 或 '/',说明这是末尾的最后一个参数(形如 ";name=value" 在末尾)
if (charset != null) {
pv = new String(uriBC.getBuffer(), start + pathParamStart, (end - start) - pathParamStart, charset);
}
// 直接把有效区间截断到 semicolon(去掉末尾整个参数片段)
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));
}

// 解析 "name=value" 并写入 request 的“路径参数”表
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); // 例如 name=jsessionid
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));
}
}
// 如果没有 '='(只写了 ";foo"),则按规范忽略之
}

// 继续寻找下一个 ';'
// 注意:此时 ByteChunk 已被“左移+收缩”,这里从上一个 semicolon 位置继续搜,
// 能覆盖连串 ";a=b;c=d" 的情况,不会跳漏,也不会死循环。
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 匹配)。

例如:

  • 输入路径:/app;jsessionid=ABC/dir;v=1/file;c=d?x=1

    • 输出路径(用于后续映射)/app/dir/file
    • 路径参数表{ jsessionid: "ABC", v: "1", c: "d" }
    • 查询串 ?x=1 不受影响(它本就不在这段逻辑处理范围内)。
  • 输入路径:/foo/bar(没有分号)→ 原样返回,零拷贝。

注意

注意这是在百分号解码(%xx)之前做的:只识别字面量 ;。如果有人用 %3B(编码后的分号),这里不会当分号参数看待;后面解码成 ; 时,它就只是普通字符了(不会再被当作路径参数)。

URI 解码

解析路径参数之后会进行 URI 解码操作。

1
2
3
4
5
6
7
8
9
10
// —— URI 解码流程 ——
// 对 URL 执行 %xx 百分号解码(按字节级进行)
try {
// 第二个参数决定如何处理已编码的斜杠 %2F(由 connector 配置策略决定)
req.getURLDecoder().convert(decodedURI.getByteChunk(),
connector.getEncodedSolidusHandlingInternal());
} catch (IOException ioe) {
// 解码异常(如非法转义序列),返回 400,并附带错误信息
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 在你看到的代码里做了三件关键安全动作(都绕不开自己解码):

  1. 按自己的策略解码
    req.getURLDecoder().convert(..., encodedSolidusHandling)

    • 明确控制 %2F(编码的 /)是解码拒绝还是透传
    • 拒绝非法 %xx、NUL 字节等。
  2. 路径规范化
    统一处理 ///.//../、反斜杠 \防目录穿越与旁路

  3. 解码后再二次校验
    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
/**
* URL 解码(就地修改源缓冲)。按 RFC 7230,假定源字节集为 US-ASCII 的超集。
*
* @param mb 待解码的字节切片(ByteChunk)
* @param encodedSolidusHandling 遇到 "%2F"(编码斜杠)时的处理策略:
* - DECODE:解码为 '/'(允许)
* - REJECT:直接拒绝抛错(更安全,防目录穿越类绕过)
* - PASS_THROUGH:不解码,原样保留为 "%2F"
*
* @throws IOException 非法的 %xx 序列(不足两位、非十六进制等)
*/
public void convert(ByteChunk mb, EncodedSolidusHandling encodedSolidusHandling) throws IOException {
// 路径解码场景:query=false(与查询串不同,路径中 '+' 不是空格)
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;
}

// 就地解码:j 指向“读位置”,idx 指向“写位置”(可能小于 j,实现压缩)
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; // 不足两位,%xx 结尾非法
}
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 == '/') { // 特判:编码斜杠 %2F
switch (encodedSolidusHandling) {
case DECODE: {
// 正常解码为 '/'
buff[idx] = (byte) res;
break;
}
case REJECT: {
// 直接拒绝(更安全,常用于路径解码防绕过)
throw EXCEPTION_SLASH;
}
case PASS_THROUGH: {
// 原样保留 "%2F":把 '%','b1','b2' 三字节写回输出
buff[idx++] = buff[j - 2]; // '%'
buff[idx++] = buff[j - 1]; // b1
buff[idx] = buff[j]; // b2
// 注意:本轮末尾 for 循环的 idx++ 还会再执行一次
}
}
} else {
// 常规 %xx 解码
buff[idx] = (byte) res;
}
}
}

// 写回新的“有效长度”,实现对 %xx 的压缩与(可选的)'+'->' ' 替换
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
/**
* 规范化 URL 路径,处理反斜杠 "\"、连续斜杠 "//"、当前目录 "/./"、父目录 "/../" 等。
* 返回 false 表示出现越过根目录的情况或包含空字节(\0);否则返回 true。
*/
public static boolean normalize(MessageBytes uriMB) {

ByteChunk uriBC = uriMB.getByteChunk();
final byte[] b = uriBC.getBytes();
final int start = uriBC.getStart();
int end = uriBC.getEnd();

// 空 URL 不合法
if (start == end) {
return false;
}

int pos = 0;
int index = 0;

// URL 必须以 '/' 开头(或以 '\' 开头,稍后会被替换)
if (b[start] != (byte) '/' && b[start] != (byte) '\\') {
return false;
}

// 第一遍扫描:将 '\' 统一替换为 '/'(若 ALLOW_BACKSLASH=false 则直接拒绝)
// 同时检查是否包含 NUL 字节
for (pos = start; pos < end; pos++) {
if (b[pos] == (byte) '\\') {
if (ALLOW_BACKSLASH) {
b[pos] = (byte) '/';
} else {
return false; // 不允许 Windows 风格分隔符
}
} else if (b[pos] == (byte) 0) {
return false; // 拒绝含 NUL 的路径
}
}

// 折叠重复斜杠:将多重 "//" 压缩为单个 "/"
for (pos = start; pos < (end - 1); pos++) {
if (b[pos] == (byte) '/') {
while ((pos + 1 < end) && (b[pos + 1] == (byte) '/')) {
// copyBytes(dst=pos, src=pos+1, len=end-pos-1) => 左移一字节
copyBytes(b, pos, pos + 1, end - pos - 1);
end--;
}
}
}

// 若以 "/." 或 "/.." 结尾,则在末尾补一个 "/",方便下方用统一的 "/./"、"/../" 规则处理
// 备注:这里假设缓冲区末尾有可写空间(Tomcat 的 ByteChunk 留有余量)
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); // 在 ByteChunk 内查找,从 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;
}
// 若出现在最前面(形如 "/../xxx"),等价需要越过根目录,直接拒绝
if (index == 0) {
return false;
}
// 回溯找到上一个 '/' 的位置 index2(即前一段目录的起点)
int index2 = -1;
for (pos = start + index - 1; (pos >= 0) && (index2 < 0); pos--) {
if (b[pos] == (byte) '/') {
index2 = pos;
}
}
// 将 "/前一段/../" 整体删除:把 "/../" 之后的内容向左搬到 index2
copyBytes(b, start + index2, start + index + 3, end - start - index - 3);
end = end + index2 - index - 3;
uriBC.setEnd(end);
// 继续从折叠后的上一个斜杠处往后处理(可能还会连锁触发)
index = index2;
}

return true;
}

具体逻辑为:

  1. 入口校验

    • 必须以 /(或 \)开头;空路径直接拒绝。
    • 拒绝包含 NUL\0)字节。
  2. 统一分隔符

    • ALLOW_BACKSLASH 配置:

      • true:把 \ 全部改成 /
      • false:出现 \ 直接判为非法。
    • 折叠连续斜杠://… 压成 /…

  3. 补尾统一化

    • 若以 /./.. 结尾,先在末尾补一个 /,便于下一步用同一规则处理(把 “结尾的点段”也变成标准的 "/./""/../" 形态)。
  4. 消去当前目录段

    • 循环查找并把所有 "/./" 压成 "/"
  5. 折叠父目录段

    • 循环处理每个 "/../":回溯到它前一个 /,把 "/前一段/../" 整体删掉。
    • 若要越过根(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
/**
* 将 URI 从“字节视图”转换为“字符视图”(bytes -> chars)。
*
* @param uri 包含 URI 的 MessageBytes(此前已是字节视图并做过一次 %xx 解码与规范化)
* @param request Servlet 层 Request,用于缓存/复用转换器
*
* @throws IOException 发送错误响应时可能抛出(正常情况下不会抛,因为下面已拦截并发 400)
*/
protected void convertURI(MessageBytes uri, Request request) throws IOException {

// 取出字节切片(ByteChunk),以及其长度(有效字节数)
ByteChunk bc = uri.getByteChunk();
int length = bc.getLength();

// 准备字符切片(CharChunk)作输出缓冲:初始容量 = 字节长度,上限 = -1(不限)
// 对于 UTF-8 等多字节编码,字符数不会超过字节数,因此以字节长度作为初始容量是充足的上界。
CharChunk cc = uri.getCharChunk();
cc.allocate(length, -1);

// 从 Connector 拿到 URI 的字符集配置(通常由 server.xml 的 URIEncoding 等决定)
Charset charset = connector.getURICharset();

// 拿一个“字节->字符”转换器(B2CConverter):
// - 优先复用挂在 request 上的实例,避免重复创建(减少对象 churn)
// - recycle() 会重置内部状态(解码器、错误替换旗标等)
B2CConverter conv = request.getURIConverter();
if (conv == null) {
// 第二个参数 true:表示遇到无效字节时“用替代字符替换”,而不是抛异常
conv = new B2CConverter(charset, true);
request.setURIConverter(conv);
} else {
conv.recycle();
}

try {
// 执行字节 -> 字符转换
// 第三个参数 true:表明这是输入的“结束”(endOfInput),便于解码器 flush 悬挂状态
conv.convert(bc, cc, true);

// 将 MessageBytes 切换到“字符视图”:后续 API(如 getRequestURI()/mapping)都以 char 为准
uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength());
} catch (IOException ioe) {
// 理论上不会到这里:B2CConverter 设置了“替换无效字符”策略而非抛异常
// 若仍异常,直接回 400 Bad Request(字符层转换失败)
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
/**
* (字符层)检查 URI 是否保持已规范化状态。
* 需要在“字节→字符”解码之后调用;若仍包含 '\', NUL, "//", "/./", "/../" 或以 "/."、"/.." 结尾,则判为未规范化。
*
* @param uriMB 待检查的 URI(此时应为字符视图)
* @return 若发现任何未规范化片段返回 false,否则 true
*/
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;

// 1) 禁止出现反斜杠 '\' 与 NUL 字符(\0)
// 说明:即使字节层已处理,这里再次在字符层兜底检查。
for (pos = start; pos < end; pos++) {
if (c[pos] == '\\') {
return false;
}
if (c[pos] == 0) {
return false;
}
}

// 2) 禁止出现连续斜杠 "//"
for (pos = start; pos < (end - 1); pos++) {
if (c[pos] == '/') {
if (c[pos + 1] == '/') {
return false;
}
}
}

// 3) 禁止以 "/." 或 "/.." 结尾
// 由于结尾无法被通用的 "/./"、"/../" 规则再折叠,因此直接判为未规范化。
if (((end - start) >= 2) && (c[end - 1] == '.')) {
if ((c[end - 2] == '/') || ((c[end - 2] == '.') && (c[end - 3] == '/'))) {
return false;
}
}

// 4) 禁止包含 "/./"(当前目录片段)
if (uriCC.indexOf("/./", 0, 3, 0) >= 0) {
return false;
}

// 5) 禁止包含 "/../"(父目录片段)
if (uriCC.indexOf("/../", 0, 4, 0) >= 0) {
return false;
}

// 都没命中,视为已规范化
return true;
}

请求映射

完成 URL 解析处理之后会执行映射将请求的 URI 映射到对应的处理方法或者请求的资源上。

1
2
3
// 执行映射:serverName + decodedURI → 填充 request.getMappingData()
// 未指定 version 时默认命中该 webapp 的最新版本
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
/**
* 将“主机名 + URI”映射到容器对象(Host/Context/Wrapper 等),并把结果写进 mappingData。
*
* @param host 虚拟主机名(MessageBytes,可是字节或字符视图)
* @param uri 已规范化后的 URI(MessageBytes)
* @param version 并行部署时的 Web 应用版本(可为 null,表示“取最新”)
* @param mappingData 映射结果写入到这里(含 host/context/wrapper/redirectPath 等)
*
* @throws IOException 当内部需要写入的缓冲区不足(例如 CharChunk 空间不够)时抛出
*/
public void map(MessageBytes host, MessageBytes uri, String version, MappingData mappingData) throws IOException {

// 若请求中未提供 host(例如 HTTP/1.0 或某些非标准场景),尝试使用默认主机名
if (host.isNull()) {
String defaultHostName = this.defaultHostName;
if (defaultHostName == null) {
// 没有默认主机名可用,直接返回(mappingData 不会被填充)
return;
}
// 把 host 的字符视图清空为长度 0(指向一个空的 char 数组)
host.setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
// 追加默认主机名,后续映射将以它为准
host.getCharChunk().append(defaultHostName);
}

// internalMap 以“字符视图”工作;确保 host 和 uri 都转为 CharChunk
host.toChars();
uri.toChars();

// 真正的映射实现:根据 host/uri/version 计算并填充 mappingData
internalMap(host.getCharChunk(), uri.getCharChunk(), version, mappingData);
}

internalMap

internalMap 函数把“主机名 + URI”路由到具体应用与 Servlet。先定 Host,再找最匹配的 Context(应用),再选版本(并行部署时),最后交给 internalMapWrapper 做到具体 Servlet 的匹配。

  1. 防御性检查

    • 如果 mappingData.host 已经被填过,直接 AssertionError(避免重复/错用导致结果不一致)。
  2. Host 映射

    • 先对 host不区分大小写的精确匹配
    • 不命中时,尝试通配主机:把第一个 . 之前的 label 暂时“切掉”(如 a.example.comexample.com)再匹配。
    • 还不行就落到 defaultHost;如果也没有,返回(无法继续)。
  3. URI 空检查与准备

    • 没有 URI 就没法做 Context/Wrapper 映射,返回
    • uri.setLimit(-1):取消 CharChunk 限制,方便后面临时截断路径做查找。
  4. Context(应用)映射:最长前缀匹配

    • 在该 Host 下的 contexts 列表里,找与 uri 前缀匹配最长的 context.name
    • 实现细节:用 find() 定位候选;若未命中,就按 / 逐级回退nthSlash / lastSlash)并临时缩短 uri 再找。
    • 如果最终没找到,且 contexts[0].name 是空串 ""(ROOT 应用),就用 ROOT;否则返回
    • 命中后写出 mappingData.contextPath
  5. Context 版本选择(并行部署)

    • 一个 Context 可能有多个版本(context.versions)。
    • 若有多个版本,把所有版本对象塞到 mappingData.contexts(供上层按会话再决策)。
    • version 参数非空,尝试精确命中该版本;否则取“最新版本”(数组最后一个)。
    • 写出 mappingData.contextcontextSlashCount(后续做 Servlet 匹配用)。
  6. 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
/**
* 根据“主机名 + URI”执行映射(Host → Context → Wrapper),把结果写进 mappingData。
*
* @throws IOException 当在处理 URI 时需要调整缓冲而空间不足等
*/
@SuppressWarnings("deprecation") // contextPath
private void internalMap(CharChunk host, CharChunk uri, String version, MappingData mappingData)
throws IOException {

if (mappingData.host != null) {
// 按理说这里开始映射时 host 应该还没被填充;
// 若已非空,说明上层重复/错误调用,直接断言失败以避免产生不一致结果。
throw new AssertionError();
}

// ========== 1) 虚拟主机映射(Host) ==========
MappedHost[] hosts = this.hosts;

// 先用完整 host 做一次不区分大小写的精确匹配(host 名字大小写不敏感)
MappedHost mappedHost = exactFindIgnoreCase(hosts, host);

if (mappedHost == null) {
// 备注:Mapper 内部表示通配主机(如 *.example.com)时,不带前导 '*'
// 这里的“捷径”是:截掉第一段(第一个 '.' 之前的 label),
// 用剩余部分(如 "example.com")再尝试一次匹配,以命中通配主机。
int firstDot = host.indexOf('.');
if (firstDot > -1) {
int offset = host.getOffset();
try {
host.setOffset(firstDot + offset); // 临时把起点右移到 '.' 之后
mappedHost = exactFindIgnoreCase(hosts, host); // 再试一次(可能命中通配 host 映射)
} finally {
host.setOffset(offset); // 无论如何都恢复 offset
}
}
if (mappedHost == null) {
// 仍未命中:落到默认主机(defaultHost)
mappedHost = defaultHost;
if (mappedHost == null) {
// 没有默认主机则无法继续映射
return;
}
}
}
// 记录命中的 Host 到输出
mappingData.host = mappedHost.object;

// ========== 2) 没有 URI 就无法继续 Context/Wrapper 映射 ==========
if (uri.isNull()) {
return;
}

// 取消 CharChunk 的扫描限制(-1 表示不限制),方便后续在同一缓冲内做临时截断/探索
uri.setLimit(-1);

// ========== 3) Context(应用)映射 ==========
ContextList contextList = mappedHost.contextList;
MappedContext[] contexts = contextList.contexts;

// find(contexts, uri) 通常是按“前缀”在已排序的 context 列表里找潜在位置(高效二分/近似)
int pos = find(contexts, uri);
if (pos == -1) {
// 找不到任何候选上下文(比如 host 下没有部署 ROOT)
return;
}

int lastSlash = -1; // 记录上一次用于回退的 '/' 位置
int uriEnd = uri.getEnd(); // 先保存原始 URI 的 end,便于后面恢复
int length = -1;
boolean found = false;
MappedContext context = null;

// 从当前位置向上回退(按斜杠逐级回退)尝试匹配最长的 contextPath
while (pos >= 0) {
context = contexts[pos];
if (uri.startsWith(context.name)) { // 以 context.name 为前缀?
length = context.name.length();
if (uri.getLength() == length) { // 完全相等:命中
found = true;
break;
} else if (uri.startsWithIgnoreCase("/", length)) {
// 前缀后紧跟 '/':也视为命中(例如 /app 与 /app/...)
found = true;
break;
}
}
// 未命中:根据“嵌套深度”计算/更新应回退到的下一个 '/',并临时截断 URI 再找
if (lastSlash == -1) {
// 第一次回退:找第 (nesting+1) 个 '/'(避免过度逐字回退)
lastSlash = nthSlash(uri, contextList.nesting + 1);
} else {
// 之后每次回退一个 '/'
lastSlash = lastSlash(uri);
}
uri.setEnd(lastSlash); // 临时缩短 URI 再次 find()
pos = find(contexts, uri);
}
uri.setEnd(uriEnd); // 恢复 URI 的 end(不影响后续使用)

// 若还没找到,且第 0 个 context 是空串 ""(即 ROOT 应用),则落到 ROOT
if (!found) {
if (contexts[0].name.equals("")) {
context = contexts[0];
} else {
context = null;
}
}
if (context == null) {
return; // 没有可用的 Context,结束
}

// 输出 contextPath(如 "/app" 或 "")
mappingData.contextPath.setString(context.name);

// ========== 4) Context 版本选择(并行部署支持) ==========
ContextVersion contextVersion = null;
ContextVersion[] contextVersions = context.versions;
final int versionCount = contextVersions.length;

if (versionCount > 1) {
// 把所有版本对应的 Context 对象放到 mappingData.contexts,供上层二次决策(按会话所属版本)
Context[] contextObjects = new Context[contextVersions.length];
for (int i = 0; i < contextObjects.length; i++) {
contextObjects[i] = contextVersions[i].object;
}
mappingData.contexts = contextObjects;

// 若上层传入了明确的 version,尝试精确命中
if (version != null) {
contextVersion = exactFind(contextVersions, version);
}
}

if (contextVersion == null) {
// 默认返回“最新版本”(versions 数组保证至少一个元素,且按版本时间顺序排列)
contextVersion = contextVersions[versionCount - 1];
}

// 写出最终命中的 Context 与其斜杠数量(用于后续 wrapper 映射的路径切分)
mappingData.context = contextVersion.object;
mappingData.contextSlashCount = contextVersion.slashCount;

// ========== 5) Wrapper(具体 Servlet)映射 ==========
if (!contextVersion.isPaused()) {
// 若该版本未处于暂停(启动尚未完成等),则继续进行精细到 Servlet 的映射
internalMapWrapper(contextVersion, uri, mappingData);
}
}

internalMapWrapper

internalMapWrapper 把“context 内的相对路径”按 Servlet 规范的优先级,精确地路由到某个具体 Servlet(wrapper),必要时生成重定向或落到默认 Servlet。

具体过程为:

  1. 定位 servletPath
    path 里剥掉 contextPath(用 setOffset(servletPath)),后续匹配都基于 servletPath。

  2. 规则 1:精确匹配(Exact)
    exactWrappers 里找与 servletPath 完全相等的 url-pattern

  3. 规则 2:前缀匹配(Wildcard,如 /foo/*
    若命中且是 JSP 通配,再看原始路径是否以 / 结尾:

    • / 结尾:清空当前匹配,转去走“欢迎页”逻辑(Bug 27664)。
    • 否则:确定 wrapperPath,清空 pathInfo(Bug 27704)。
  4. Context 根重定向
    若仍未命中、且 servletPath 为空、且开启了 context root redirect,把路径重定向到 / 并返回。

  5. 规则 3:扩展名匹配(Extension,如 *.jsp/*.do
    extensionWrappers 里按后缀匹配(允许作为欢迎资源的一部分)。

  6. 规则 4:欢迎资源(Welcome files)
    仅当还没匹配到且需要检查欢迎页时(JSP 通配触发或路径以 / 结尾):
    逐个把 index.html / index.jsp / index.do… 拼到路径末尾,依次尝试:

    • 4a 精确匹配
    • 4b 前缀匹配
    • 4c 物理文件存在时,再试扩展匹配;若仍无匹配且有 defaultWrapper,就用它处理并设置 requestPath/wrapperPath
      做完后把 path 的 offset/end 复原。
  7. 欢迎文件处理·第二轮(无物理文件场景)
    若还没命中,再按欢迎名做一轮仅扩展匹配(比如 index.jsf/index.do 这类“没有真实文件”的映射)。

  8. 规则 7:默认 Servlet(DefaultServlet)兜底
    若仍未命中且不在 JSP 欢迎模式:

    • defaultWrapper 就用之,并设置 requestPath/wrapperPathmatchType=DEFAULT
    • 目录重定向:若资源存在且是目录且路径不以 / 结尾、并开启 directory redirect,则拼上 / 设置 redirectPath;否则直接记录路径给默认 Servlet 处理。
  9. 收尾
    无论匹配到什么,最后都会把 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
/**
* Wrapper(具体 Servlet)映射。
*
* 规则顺序基本遵循 Servlet 规范:
* 1) 精确匹配(Exact)
* 2) 前缀匹配(Wildcard / 前缀模式)
* - 特殊:JSP 通配 & 末尾是 '/' 时,强制走欢迎页检查(Bug 27664)
* 3) 扩展名匹配(Extension)
* 4) 欢迎资源(Welcome files)
* 4a) 精确
* 4b) 前缀
* 4c) 物理文件存在时按扩展或默认 servlet
* —— 然后再做一次“无物理文件”的扩展欢迎匹配(index.jsf/index.do 等)
* 7) 默认 servlet(DefaultServlet)
*
* @throws IOException 缓冲区空间不足等导致无法回写映射结果
*/
private void internalMapWrapper(ContextVersion contextVersion, CharChunk path, MappingData mappingData)
throws IOException {

int pathOffset = path.getOffset(); // 原始偏移(包含 contextPath 的路径起点)
int pathEnd = path.getEnd(); // 原始结尾
boolean noServletPath = false; // 是否不存在 servletPath(仅有 contextPath)

// contextVersion.path 为 contextPath(如 "/app" 或 "")
int length = contextVersion.path.length();
if (length == (pathEnd - pathOffset)) { // 路径长度 == contextPath 长度 => 没有 servletPath
noServletPath = true;
}
int servletPath = pathOffset + length; // servletPath 起始位置
path.setOffset(servletPath); // 之后的匹配均基于 servletPath 片段

// 规则 1 —— 精确匹配(/foo/bar 完全等于某个 url-pattern)
MappedWrapper[] exactWrappers = contextVersion.exactWrappers;
internalMapExactWrapper(exactWrappers, path, mappingData);

// 规则 2 —— 前缀匹配(通配,形如 /foo/*)
boolean checkJspWelcomeFiles = false; // 是否需要按“JSP 通配 + 目录尾斜杠”的欢迎页逻辑
MappedWrapper[] wildcardWrappers = contextVersion.wildcardWrappers;
if (mappingData.wrapper == null) {
internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData);

// 命中前缀匹配,且是 JSP 通配
if (mappingData.wrapper != null && mappingData.jspWildCard) {
char[] buf = path.getBuffer();
if (buf[pathEnd - 1] == '/') {
/*
* 末尾是 '/' 且是 JSP 通配命中:强制改走“欢迎文件”逻辑。
* 例如 jsp-property-group 的 url-pattern 为 *.jsp,目录请求应尝试欢迎 JSP。
* (Bug 27664)
*/
mappingData.wrapper = null; // 先清空,转到欢迎文件流程
checkJspWelcomeFiles = true;
} else {
// Bug 27704:非目录结尾,直接确定 wrapperPath,清理 pathInfo
mappingData.wrapperPath.setChars(buf, path.getStart(), path.getLength());
mappingData.pathInfo.recycle();
}
}
}

// Context 根路径且启用了“root redirect”:将空 servletPath 重定向到 "/"
if (mappingData.wrapper == null && noServletPath &&
contextVersion.object.getMapperContextRootRedirectEnabled()) {
path.append('/'); // path 末尾临时加 '/'
pathEnd = path.getEnd();
mappingData.redirectPath.setChars(path.getBuffer(), pathOffset, pathEnd - pathOffset);
path.setEnd(pathEnd - 1); // 复原 path 结尾
return;
}

// 规则 3 —— 扩展名匹配(*.jsp、*.do 等)
MappedWrapper[] extensionWrappers = contextVersion.extensionWrappers;
if (mappingData.wrapper == null && !checkJspWelcomeFiles) {
// 第一次扩展匹配:正常玩法(允许作为欢迎资源检查的一部分)
internalMapExtensionWrapper(extensionWrappers, path, mappingData, true);
}

// 规则 4 —— 欢迎资源(servlet 侧的欢迎文件处理)
if (mappingData.wrapper == null) {
boolean checkWelcomeFiles = checkJspWelcomeFiles;
if (!checkWelcomeFiles) {
char[] buf = path.getBuffer();
checkWelcomeFiles = (buf[pathEnd - 1] == '/'); // 目录请求需要检查欢迎文件
}
if (checkWelcomeFiles) {
// 遍历所有欢迎资源名(如 "index.html", "index.jsp", "index.do"...)
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);

// 4a:欢迎文件的“精确匹配”
internalMapExactWrapper(exactWrappers, path, mappingData);

// 4b:欢迎文件的“前缀匹配”
if (mappingData.wrapper == null) {
internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData);
}

// 4c:若有物理文件存在(真实资源),再尝试按扩展或默认 servlet 处理
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);
// 仍无匹配,退回默认 servlet(defaultWrapper)
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 游标
path.setOffset(servletPath);
path.setEnd(pathEnd);
}
}

/*
* 欢迎文件处理(第二轮):
* 上面做过“有物理文件”的情况;现在处理“只有扩展映射、可能没有物理文件”的情况,
* 例如 index.jsf、index.do 等(精简版的规则 4)。
*/
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);
// 第二轮扩展匹配:不要求物理存在(allowWelcomeResource = false)
internalMapExtensionWrapper(extensionWrappers, path, mappingData, false);
}

path.setOffset(servletPath);
path.setEnd(pathEnd);
}
}

// 规则 7 —— 默认 servlet(最后兜底)
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();
// 优先检查是否需要目录重定向(BZ 62968:先判断可节省一次 getResource())
if (contextVersion.object.getMapperDirectoryRedirectEnabled()) {
WebResource file;
if (pathStr.length() == 0) {
file = contextVersion.resources.getResource("/"); // context 根特殊处理
} else {
file = contextVersion.resources.getResource(pathStr);
}
if (file != null && file.isDirectory()) {
// 注意:此处会改写 path,不要在此后再做其他处理
path.setOffset(pathOffset);
path.append('/');
mappingData.redirectPath.setChars(path.getBuffer(), path.getStart(), path.getLength());
} else {
// 非目录:直接按默认 servlet 记录 requestPath 与 wrapperPath
mappingData.requestPath.setString(pathStr);
mappingData.wrapperPath.setString(pathStr);
}
} else {
// 未启用目录重定向:同样记录路径,交由默认 servlet 处理
mappingData.requestPath.setString(pathStr);
mappingData.wrapperPath.setString(pathStr);
}
}
}

// 恢复原始 path 边界
path.setOffset(pathOffset);
path.setEnd(pathEnd);
}

Shiro 解析流程

这里以 Spring 为例。

初始化流程

org.apache.shiro.spring.web.ShiroFilterFactoryBean 实现了 FactoryBean 接口,那么 Spring 在初始化的时候必然会调用 ShiroFilterFactoryBeangetObject() 获取实例,而 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
/**
* 该实现会做以下事情:
* <ol>
* <li>确保必须的 {@link #setSecurityManager(org.apache.shiro.mgt.SecurityManager) securityManager}
* 属性已被设置;</li>
* <li>{@link #createFilterChainManager() 创建} 一个 {@link FilterChainManager} 实例,
* 该实例基于当前配置的 {@link #setFilters(java.util.Map) filters}
* 和 {@link #setFilterChainDefinitionMap(java.util.Map) 过滤链定义};</li>
* <li>用一个合适的
* {@link org.apache.shiro.web.filter.mgt.FilterChainResolver FilterChainResolver}
* 包裹上面的 FilterChainManager,因为 Shiro 的 Filter 实现并不知道“FilterChainManager”这个概念;</li>
* <li>把 {@code SecurityManager} 和 {@code FilterChainResolver} 注入到一个新的 Shiro Filter 实例上,
* 并返回该 Filter 实例。</li>
* </ol>
*
* @return 一个新的 Shiro Filter,反映了当前配置的各个过滤器和 URL 过滤链定义。
* @throws Exception 如果创建 AbstractShiroFilter 实例时出现问题会抛出异常。
*/
protected AbstractShiroFilter createInstance() throws Exception {

// 日志:开始创建 Shiro Filter 实例
log.debug("Creating Shiro Filter instance.");

// 1) 取出 SecurityManager,并进行必备性与类型检查
SecurityManager securityManager = getSecurityManager();
if (securityManager == null) {
// 未设置 SecurityManager 是致命配置错误:直接抛出 Spring 的初始化异常
String msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
}

if (!(securityManager instanceof WebSecurityManager)) {
// Web 场景要求使用 WebSecurityManager(例如 DefaultWebSecurityManager)
String msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
}

// 2) 基于 filters 与 filterChainDefinitionMap 构建 FilterChainManager
// 它持有“过滤器实例集合”和“URL->过滤器链”的映射
FilterChainManager manager = createFilterChainManager();

// 3) 暴露 FilterChainManager:用 FilterChainResolver 包起来
// AbstractShiroFilter 只认识 Resolver,不直接依赖 Manager
PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);

// 4) 组装最终可用的 Shiro Filter:
// - 传入 WebSecurityManager(已检查类型)
// - 传入 FilterChainResolver(里面封装了 FilterChainManager)
// - 传入(可选的)ShiroFilter 配置(如 loginUrl、successUrl 等)
// 这里用的是一个具体可实例化的 SpringShiroFilter(AbstractShiroFilter 的实现)
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
/**
* 构建并返回一个 {@link FilterChainManager}:
* <ol>
* <li>创建默认的过滤器管理器(内含 Shiro 内置过滤器,如 anon/authc/roles/perms/logout 等);</li>
* <li>对“默认过滤器”与“自定义过滤器”应用全局属性(如 loginUrl/successUrl/unauthorizedUrl 等,前提是过滤器支持);</li>
* <li>注册调用方传入的自定义过滤器(不在此处调用 init,交给 Spring 生命周期去做);</li>
* <li>设置“全局过滤器”(globalFilters):这些会附加到每一条 URL 过滤链上;</li>
* <li>根据 filterChainDefinitionMap(URL → 链定义字符串)逐条创建过滤链;</li>
* <li>最后创建兜底默认链 "/**"(用于匹配遗漏路径)。</li>
* </ol>
*/
protected FilterChainManager createFilterChainManager() {

// 1) 创建默认的链管理器(会预置一组内置过滤器)
DefaultFilterChainManager manager = new DefaultFilterChainManager();

// 2) 对“默认过滤器集合”应用全局属性(如果该过滤器支持的话)
Map<String, Filter> defaultFilters = manager.getFilters();
for (Filter filter : defaultFilters.values()) {
applyGlobalPropertiesIfNecessary(filter);
}

// 3) 注册调用方配置/注入的“自定义过滤器”
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);

// 如果过滤器可命名(实现了 Nameable),设置其逻辑名
if (filter instanceof Nameable) {
((Nameable) filter).setName(name);
}

// 这里第三个参数传 false:表示“不在这里调用 init()”
// 由 Spring 自己完成初始化(如 @PostConstruct / InitializingBean / init-method)
manager.addFilter(name, filter, false);
}
}

// 4) 设置“全局过滤器”(会附加到所有链上)
manager.setGlobalFilters(this.globalFilters);

// 5) 按 URL → 链定义(如 "/admin/** = authc, roles[admin]")建立具体过滤链
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);
}
}

// 6) 创建兜底默认链,用于匹配遗漏的路径(此处假定 ANT 风格路径匹配)
manager.createDefaultChain("/**");

return manager;
}

第一步 new 了一个 DefaultFilterChainManager,在它的构造方法中将 filtersfilterChains 两个成员变量都初始化为一个能保持插入顺序的 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());
}
}

在这个方法中调用了三个方法,三个方法逻辑是一样的,分别是设置 loginUrlsuccessUrlunauthorizedUrl,我们就看第一个 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;
//only apply the login url if they haven't explicitly configured one already:
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
// 3) 注册调用方配置/注入的“自定义过滤器”
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);

// 如果过滤器可命名(实现了 Nameable),设置其逻辑名
if (filter instanceof Nameable) {
((Nameable) filter).setName(name);
}

// 这里第三个参数传 false:表示“不在这里调用 init()”
// 由 Spring 自己完成初始化(如 @PostConstruct / InitializingBean / init-method)
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
// 4) 设置「全局过滤器」:这些会附加到每一条链上(无论 URL 是什么)
manager.setGlobalFilters(this.globalFilters);

// 5) 根据 URL->链定义 的映射,逐条构建过滤链
// 例如:"/api/admin/**" -> "authc, roles[admin]" 会被解析并注册到管理器
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);
}
}

// 6) 创建默认链:兜底匹配任何路径(避免有路径未被前面的规则覆盖)
// 注意:此处假设使用的是 ANT 风格的路径匹配(/** 为多段匹配)
manager.createDefaultChain("/**"); // TODO 这里假定 ANT 路径匹配;在此场景下通常是 OK 的

org.apache.shiro.web.filter.mgt.DefaultFilterChainManager#createChainchainName 是我们配置的过滤路径,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
/**
* 根据给定的 URL 链名与“链定义字符串”创建一条过滤链。
*
* <p>流程概述:
* <ol>
* <li>参数合法性校验(链名与链定义均不能为空);</li>
* <li>优先把「全局过滤器」追加到该链上;</li>
* <li>解析链定义字符串(逗号分隔,支持形如 roles[admin,user] 的带参数写法);</li>
* <li>逐个把解析出的过滤器及其可选配置添加到该链上。</li>
* </ol>
*
* 示例:
* chainName: "/api/admin/**"
* chainDef : "authc, roles[admin,user], perms[file:edit]"
*/
public void createChain(String chainName, String chainDefinition) {
// 1) 基本校验:链名不能为空
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 + "]");
}

// 2) 先把「全局过滤器」加到这条链上(这些过滤器会应用到所有链)
if (!CollectionUtils.isEmpty(globalFilterNames)) {
globalFilterNames.stream().forEach(filterName -> addToChain(chainName, filterName));
}

// 3) 解析链定义字符串,按逗号切分为“过滤器 token”
// 例如:"authc, roles[admin,user], perms[file:edit]"
// => ["authc", "roles[admin,user]", "perms[file:edit]"]
String[] filterTokens = splitChainDefinition(chainDefinition);

// 4) 逐个 token 处理:
// - 拆出过滤器名与可选的配置([] 中内容)
// - 追加到指定链上
for (String token : filterTokens) {
// nameConfigPair[0] = 过滤器名,如 "roles"
// nameConfigPair[1] = 过滤器配置,如 "admin,user"(可能为 null)
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
/**
* 向指定的过滤链(chainName 对应的 URL 模式)追加一个过滤器。
*
* @param chainName 链名/URL 模式(如 "/api/admin/**")
* @param filterName 过滤器逻辑名(如 "authc"、"roles"、"perms"、自定义 "deny" 等)
* @param chainSpecificFilterConfig 该过滤器在此链上的专属配置(如 roles[admin,user] 中的 "admin,user")
*/
public void addToChain(String chainName, String filterName, String chainSpecificFilterConfig) {
// 1) 基本校验:链名不能为空
if (!StringUtils.hasText(chainName)) {
throw new IllegalArgumentException("chainName cannot be null or empty.");
}

// 2) 按名称拿到已注册的过滤器实例(来自 Filter 池/注册表)
Filter filter = getFilter(filterName);
if (filter == null) {
// 如果拿不到,说明这个过滤器还没通过 addFilter(...) 注册到管理器
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).");
}

// 3) 应用“链级”配置:
// 某些过滤器支持基于 URL 链的独立配置,比如:
// roles[admin,user] --> 针对该链的 roles 过滤器需要 "admin,user"
// perms[file:edit] --> 针对该链的 perms 过滤器需要 "file:edit"
// 通常内部会调用 PathConfigProcessor#processPathConfig(chainName, config) 之类的方法
applyChainConfig(chainName, filter, chainSpecificFilterConfig);

// 4) 确保链对象存在(没有则创建),然后把过滤器按顺序追加进去
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
/**
* 根据请求的 URI 获取对应的过滤链。
*
* <p>该方法的作用是匹配请求 URI 与配置的 URL 路径模式(如 `/api/**`)并返回合适的过滤链。
* 它会首先检查路径模式是否匹配,如果匹配则返回对应的过滤链。
* 如果没有找到匹配的路径模式,则返回 null。
*
* @param request 当前请求
* @param response 当前响应
* @param originalChain 原始过滤链(会根据需要被代理)
* @return 返回匹配的过滤链,或如果没有匹配的链则返回 null
*/
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
// 获取当前 FilterChainManager(用于管理所有的过滤器链)
FilterChainManager filterChainManager = getFilterChainManager();

// 如果没有配置任何过滤链,直接返回 null
if (!filterChainManager.hasChains()) {
return null;
}

// 获取当前请求的 URI
final String requestURI = getPathWithinApplication(request);

// 去掉路径结尾的斜杠(/)
final String requestURINoTrailingSlash = removeTrailingSlash(requestURI);

// 这里的 'chain names' 实际上是用户配置的路径模式(例如:/api/**)。我们利用这些路径模式来寻找对应的过滤链
for (String pathPattern : filterChainManager.getChainNames()) {
// 如果请求的 URI 与某个路径模式匹配,则获取对应的过滤链
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("路径模式 [{}] 与请求 URI [{}] 匹配,使用对应的过滤链...", pathPattern, Encode.forHtml(requestURI));
}
// 返回一个代理的过滤链,代理会用当前匹配到的路径模式来包装原始的过滤链
return filterChainManager.proxy(originalChain, pathPattern);
} else {
// 如果路径模式没有匹配,尝试去掉路径末尾的斜杠再匹配一次
// 这个逻辑处理了 URL 中带有和不带斜杠的路径匹配问题
pathPattern = removeTrailingSlash(pathPattern);

// 再次检查去掉斜杠后的路径是否匹配
if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
if (log.isTraceEnabled()) {
log.trace("路径模式 [{}] 与请求 URI [{}] 匹配,使用对应的过滤链...", pathPattern, Encode.forHtml(requestURINoTrailingSlash));
}
// 同样返回一个代理的过滤链
return filterChainManager.proxy(originalChain, pathPattern);
}
}
}

// 如果没有找到匹配的路径模式,返回 null
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 以责任链的形式调用了一系列 FilterOncePerRequestFilter 就是众多 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
/**
* doFilterInternal:为一次经过 Shiro 的请求完成“准备 → 执行 → 清理”的完整流程。
*
* 执行顺序:
* 1) prepareServletRequest(...):准备本次要交给 Shiro 处理的请求对象(可能包装/设置属性)
* 2) prepareServletResponse(...):准备响应对象(可能包装/设置属性)
* 3) createSubject(...):基于请求/响应创建 Subject(含会话/登录身份/权限等上下文)
* 4) 使用 Subject.execute(Runnable/Callable) 在“已绑定 Subject 的线程上下文”中执行:
* - updateSessionLastAccessTime(...):更新会话最后访问时间(影响会话超时)
* - executeChain(...):执行匹配到的 Shiro 过滤器链(跑完再回到容器原始链)
*
* 之所以用 Subject.execute(...)(第 4 步),是为了保证在当前线程内正确完成
* Subject 的绑定与恢复(ThreadLocal 语义),从而确保例如 SecurityUtils.getSubject()
* 能拿到同一个 Subject,并在执行结束后正确清理线程状态。
*
* @param servletRequest 进入过滤器的请求
* @param servletResponse 进入过滤器的响应
* @param chain 容器提供的原始 FilterChain
* @throws IOException I/O 错误
* @throws ServletException 其他非 I/O 异常(会被统一包装为 ServletException 抛出)
*/
protected void doFilterInternal(ServletRequest servletRequest,
ServletResponse servletResponse,
final FilterChain chain)
throws ServletException, IOException {

Throwable t = null; // 用于在统一出口处按规范抛出异常

try {
// 1) 准备/包装请求与响应,使其符合 Shiro 的处理预期
final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain);
final ServletResponse response = prepareServletResponse(request, servletResponse, chain);

// 2) 基于本次请求/响应创建 Subject,并与当前线程绑定
final Subject subject = createSubject(request, response);

// 3) 在 Subject 上下文中执行业务逻辑,保证线程绑定/恢复正确
//noinspection unchecked
subject.execute(new Callable() {
public Object call() throws Exception {
// 3.1 刷新会话的最后访问时间(用于会话超时控制)
updateSessionLastAccessTime(request, response);
// 3.2 执行匹配到的 Shiro 过滤器链,跑完后回到原始容器链
executeChain(request, response, chain);
return null;
}
});

} catch (ExecutionException ex) {
// Subject.execute(...) 可能把真实异常包成 ExecutionException,这里取根因
t = ex.getCause();
} catch (Throwable throwable) {
// 兜底:捕获其他未声明异常
t = throwable;
}

// 4) 统一异常处理:尽量还原为容器期望的两类,否则包装为 ServletException
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
/**
* 执行本次请求所需的过滤器链。
* 1) 先用 getExecutionChain(...) 计算“应该执行哪条链”(可能是原始容器链,也可能是 Shiro 代理链)
* 2) 然后调用 chain.doFilter(request, response) 真正开始执行
*/
protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain)
throws IOException, ServletException {
// 计算出要执行的“最终链”
FilterChain chain = getExecutionChain(request, response, origChain);
// 执行(如果是代理链,会先跑 Shiro 的过滤器,再回到原始容器链)
chain.doFilter(request, response);
}

/**
* 计算“最终要执行的过滤器链”:
* - 如果没有配置 FilterChainResolver,直接返回容器给的原始链(等于不走 Shiro)
* - 如果有 resolver,就让它根据本次请求路径匹配一条链:
* * 命中:返回一个“代理链”(把本 URL 对应的 Shiro 过滤器们挂到前面)
* * 未命中:仍然使用原始链
*/
protected FilterChain getExecutionChain(ServletRequest request, ServletResponse response, FilterChain origChain) {

FilterChain chain = origChain;

// 解析器:负责“根据请求路径找链”
FilterChainResolver resolver = getFilterChainResolver();
if (resolver == null) {
log.debug("未配置 FilterChainResolver,直接使用原始 FilterChain。");
return origChain;
}

// 尝试解析出一条“为当前请求配置好的链”(通常是 ProxiedFilterChain)
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);
}
Filter 名称 对应类
anon org.apache.shiro.web.filter.authc.AnonymousFilter
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter
authcBearer org.apache.shiro.web.filter.authc.BearerHttpAuthenticationFilter
invalidRequest org.apache.shiro.web.filter.InvalidRequestFilter
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
  • anonAnonymousFilter):直接放行,不做任何安全检查;常用于静态资源、公开页。写法:/public/** = anon
  • authcFormAuthenticationFilter):要求用户已登录,否则按 loginUrl 走表单登录流程(默认参数名 username/password/rememberMe 可改)。写法:/account/** = authc
  • authcBasicBasicHttpAuthenticationFilter):HTTP Basic 认证,用于接口/工具;会返回 401 并带 WWW-Authenticate。务必配合 HTTPS。写法:/api/** = authcBasic
  • authcBearerBearerHttpAuthenticationFilter):HTTP Bearer 令牌(Authorization: Bearer ...),更适合无状态 API;自 Shiro 1.5 起提供。写法:/api/** = noSessionCreation, authcBearer
  • invalidRequestInvalidRequestFilter):拦截“可疑”URL(分号、反斜杠、非 ASCII 等);自 1.6 起默认作为全局过滤器,会套到所有路径。若中文/分号被 400 拦截,可按需调 blockNonAscii/blockSemicolon
  • logoutLogoutFilter):执行 Subject.logout() 并重定向到配置的 redirectUrl;浏览器预取下建议用 POST 触发。写法:/logout = logout
  • noSessionCreationNoSessionCreationFilter):禁止在该请求中创建新会话(适合真正的无状态 API);要放在链前面。
  • permsPermissionsAuthorizationFilter):要求拥有给定全部权限;写法:/doc/** = perms[doc:read]
  • rolesRolesAuthorizationFilter):要求拥有给定全部角色;写法:/admin/** = roles[admin,ops](AND 语义)。
  • restHttpMethodPermissionFilter):把 HTTP 方法映射成动词并拼成权限名(如 GET→read、POST→create);写法:/users/** = rest[user]
  • portPortFilter):要求特定端口,不符则重定向到该端口。写法:/secure/** = port[8443]
  • sslSslFilter):要求 HTTPS(isSecure() 且端口匹配),并支持开启 HSTS。写法:/secure/** = ssl
  • userUserFilter):“已知身份”即可放行(登录中或 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);

// Register custom filters
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("deny", deny);
bean.setFilters(filters);

// URL chain — edit to test patterns and order
Map<String, String> chain = new LinkedHashMap<>();
chain.put("/open/**", "anon"); // allow
chain.put("/secure/**", "deny"); // always block (401)
chain.put("/**", "anon"); // fallback allow
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]:

  • Title: Java Shiro
  • Author: sky123
  • Created at : 2025-11-13 00:34:50
  • Updated at : 2025-11-10 01:28:46
  • Link: https://skyi23.github.io/2025/11/13/Java Shiro/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments