1. 拦截器流程梳理
这里梳理的工作流程是以开发者的角度来梳理其执行流程,不会过多涉及到Shiro的内部执行流程。通过上一章节对FormAuthenticationFilter
的改造,知道了一个大概。下面会对它做详细分析。
继承关系比较长,这里拆分成两张图
-
AbstractFilter :它实现了
javax.servlet.Filter
接口,也就是JavaEE Servlet 规范中的Filter,并进行了初始化。所以Shiro web中提供的Filter ,实际上就是一个 Servlet Filter。在传统web项目中,Servlet Filter是配置在web.xml中的,那么在SpringBoot中,这些Filter会被Spring 容器接管,改如何配置呢?实际上上一个章节已经配置过了,模板代码:
@Bean public FilterRegistrationBean<AuthenticationFilter> customShiroFilterRegistration(ShiroFilterFactoryBean shiroFilterFactoryBean) { ... }
给Sprinig容器中扔一个
FilterRegistrationBean
即可,它是一个泛型类, T 就是要注册的Filterpublic class FilterRegistrationBean<T extends Filter> extends AbstractFilterRegistrationBean<T>
-
NameableFilter:提供了name属性表示Filter的名称如“anon,auth,logout”等,在spring应用中,这些Filter将会被注册到SpringShiroFilterFactoryBean上,注册完毕之后,框架会遍历那些注册的Filter,调用setName来将name保存到filter对象中
-
OncePerRequestFilter: 首先看这个Filter是否已经执行过了,如果已经执行过了,则放行,交给下一个过滤器。如果没有执行,则执行doFilterInternal 这个抽象方法,这个抽象方法由子类来实现。它的主要功能是保证这个filter在一次请求中只能执行一次
-
AdviceFilter:对拦截器做的工作进行了前置和后置处理,实现了 doFilterInternal 方法,这个方法中做了一系列的流程判断:
-
调用preHandle方法来判断流程是否还要继续,如果需要继续,则将请求交给后续的过滤器链
-
后续的过滤器链执行。调用的是executeChain方法,这个方法仅仅简单的执行了 chain.doFilter(request, response); 在后面的大量子类中对它都做了重写
-
调用postHandle 来继续做一些后置 处理
-
-
PathMatchingFilter: 定义了一个PatternMatcher,默认使用了是AntPathMatcher匹配器,还定义了一个appliedPaths ,是一个ant格式的路径匹配器,预先定义一些路径,如果当前请求的url与设定的路径匹配上了,则处理,否则不进行处理
-
AccessControlFilter抽象类: 定义了 loginUrl 属性表示登录地址,默认为/login.jsp. 重写了父类的
preHandler
方法, 在这个方法中会调用的抽象方法,这些抽象方法需要后面的子类来实现- isAccessAllowed抽象方法
- onAccessDenied抽象方法
-
AuthenticationFilter 抽象类:它重写了 isAccessAllowed() 方法,获取Shiro 的 Subject 对象后判断是否已经认证过了
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { Subject subject = getSubject(request, response); return subject.isAuthenticated() && subject.getPrincipal() != null; }
-
AuthenticatingFilter 抽象类:这个类中有几个重点要关注的方法:
-
createToken 抽象方法,用于创建 Shiro中的 AuthenticationToken .
AuthenticationToken 是一个核心接口,它定义了身份验证过程中客户端提交的用于证明用户身份和权限的数据结构. 它包含两个方法:
-
Object getPrincipal()
: 这个方法返回主体(Principals),通常代表的是用户的标识信息,如用户名、用户ID或者更复杂的用户实体对象。主体是认证过程中用来唯一识别用户的身份元素 -
Object getCredentials()
: 这个方法返回凭证(Credentials),通常对应于用户的密码、密钥或者其他形式的秘密或证据,用以验证主体的真实性。凭证是敏感信息,通常会进行加密处理。
-
-
executeLogin 方法: 这个方法中首先创建了
AuthenticationToken
, 然后获取了Subject
对象,然后调用了subject.login(token)
方法。它实际完成的工作就是将 token交给Shiro框架来完成登录的工作。这个login 方法就进入到了Shiro的内部工作流程中了。这个内部工作流程简单来讲就是会调用
Realm
来获取用户正确的身份信息,然后再使用匹配器来比较正确身份信息和 客户端提交的token信息 -
onLoginSuccess() 方法: 登录成功后的方法,在这个类中,返回了
true
, 这样请求就会到达Spring的Controller中。 -
onLoginFailure()方法: 登录失败后的方法,在这个类中,返回了
false
,此时请求调用不会继续向前。
-
-
FormAuthenticationFilter:
-
onAccessDenied: 判断是不是登录请求,如果不是登录请求则跳转到登录页面。上一章节我们对它进行了重写,不会跳转到登录页面,而是返回JSON格式的数据
如果是登录请求,再判断是不是登录提交,如果不是则直接返回true,执行executeLogin方法,完成登录
-
覆盖onLoginSuccess ,跳转登录成功页,上一章节我们对她进行了改造,返回JSON格式数据
-
覆盖 onLoginFailure, 登录失败后,返回了true,表示它可以继续进入Spring Controller,此时可以在Controller中获取登录失败的原因
整个Filter的工作流程如下:
-
以上只是Shiro Web 中的过滤器的工作流程,整个流程中,红色部分的 subject.isAuthenticated()
和 subject.login(token)
才是Shiro的核心功能部分。下面从 改造Shiro的 Realm开始,逐步了解Shiro的核心框架流程。
2. Shiro核心概念
上一章节提到了核心概念,但是没有详细分析。Shiro 架构包含三个主要的理念:Subject,SecurityManager和 Realm
-
Subject:主体,当前参与应用安全部分的主体。一般指用户,可以是第三方服务,比如我们向第三方开放一些接口供第三方介入。主要指一个正在与当前系统交互的东西,而这个东西就叫Subject. 所以Shiro中的Subject不仅仅指的是用户。
所有Subject都需要SecurityManager,进行管理。 当系统与Subject进行交互,这些交互行最终会委托给SecurityManager。
也就是说前面 红色框内的
subject.isAuthenticated()
和subject.login(token)
方法的调用,最终都会由 SecurityManager来执行,它才是真正干活的 -
SecurityManager:安全管理员,Shiro架构的核心,真正调度和干活的就是它。当系统与Subject进行交互的时候,实际上是委托给 SecurityManager在背后执行操作。
实际开发中,SecurityManager一旦配置好,开发者就很少去关注它了。
-
Realms:Realms作为Shiro和应用的连接桥,因为Shiro是不知道系统里有谁,能干什么,即需要认证和鉴权的数据。所以当需要与安全数据交互的时候,像用户账户,或者访问控制,Shiro就从一个或多个Realms中查找。Realm就像是一个安全数据的数据源提供给Shiro框架。Shiro自身提供了一些可以直接使用的Realms,如果默认的Realms不能满足你的需求,我们也可以定制自己的Realms。
3. TextConfigurationRealm
org.apache.shiro.realm.text.TextConfigurationRealm
是Shiro内置的一个Realm。它允许开发人员通过文本配置文件(例如 properties 文件或 ini 文件)来管理应用程序的安全数据,如用户、角色和权限。
比如:下面是 shiro.ini , 即把数据信息放入到一个叫做 shiro.ini 的文件中
[users]
root = secret, admin
[roles]
admin = *
[permissions]
* = *
在这个例子中,用户 “root” 的密码是 “secret”,并且他属于角色 “admin”;角色 “admin” 拥有所有的权限(“*” 表示所有)。这种配置使得具有 “admin” 角色的用户能够访问系统中的所有资源。
前面章节中我们是写死在代码里面的。
通过TextConfigurationRealm 类继承机构图,来详细了解 Realm。重点要关注的就是最顶层的接口 org.apache.shiro.realm.Realm
3.1 Realm接口
public interface Realm {
String getName();
boolean supports(AuthenticationToken token);
AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
}
所有的Realm都必须实现这个接口
-
String getName()
: Shiro框架核心(SecurityManager)拿到realm对象后通过它可以知道这个 realm 的名称 -
boolean supports(AuthenticationToken token)
: 通过前面Filter的分析,客户端会传入一个AuthenticationToken
,其中包含了客户端提交的身份和凭证信息(简单理解成用户名密码), 那么SecurityManager
在通过realm获取用户真实身份信息的时候,就需要调用这个方法来判断传入的AuthenticationToken
是不是与当前的这个Realm适配。
比如有这样的一个场景:我们开发了一个开放平台,为第三方提供API服务,第三方入驻我方后,我方会为第三方分配AppID(应用ID),SecurityKey(秘钥)等信息。当第三方调用我们API接口的时候,我们就需要对这个调用进行认证,此时就需要写一个Realm 来提供第三方用户的真实身份信息,同时也需要自定义个AuthenticationToken
,即实现 AuthenticationToken
接口的一个类来与Realm相匹配。
-
AuthenticationInfo getAuthenticationInfo(AuthenticationToken token)
:根据客户端提交的token
获取用户的真实身份信息, 真实身份信息被封装到了AuthenticationInfo
中,它翻译为认证信息,实际上它也是一个接口,包含身份和凭证信息。
仔细看
AuthenticationToken
和AuthenticationInfo
的定义:public interface AuthenticationToken extends Serializable { Object getPrincipal(); Object getCredentials(); } public interface AuthenticationInfo extends Serializable { PrincipalCollection getPrincipals(); Object getCredentials(); }
会发现两个很相似,都包含了Principal,翻译为主体身份, Credential翻译为凭证。即都包含了主体身份和凭证两部分信息。
区别是:AuthenticationToken是客户端请求携带过来的信息,它是不可信任的
AuthenticationInfo 是系统中完全可信的,真实的信息
通过Realm接口可以看到,Realm最重要的作用就是:
SecurityManager
拿客户端提交的AuthenticationToken
来换取 完全可信的AuthenticationInfo
, 然后使用匹配器来验证是否匹配
3.2 CachingRealm 抽象类
因为系统在运行的时候经常需要获取AuthenticationInfo
来进行认证,为了提高性能,加入了缓存。至于存放到哪个缓存上,此时可以为 Realm 配置一个 CacheManager
,通过它来管理缓存
3.3 AuthenticatingRealm 抽象类
它是一个抽象类,提供了对用户凭据(如用户名/密码)进行身份验证的基本框架。任何需要处理登录验证的 Realm 都应该扩展这个类。
3.3.1 两个重要的属性:
-
CacheManager
即缓存管理器 -
CredentialsMatcher
前面多次提到了凭证匹配器。默认使用的是SimpleCredentialsMatcher
public interface CredentialsMatcher { boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info); }
它就只提供了一个方法,用来判定 客户端提交的
AuthenticationToken
和可信任的AuthenticationInfo
是否匹配
这两个属性可以通过set方法传入,也可以通过构造方法传入
3.3.2 几个重要的方法
-
AuthenticationInfo getAuthenticationInfo(AuthenticationToken token)
它是 Realm接口中规定的方法实现之所以重要,是因为它规范了认证的主框架流程
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 从缓存中获取AuthenticationInfo
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
//重要!!!! 缓存中没有,则调用 doGetAuthenticationInfo,它是一个抽象方法,由子类实现
info = doGetAuthenticationInfo(token);
LOGGER.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
cacheAuthenticationInfoIfPossible(token, info);
}
} else {
LOGGER.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}
// 重要!!!! 如果 凭证不为空,则开始匹配 AuthenticationToken与 AuthenticationInfo
if (info != null) {
assertCredentialsMatch(token, info);
} else {
LOGGER.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token);
}
return info;
}
-
doGetAuthenticationInfo()
抽象方法,子类要实现的,由子类来提供AuthenticationInfo
-
assertCredentialsMatch()
调用匹配器来验证 AuthenticationToken 与 AuthenticationInfo 是否匹配这个方法是 protected 的方法,比对凭证也可以在子类中完成,也可以提供了一个匹配器来完成对比
3.4 AuthorizingRealm 抽象类
它实例化的时候,同样可以传入 CacheManager
和 CredentialsMatcher
同时,它也实现了 org.apache.shiro.authz.Authorizer
接口,它是个鉴权 接口, 即鉴定是否拥有某个角色,是否具备某种权限等。这个接口中定义了isPermitted
,checkPermission
,hasRole
等与鉴权功能相关的接口。这些接口方法同样会被 SecurityManager
来调用。所以AuthorizingRealm
就需要实现与鉴权 相关的接口。
跟踪源码我们发现,实现的这些与鉴权 相关的方法都会调用 AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals)
这个方法来获取授权信息。而这个方法又会调用protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals)
这个抽象方法,也就是说 用户的授权信息实际上是由它子类 来提供的。
所以子类要实现 doGetAuthorizationInfo 方法来提供用户真实的授权信息。 因为 SecurityManager 在调用鉴权方法(
org.apache.shiro.authz.Authorizer
接口提供)的时候,都会调用doGetAuthorizationInfo
来获取用户授权信息,然后对比看是否具备授权
分析到这里后,现在得到了如下启事: 自定义的Realm 如果继承
AuthorizingRealm
抽象类, 至少要实现两个抽象方法:
- doGetAuthenticationInfo() 提供主体身份信息
- doGetAuthorizationInfo() 提供主体授权信息
3.5 SimpleAccountRealm 类
这个类不是抽象类,通过名字可以看出它提供的主体身份和授权信息是比较简单的用户名密码和一些简单角色,它实现了上面的两个方法:
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals)
对实现有兴趣可以去看它们的源码
3.6 TextConfigurationRealm
它通过重写 onInit()
方法,将文本配置的 主体身份信息和角色权限信息初始化到了Realm 中,为 SecurityManager
在认证和鉴权的时候有可以对比的数据。
4. 小结
通过前面对TextConfigurationRealm
的分析,我们明白了 SecurityManager
主要是
- 依靠
org.apache.shiro.realm.Realm
这个接口的实现类来提供 主体认证身份信息 - 依靠
org.apache.shiro.authz.Authorizer
接口的实现类来提供 主体的角色权限信息 - 而AuthorizingRealm 抽象类实现了这两个接口中所有的方法,但还是留了两个抽象方法
doGetAuthenticationInfo
和doGetAuthorizationInfo
让子类去实现 - 所以要自定义Realm我们就只需要继承 AuthorizingRealm 即可,并实现那两个抽象方法。
5. 自定义Realm
这个自定义的Realm模拟数据库中用户名,密码认证。所不同的是数据库中往往不会保存用户密码的明文,保存的往往都是加密过的数据。Shiro 也提供了一些加密解密,散列算法。在这个例子中正好都用进去。
这个例子中为了相对简单,我不会连接数据库,只在内存中模拟数据库中的数据。
5.1 用户
这里定义一个用户类,来封装用户的一些信息
package com.qinyeit.shirojwt.demos.shiro.entity
...
@Data
@ToString
@Builder
public class SystemAccount implements Serializable {
private String account;//账号
private String pwdEncrypt;//密码密文
private String salt;// 对密码加密的时候使用的salt值
}
5.2 创建两个用户
因为要给用户明文加密,而且两个用户的盐值为随机数,所以写个测试用例先运行出来用户数据:
@Test
public void createSystemAccount() {
// 创建两个系统账号,调用Shiro提供的散列算法计算出加密密码
String account = "administrator";
String pwd = "admin";// 明文密码
// 使用Shiro 提供的随机数生成器生成盐值,默认生成16字节,128位
RandomNumberGenerator saltGenerator = new SecureRandomNumberGenerator();
// 使用16进制字符串表示
String salt = saltGenerator.nextBytes().toHex();
// 使用 SHA-256 散列算法, 对 密码明文加密,加盐 salt,迭代次数为 2 次
SimpleHash shiroHash = new SimpleHash("SHA-256", pwd, salt, 2);
// 加密后的密码, 也可以通过 new Sha256Hash(pwd, salt, 2) 来实现
String pwdEncrypt = shiroHash.toHex();
SystemAccount admin = SystemAccount.builder()
.account(account)
.pwdEncrypt(pwdEncrypt)
.salt(salt)
.build();
// SystemAccount(
// account=administrator,
// pwdEncrypt=0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb,
// salt=55ae2b2c63ddd6d4763e0c57bda9078e
// )
log.info("admin:{}", admin.toString());
///
account = "zhangsan";
pwd = "123456";// 明文密码
// 使用16进制字符串表示
salt = saltGenerator.nextBytes().toHex();
// 使用 SHA-256 散列算法, 对 密码明文加密,加盐 salt,迭代次数为 2 次
pwdEncrypt = new Sha256Hash(pwd, salt, 2).toHex();
SystemAccount zhangsan = SystemAccount.builder()
.account(account)
.pwdEncrypt(pwdEncrypt)
.salt(salt)
.build();
//zhangsan:SystemAccount(
// account=zhangsan,
// pwdEncrypt=3bff14c4279f01892165b96afed9b40ec7f14a9de55d9564c088bad3e04d6411,
// salt=cbce2d1aad0867f8317e7ebeb3427999
// )
log.info("zhangsan:{}", zhangsan.toString());
}
5.4 定义匹配器
自定义的Realm,如果不指定匹配器,那么使用的就是 SimpleCredentialsMatcher
:
SimpleCredentialsMatcher.java
public class SimpleCredentialsMatcher extends CodecSupport implements CredentialsMatcher {
...
// 仅仅只是简单比较了凭证是否相等。这里的凭证就是密码
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
Object tokenCredentials = getCredentials(token);
Object accountCredentials = getCredentials(info);
return equals(tokenCredentials, accountCredentials);
}
}
这个匹配器显然是不适用的。因为客户端提交过来的是 用户名和密码明文,所以 AuthenticationToken
中存放的明文密码和 AuthenticationInfo
中存放的密文密码是没法直接比较的,需要将 AuthenticationToken
中的明文密码和AuthenticationInfo
中存放的salt
进行两个散列得到的加密数据再与AuthenticationInfo
中存放的 pwdEncrypt
进行对比才行。
所以要自定义一个匹配器,在这个匹配器:
- 从
AuthenticationInfo
中取出SystemAccount
信息 - 取出
SystemAccount
中的 salt - 从
AuthenticationToken
中取出凭证,即密码 - 用 Sha256算法,对从token中取出的密码加盐值进行2次散列。因为保存
SystemAccount
的pwdEncrypt 值的时候,采用的也是这个算法,它们要保持一致 - 将散列结果与
SystemAccount
中保存的pwdEncrypt 进行对比
package com.qinyeit.shirojwt.demos.shiro.matcher;
...
public class Sha256HashCredentialsMatcher extends CodecSupport implements CredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
//1. 取出真实身份信息
Object primaryPrincipal = info.getPrincipals().getPrimaryPrincipal();
// 如果身份信息是 SystemAccount 对象
// 此时要注意,Realm 中要将 SystemAccount 对象放入到 AuthenticationInfo 中
if (primaryPrincipal instanceof SystemAccount account) {
String accountPwd = account.getPwdEncrypt();
//2. 获取盐值
String accountSalt = account.getSalt();
//3. token中取出凭证. 这里可以强转成UsernamePasswordToken
String tokenPwd = new String(((UsernamePasswordToken) token).getPassword());
//4. 进行2次散列
String tokenPwdSha = new Sha256Hash(tokenPwd, accountSalt, 2).toHex();
//5. 与account 中的 pwdEncrypt进行对比
return accountPwd.equals(tokenPwdSha);
}
return false;
}
}
5.5 定义 Realm
自定义的Realm 只需要继承 AuthorizingRealm
即可。为了演示方便,在构造函数中初始化了两个账号,并指定了上面定义的匹配器。
这个Realm支持的AuthenticationToken
是什么类型的?通过查看源码,发现 是 UsernamePasswordToken
.
我们自己定义了一个 com.qinyeit.shirojwt.demos.shiro.filter.AuthenticationFilter
, 在这个 Filter中默认创建的Token也是 UsernamePasswordToken
:
package org.apache.shiro.web.filter.authc;
...
public abstract class AuthenticatingFilter extends AuthenticationFilter {
...
protected AuthenticationToken createToken(String username, String password,
boolean rememberMe, String host) {
// 返回的是 UsernamePasswordToken
return new UsernamePasswordToken(username, password, rememberMe, host);
}
...
}
因为后续我们会创建多个不同的Realm 来处理多端的认证鉴权,也会自己扩展一些 AuthenticationToken
, 所以在自定义的Reaml中虽然可以使用父类的 supports
方法来判断当前的Realm是否支持所使用的Token类型,还是写明白比较好一些。下面是自定义的 SystemAccountRealm:
public class SystemAccountRealm extends AuthorizingRealm {
// 模拟数据库中的账号信息 key为账号
private Map<String, SystemAccount> systemAccountMap = new HashedMap();
// 一个账号可以拥有多种角色
private Map<String, Set<String>> roles = Map.of(
"administrator", Set.of("admin"),//管理员
"zhangsan", Set.of("normal") // 普通用户
);
// 角色权限
private Map<String, Set<String>> permissions = Map.of(
"admin", Set.of("*", "*:*"), //所有权限
"normal", Set.of("employee:write", "employee:read") //normal角色对员工只有写和读的权限,不能做其它操作
);
public SystemAccountRealm() {
// 指定密码匹配器. 也可以在配置 Realm的时候指定,因为在父类中提供了匹配器的set方法
super(new Sha256HashCredentialsMatcher());
// 构造方法中构建出账号信息
systemAccountMap.put("administrator", SystemAccount.builder()
.account("administrator")
// 明文为 admin
.pwdEncrypt("0b188436fd5c434e3b8ed05cfe7c107250c1ff0ac034fad089db0f017ac3cacb")
.salt("55ae2b2c63ddd6d4763e0c57bda9078e")
.build());
systemAccountMap.put("zhangsan", SystemAccount.builder()
.account("zhangsan")
// 明文为 123456
.pwdEncrypt("3bff14c4279f01892165b96afed9b40ec7f14a9de55d9564c088bad3e04d6411")
.salt("cbce2d1aad0867f8317e7ebeb3427999")
.build());
}
// 当前Realm 只支持 UsernamePasswordToken类型的Token
public boolean supports(AuthenticationToken token) {
return token != null && UsernamePasswordToken.class.isAssignableFrom(token.getClass());
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 1. 获取用户信息
SystemAccount account = (SystemAccount) principals.getPrimaryPrincipal();
// 2. 获取用户角色
Set<String> accountRoles = roles.get(account.getAccount());
if (systemAccount == null) {
throw new AuthenticationException("账号不存在");
}
// 3. 创建认证信息,即正确的用户名和密码。
// 三个参数,第一个参数为主体,第二个参数为凭证,第三个参数为Realm的名称
// 因为上面将凭证信息和主体身份信息都保存在 SystemAccount中了,所以这里直接将 SystemAccount对象作为主体信息即可
// 第二个参数表示凭证,匹配器中会从 SystemAccount中获取凭证信息,所以这里直接传null。
// 第三个参数表示 Realm的名称
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
systemAccount, null, getName());
return authenticationInfo;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(