Springboot+Shiro+JWT+前后端分离:登录流程源码分析

本文讲解针对对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

在配置ShiroConfigShiroFilterChainDefinition时,设置登录(我定义的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自带的认证器ModularRealmAuthenticatordoMultiRealmAuthentication( )方法中,捕获了异常,而后续的afterAllAttempts调用了AtLeastOneSuccessfulStrategy中的afterAllAttempts(token, aggregate),抛出了新的异常AuthenticationException

在这里插入图片描述


上述结果会导致UnknownAccountExceptionIncorrectCredentialsException等异常被捕获后只抛出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接口的实现类DelegatingSubjectlogin(AuthenticationToken token)方法


2.1.2 DelegatingSubject

在这里插入图片描述
这里调用了安全管理器的login(...)方法

this:subject主体,这是个抽象的概念,代表请求登录的Object(我理解为是一个需要进行登录操作的对象,有点像身份证,记录了用户名,密码以及相关状态)

token:由用户名和明文密码生成

继续深入


2.1.3 DefaultSecurityManager


调用了实现SecurityManager接口的DefaultSecurityManager类的login(...)方法

Info = authenticate(token) 是认证的后续实现方法

继续深入

会走到AuthenticatingDefaultSecurityManagerauthenticate(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;
    }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值