本文讲解针对对Shiro有一定了解的同学,如有错误欢迎指正,感谢
@Date: 2021-02-04
Shiro版本(目前最新是1.7.1)
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.6.0</version>
</dependency>
<!--与之对应的starter版本是-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.6.0</version>
</dependency>
1. 相关配置
1.1 ShiroFilterChainDefinition
在配置ShiroConfig
的ShiroFilterChainDefinition
时,设置登录(我定义的url是"/login"
)请求不会被自定义的filter处理(anon,匿名访问,带有此标示,会被anon
对应的过滤器处理,但是该过滤器会直接放行)。
1.2 多Realm
由于整合了JWT所以定义了两个Realm
DbRealm
:用于处理登录请求
JwtRealm
:用于处理需要JWT认证、鉴权后的请求。(JwtCredentialsMatcher是我自定义的JwtRealm的密码匹配器)
1.3 CredentialsMatcher
密码匹配器重写了HashedCredentialsMatcher
(Hash散列密码匹配器),并注入DbRealm
。其中AdminDao
用于CRUD管理员(用户)信息。
散列算法:MD5
散列次数:1024次
参数设置原因:
由于保证账户的安全性,数据库中密码采用密文存储,因此,本文定义的注册流程(如下)对密码进行了相同的处理
1.4 ModularRealmAuthenticator
由于采用了多realm,在Shiro自带的认证器ModularRealmAuthenticator
的doMultiRealmAuthentication( )
方法中,捕获了异常,而后续的afterAllAttempts
调用了AtLeastOneSuccessfulStrategy
中的afterAllAttempts(token, aggregate)
,抛出了新的异常AuthenticationException
上述结果会导致UnknownAccountException
、IncorrectCredentialsException
等异常被捕获后只抛出AuthenticationException
,当然,这是多realm会出现的情况,一个realm都是正常的(真的坑爹)。
解决方法:
重写
doMultiRealmAuthentication()
,删掉捕获异常的地方,也即删除try...catch...
就能重新捕获UnknownAccountException
等异常。
本节参考博客:https://blog.csdn.net/dan339811953/article/details/104798079
2. 请求流程分析
2.1 登录Controller
2.1.1 Controller
放在service也可以,别杠了 = =
如注释所述,显然,认证流程是发生subject.login(token)
中的。
再向里追溯,进入Subject
接口的实现类DelegatingSubject
的login(AuthenticationToken token)
方法
2.1.2 DelegatingSubject
这里调用了安全管理器的login(...)
方法
this
:subject主体,这是个抽象的概念,代表请求登录的Object(我理解为是一个需要进行登录操作的对象,有点像身份证,记录了用户名,密码以及相关状态)
token
:由用户名和明文密码生成
继续深入
2.1.3 DefaultSecurityManager
调用了实现SecurityManager
接口的DefaultSecurityManager
类的login(...)
方法
Info = authenticate(token) 是认证的后续实现方法
继续深入
会走到AuthenticatingDefaultSecurityManager
的authenticate(AuthenticationToken token)
方法,这个方法在AbstractAuthenticator
抽象类有实现
进入doAuthenticat(token)
方法,会通过AbstractAuthenticator
抽象类的继承类ModularRealmAuthenticator
2.1.4 ModularRealmAuthenticator
这里会根据realm的数量,调用不同的方法。在1.2 和1.4中,显然是会进入doMultiRealmAuthentication(realms, authenticationToken)
realms
:一个集合,存放自定义的realm
authenticationToken
:之前生成的UserPasswordToken
这里会走1.4中自定义的CustomModularRealmAuthenticator
(名字自定义的)
直接上代码,解说放在注释里了:
/**
* 为什么定义此类?
* 自定义重写ModularRealmAuthenticator类,用与处理多realm的自定义异常捕获问题,因为shiro自带的多realm会将异常捕获
* 主要是在{@link ModularRealmAuthenticator}中的{@code doAuthenticate}方法中判断了是否是多realm,走了不同的认证流程
* 在多realm认证中{@code doMultiRealmAuthentication}捕获了异常,而后续的{@code afterAllAttempts}
* 调用了{@link AtLeastOneSuccessfulStrategy}中的{@code afterAllAttempts},抛出了新的异常{@code AuthenticationException}
* 解决办法:
* 只需去除中间捕获异常的过程
* @date 2021/2/3 下午1:53
*/
@Slf4j
public class CustomModularRealmAuthenticator extends ModularRealmAuthenticator {
@Override
protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) {
// 策略:ShiroConfig中配置了,我采用的是FirstSuccessfulStrategy
//
// shiro自带的三种策略:
// 1. FirstSuccessfulStrategy:只要有一个Realm验证成功即可,只返回第一个成功验证身份的Realm认证信息,其他的忽略;
// 2. AtLeastOneSuccessfulStrategy:(默认)只要有一个Realm验证成功即可,和FirstSuccessfulStrategy 不同,
// 返回所有 Realm 身份验证成功的认证信息;
// 3. AllSuccessfulStrategy:所有Realm验证成功才算成功,且返回所有Realm身份验证成功的 认证信息,如果有一个失败就失败了。
AuthenticationStrategy strategy = getAuthenticationStrategy();
// FirstSuccessfulStrategy中返回的是null
AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);
if (log.isTraceEnabled()) {
log.trace("Iterating through {} realms for PAM authentication", realms.size());
}
// 遍历每个realm
for (Realm realm : realms) {
// 通过realm中重写的support方法可以区别不同类型的token,因为是集成了jwt,所以还有jwt
aggregate = strategy.beforeAttempt(realm, token, aggregate);
// 判断realm是否支持token,在自定义的两个realm中,肯定只有DbRealm支持
if (realm.supports(token)) {
log.trace("Attempting to authenticate token [{}] using realm [{}]", token, realm);
Throwable t = null;
/*
* 此处执行取消try catch,若异常直接抛出,其他同原方法
*/
// 这里会去AuthenticatingRealm中判读获取AuthenticationInfo
// 首先是getCachedAuthenticationInfo,但是没有定义缓存的AuthenticationInfo,所以跳过,
// 接下来,也就是自定义Realm时重写的doGetAuthenticationInfo(AuthenticationToken token)方法
AuthenticationInfo info = realm.getAuthenticationInfo(token);
aggregate = strategy.afterAttempt(realm, token, info, aggregate, t);
} else {
log.debug("Realm [{}] does not support token {}. Skipping realm.", realm, token);
}
}
aggregate = strategy.afterAllAttempts(token, aggregate);
return aggregate;
}
把上述第43行的代码拿出来分析
2.1.5 AuthenticatingRealm
获取缓存的AuthenticationInfo
,因为还没集成Redis,所以还没做缓存。Shiro虽然自带有CacheManager,但默认是不缓存AuthenticationInfo的。
进入自定义的DbRealm的doGetAuthenticationInfo(AuthenticationToken token)
2.1.5.1 DbRealm
token
:请求时,生成的带有用户名和明文密码的UsernamePasswordToken
AuthenticationInfo
: 数据库中查出的用户名、密文密码、盐(在1.3中有说明)
继续回到2.1.5 AuthenticatingRealm
中,流程向下,走到了assertCredentialsMatch(token, info)
,至此,明白终于在这个方法里,通过密码匹配器来验证带明文密码的token经过散列后得到的密文,是否和info中数据库保存的密文密码相同。
进入这个方法
首先是获取密码匹配器,在1.3中已说明我配置的的DbRealm的密码匹配器HashedCredentialsMatcher
,所以执行cm.doCredentialsMatch(token, info)
2.1.6 HashedCredentialsMatcher
这里是把‘明文加盐散列化后的密文’ 和‘数据库中保存密码‘由String类型转为Char数组,以保证安全性。可参考下面这个博客
参考博客:https://blog.csdn.net/u012881904/article/details/53843386
为什么Java中的密码优先使用 char[] 而不是String?
知乎:https://www.zhihu.com/question/36734157
防止对方文档丢失,做了搬运 = =
String在Java中是不可变对象,如果作为普通文本存储密码,那么它会一直存在内存中直至被垃圾收集器回收。这就意味着一旦创建了一个字符串,如果另一个进程把尝试内存的数据导出(dump),在GC进行垃圾回收之前该字符串会一直保留在内存中,那么该进程就可以轻易的读取到该字符串。
而对于数组,可以在使用该数组之后显示地擦掉数组中的内容,你可以使用其他不相关的内容把数组内容覆盖掉,例如,在使用完密码后,我们将char[]的值均赋为0,如果有人能以某种方式看到内存映像,他只能看到一串0;而如果我们使用的是字符串,他们便能以纯文本方式看到密码。因此,使用char[]是相对安全的。
推荐使用char[],这是从安全角度来选择的。但是,我们应当注意到,即使是用char[]处理密码也只是降低被攻击的概率而已,还是会有其他方法攻破数组处理的密码。
另一方面,使用String的时候,你可能会不经意间将密码打印出来(如log文件),此时,使用char[]就显得更加的安全了,如:
public static void main(String[] args) { Object pw = “Password”; System.out.println(“String: ” + pw); }
pw = "Password".toCharArray(); System.out.println("Array: " + pw);
此时的输出结果将会是
String: Password Array: [C@5829428e
实际上,即使使用了char[]保存密码也仍然不够安全,内存中还是可能会有这串数据的零碎副本,因此,建议使用加密的密码来代替普通的文本字符串密码,并且在使用完后记得立即清除。
3. 尾声
在equals(...)
中发现了一个有趣的算法,可以拿来做字符串比较么?毕竟是官方源码
翻一下说明:
在字节数组a中的所有字节都要被确认相等。算法计算的时间仅仅依赖于a数组的长度,与b的长度或a与b的内容无关。
package java.security
// MessageDigest.java中
public static boolean isEqual(byte[] digesta, byte[] digestb) {
/* All bytes in digesta are examined to determine equality.
* The calculation time depends only on the length of digesta
* It does not depend on the length of digestb or the contents
* of digesta and digestb.
*/
if (digesta == digestb) return true;
if (digesta == null || digestb == null) {
return false;
}
int lenA = digesta.length;
int lenB = digestb.length;
if (lenB == 0) {
return lenA == 0;
}
int result = 0;
result |= lenA - lenB;
// time-constant comparison
for (int i = 0; i < lenA; i++) {
// If i >= lenB, indexB is 0; otherwise, i.
int indexB = ((i - lenB) >>> 31) * i;
result |= digesta[i] ^ digestb[indexB];
}
return result == 0;
}