天天看點

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

本文講解針對對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

對應的過濾器處理,但是該過濾器會直接放行)。

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

1.2 多Realm

由于整合了JWT是以定義了兩個Realm

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

DbRealm

:用于處理登入請求

JwtRealm

:用于處理需要JWT認證、鑒權後的請求。(JwtCredentialsMatcher是我自定義的JwtRealm的密碼比對器)

1.3 CredentialsMatcher

密碼比對器重寫了

HashedCredentialsMatcher

(Hash散列密碼比對器),并注入

DbRealm

。其中

AdminDao

用于CRUD管理者(使用者)資訊。

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

雜湊演算法:MD5

散列次數:1024次

參數設定原因:

由于保證賬戶的安全性,資料庫中密碼采用密文存儲,是以,本文定義的注冊流程(如下)對密碼進行了相同的處理
Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

1.4 ModularRealmAuthenticator

由于采用了多realm,在Shiro自帶的認證器

ModularRealmAuthenticator

doMultiRealmAuthentication( )

方法中,捕獲了異常,而後續的

afterAllAttempts

調用了

AtLeastOneSuccessfulStrategy

中的

afterAllAttempts(token, aggregate)

,抛出了新的異常

AuthenticationException

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析
Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

上述結果會導緻

UnknownAccountException

IncorrectCredentialsException

等異常被捕獲後隻抛出

AuthenticationException

,當然,這是多realm會出現的情況,一個realm都是正常的(真的坑爹)。

解決方法:

重寫

doMultiRealmAuthentication()

,删掉捕獲異常的地方,也即删除

try...catch...

就能重新捕獲

UnknownAccountException

等異常。
Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析
本節參考部落格:https://blog.csdn.net/dan339811953/article/details/104798079

2. 請求流程分析

2.1 登入Controller

2.1.1 Controller

放在service也可以,别杠了 = =

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

如注釋所述,顯然,認證流程是發生

subject.login(token)

中的。

再向裡追溯,進入

Subject

接口的實作類

DelegatingSubject

login(AuthenticationToken token)

方法

2.1.2 DelegatingSubject

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

這裡調用了安全管理器的

login(...)

方法

this

:subject主體,這是個抽象的概念,代表請求登入的Object(我了解為是一個需要進行登入操作的對象,有點像身份證,記錄了使用者名,密碼以及相關狀态)

token

:由使用者名和明文密碼生成

繼續深入

2.1.3 DefaultSecurityManager

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

調用了實作

SecurityManager

接口的

DefaultSecurityManager

類的

login(...)

方法

Info = authenticate(token) 是認證的後續實作方法

繼續深入

會走到

AuthenticatingDefaultSecurityManager

authenticate(AuthenticationToken token)

方法,這個方法在

AbstractAuthenticator

抽象類有實作

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

進入

doAuthenticat(token)

方法,會通過

AbstractAuthenticator

抽象類的繼承類

ModularRealmAuthenticator

2.1.4 ModularRealmAuthenticator

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

這裡會根據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

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

擷取緩存的

AuthenticationInfo

,因為還沒內建Redis,是以還沒做緩存。Shiro雖然自帶有CacheManager,但預設是不緩存AuthenticationInfo的。

進入自定義的

DbRealm的doGetAuthenticationInfo(AuthenticationToken token)

2.1.5.1 DbRealm

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

token

:請求時,生成的帶有使用者名和明文密碼的

UsernamePasswordToken

AuthenticationInfo

: 資料庫中查出的使用者名、密文密碼、鹽(在1.3中有說明)

繼續回到

2.1.5 AuthenticatingRealm

中,流程向下,走到了

assertCredentialsMatch(token, info)

,至此,明白終于在這個方法裡,通過密碼比對器來驗證帶明文密碼的token經過散列後得到的密文,是否和info中資料庫儲存的密文密碼相同。

進入這個方法

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

首先是擷取密碼比對器,在1.3中已說明我配置的的DbRealm的密碼比對器

HashedCredentialsMatcher

,是以執行

cm.doCredentialsMatch(token, info)

2.1.6 HashedCredentialsMatcher

Springboot+Shiro+JWT+前後端分離:登入流程源碼分析1. 相關配置2. 請求流程分析

這裡是把‘明文加鹽散列化後的密文’ 和‘資料庫中儲存密碼‘由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;
    }