@[toc]
今天想和大家聊一聊 Shiro 中的多 Realm 認證策略問題~
在項目中,如果我們想手機驗證碼登錄、第三方 QQ 登錄、郵箱登錄等多種登錄方式共存,那麼就可以考慮通過 Shiro 中的多 Realm 來實現,具體操作中,一個 Realm 剛好就對應一種登錄方式。
多 Realm 登錄的用法並不難,鬆哥之前也專門發過相關的文章和大家分享,傳送門:
- 其實我不僅會 Spring Security,Shiro 也略懂一二!
今天我不想聊用法,主要是想和大家聊一聊這裏相關的源碼。因此本文需要大家有一定的 Shiro 使用經驗,若無,可以參考上面的鏈接惡補一下。
1. ModularRealmAuthenticator
1.1 Realm 去哪了?
我們配置的 Realm,可以直接配置給 SecurityManager,也可以配置給 SecurityManager 中的 ModularRealmAuthenticator。
如果我們是直接配置給 SecurityManager,那麼在完成 Realm 的配置後,會自動調用 afterRealmsSet 方法,在該方法的中,會將我們配置的所有 Realm 最終配置給 ModularRealmAuthenticator。
相關源碼如下:
RealmSecurityManager#setRealm(RealmSecurityManager 是 DefaultWebSecurityManager 的父類)
public void setRealm(Realm realm) {
if (realm == null) {
throw new IllegalArgumentException("Realm argument cannot be null");
}
Collection<Realm> realms = new ArrayList<Realm>(1);
realms.add(realm);
setRealms(realms);
}
public void setRealms(Collection<Realm> realms) {
if (realms == null) {
throw new IllegalArgumentException("Realms collection argument cannot be null.");
}
if (realms.isEmpty()) {
throw new IllegalArgumentException("Realms collection argument cannot be empty.");
}
this.realms = realms;
afterRealmsSet();
}
可以看到,無論是設置單個 Realm 還是設置多個 Realm,最終都會調用到 afterRealmsSet 方法,該方法在 AuthorizingSecurityManager#afterRealmsSet 類中被重寫,內容如下:
protected void afterRealmsSet() {
super.afterRealmsSet();
if (this.authorizer instanceof ModularRealmAuthorizer) {
((ModularRealmAuthorizer) this.authorizer).setRealms(getRealms());
}
}
可以看到,所有的 Realm 最終都被設置給 ModularRealmAuthenticator 了。
所以説,無論是單個 Realm 還是多個 Realm,最終都是由 ModularRealmAuthenticator 統一管理統一調用的。
1.2 ModularRealmAuthenticator 怎麼玩
ModularRealmAuthenticator 中核心的方法就是 doAuthenticate,如下:
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);
}
}
這個方法的邏輯很簡單:
- 首先調用 assertRealmsConfigured 方法判斷一下開發者有沒有配置 Realm,要是沒有配置就直接拋異常了。
- 判斷開發者配置了幾個 Realm,要是配置了一個,就調用 doSingleRealmAuthentication 方法進行處理,要是配置了多個 Realm 則調用 doMultiRealmAuthentication 方法進行處理。
配置一個 Realm 的情況比較簡單,不在本文的討論範圍內,本文主要是想和大家討論多個 Realm 的情況。
當存在多個 Realm 的時候,必然又會帶來另一個問題:認證策略,即怎麼樣就算認證成功?一個 Realm 認證成功就算成功還是所有 Realm 認證成功才算成功?還是怎麼樣。
接下來我們來詳細聊一聊這個話題。
2. AuthenticationStrategy
先來整體上看下,負責認證策略的類是 AuthenticationStrategy,這是一個接口,有三個實現類:
單從字面上來看,三個實現類都好理解:
- AtLeastOneSuccessfulStrategy:至少有一個 Realm 認證成功。
- AllSuccessfulStrategy:所有 Realm 都要認證成功。
- FirstSuccessfulStrategy:這個從字面上理解不太準確,它是隻返回第一個認證成功的用户數據。
第二種其實很好理解,問題在於第 1 個和第 3 個,這兩個單獨理解也好理解,放在一起的話,那有人不禁要問,這倆有啥區別?
老實説,在 1.3.2 之前的版本還真沒啥大的區別,不過現在最新版本還是有些區別,且聽鬆哥來分析。
首先這裏一共涉及到四個方法:
- beforeAllAttempts:在所有 Realm 驗證之前的做準備。
- beforeAttempt:在單個 Realm 之前驗證做準備。
- afterAttempt:處理單個 Realm 驗證之後的後續事宜。
- afterAllAttempts:處理所有 Realm 驗證之後的後續事宜。
第一個和第四個方法在每次認證流程中只調用一次,而中間兩個方法則在每個 Realm 調用前後都會被調用到,偽代碼就類似下面這樣:
上面這四個方法,在 AuthenticationStrategy 的四個實現類中有不同的實現,我整理了下面一張表格,方便大家理解:
大家注意這裏多了一個 merge 方法,這個方法是在 AbstractAuthenticationStrategy 類中定義的,當存在多個 Realm 時,合併多個 Realm 中的認證數據使用的。接下來我們就按照這張表的順序,來挨個分析這裏的幾個方法。
2.1 AbstractAuthenticationStrategy
2.1.1 beforeAllAttempts
直接來看代碼吧:
public AuthenticationInfo beforeAllAttempts(Collection<? extends Realm> realms, AuthenticationToken token) throws AuthenticationException {
return new SimpleAuthenticationInfo();
}
這裏啥都沒幹,就創建了一個空的 SimpleAuthenticationInfo 對象。
2.1.2 beforeAttempt
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
return aggregate;
}
這個方法的邏輯也很簡單,傳入的 aggregate 參數是指多個 Realm 認證後聚合的結果,這裏啥都沒做,直接把結果原封不動返回。
2.1.3 afterAttempt
public AuthenticationInfo afterAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo singleRealmInfo, AuthenticationInfo aggregateInfo, Throwable t) throws AuthenticationException {
AuthenticationInfo info;
if (singleRealmInfo == null) {
info = aggregateInfo;
} else {
if (aggregateInfo == null) {
info = singleRealmInfo;
} else {
info = merge(singleRealmInfo, aggregateInfo);
}
}
return info;
}
這是每個 Realm 認證完成後要做的事情,參數 singleRealmInfo 表示單個 Realm 認證的結果,aggregateInfo 表示多個 Realm 認證結果的聚合,具體邏輯如下:
- 如果當前 Realm 認證結果為 null,則把聚合結果賦值給 info 並返回。
- 如果當前 Realm 認證結果不為 null,並且聚合結果為 null,那麼就把當前 Realm 的認證結果賦值給 info 並返回。
- 如果當前 Realm 認證結果不為 null,並且聚合結果也不為 null,則將兩者合併之後返回。
2.1.4 afterAllAttempts
public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
return aggregate;
}
這裏直接把聚合結果返回,沒啥好説的。
2.1.5 merge
protected AuthenticationInfo merge(AuthenticationInfo info, AuthenticationInfo aggregate) {
if( aggregate instanceof MergableAuthenticationInfo ) {
((MergableAuthenticationInfo)aggregate).merge(info);
return aggregate;
} else {
throw new IllegalArgumentException( "Attempt to merge authentication info from multiple realms, but aggregate " +
"AuthenticationInfo is not of type MergableAuthenticationInfo." );
}
}
merge 其實就是調用 aggregate 的 merge 方法進行合併,正常情況下我們使用的 SimpleAuthenticationInfo 就是 MergableAuthenticationInfo 的子類,所以這裏合併沒問題。
2.2 AtLeastOneSuccessfulStrategy
2.2.1 beforeAllAttempts
同 2.1.1 小節。
2.2.2 beforeAttempt
同 2.1.2 小節。
2.2.3 afterAttempt
同 2.1.3 小節。
2.2.4 afterAllAttempts
public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
//we know if one or more were able to successfully authenticate if the aggregated account object does not
//contain null or empty data:
if (aggregate == null || isEmpty(aggregate.getPrincipals())) {
throw new AuthenticationException("Authentication token of type [" + token.getClass() + "] " +
"could not be authenticated by any configured realms. Please ensure that at least one realm can " +
"authenticate these tokens.");
}
return aggregate;
}
這裏的邏輯很明確,就是當聚合結果為空就直接拋出異常。
2.2.5 merge
同 2.1.5 小節。
2.2.6 小結
結合 2.1 小節的內容,我們來梳理一下 AtLeastOneSuccessfulStrategy 的功能。
- 首先,系統調用 beforeAllAttempts 方法會獲取一個空的 SimpleAuthenticationInfo 對象作為聚合結果 aggregate。
- 接下來遍歷所有的 Realm,在每個 Realm 調用之前先調用 beforeAttempt 方法,該方法只會原封不動的返回聚合結果 aggregate。
- 調用每個 Realm 的 getAuthenticationInfo 方法進行認證。
- 調用 afterAttempt 方法對認證結果進行聚合處理。如果當前 Realm 認證返回 null,就把聚合結果返回;如果當前 Realm 認證不返回 null,就把 當前的 Realm 的認證結果和 aggregate 進行合併(aggregate 不會為 null,因為 beforeAllAttempts 方法中固定返回空對象)。
這就是 AtLeastOneSuccessfulStrategy 的認證策略。可以看到:如果只有一個 Realm 認證成功,那麼正常返回,如果有多個 Realm 認證成功,那麼返回的用户信息中將包含多個認證用户信息。
可以通過如下方式獲取返回的多個用户信息:
Subject subject = SecurityUtils.getSubject();
subject.login(token);
PrincipalCollection principals = subject.getPrincipals();
List list = principals.asList();
for (Object o : list) {
System.out.println("o = " + o);
}
subject.getPrincipals() 方法可以獲取多個認證成功的憑證。
2.3 AllSuccessfulStrategy
2.3.1 beforeAllAttempts
同 2.1.1 小節。
2.3.2 beforeAttempt
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
if (!realm.supports(token)) {
String msg = "Realm [" + realm + "] of type [" + realm.getClass().getName() + "] does not support " +
" the submitted AuthenticationToken [" + token + "]. The [" + getClass().getName() +
"] implementation requires all configured realm(s) to support and be able to process the submitted " +
"AuthenticationToken.";
throw new UnsupportedTokenException(msg);
}
return info;
}
可以看到,這裏就是去檢查了下 Realm 是否支持當前 token。
這塊的代碼我覺得略奇怪,為啥其他認證策略都不檢查,只有這裏檢查?感覺像是一個 BUG。有懂行的小夥伴可以留言討論下這個問題。
2.3.3 afterAttempt
public AuthenticationInfo afterAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo info, AuthenticationInfo aggregate, Throwable t)
throws AuthenticationException {
if (t != null) {
if (t instanceof AuthenticationException) {
throw ((AuthenticationException) t);
} else {
String msg = "Unable to acquire account data from realm [" + realm + "]. The [" +
getClass().getName() + " implementation requires all configured realm(s) to operate successfully " +
"for a successful authentication.";
throw new AuthenticationException(msg, t);
}
}
if (info == null) {
String msg = "Realm [" + realm + "] could not find any associated account data for the submitted " +
"AuthenticationToken [" + token + "]. The [" + getClass().getName() + "] implementation requires " +
"all configured realm(s) to acquire valid account data for a submitted token during the " +
"log-in process.";
throw new UnknownAccountException(msg);
}
merge(info, aggregate);
return aggregate;
}
如果當前認證出錯了,或者認證結果為 null,就直接拋出異常(因為這裏要求每個 Realm 都認證成功,但凡有一個認證失敗了,後面的就沒有必要認證了)。
如果一切都 OK,就會結果合併然後返回。
2.3.4 afterAllAttempts
同 2.1.4 小節。
2.3.5 merge
同 2.1.5 小節。
2.3.6 小結
這種策略比較簡單,應該不用多做解釋吧。如果有多個 Realm 認證成功,這裏也是會返回多個 Realm 的認證信息的,獲取多個 Realm 的認證信息同上一小節。
2.4 FirstSuccessfulStrategy
2.4.1 beforeAllAttempts
public AuthenticationInfo beforeAllAttempts(Collection<? extends Realm> realms, AuthenticationToken token) throws AuthenticationException {
return null;
}
不同於前面,這裏直接返回了 null。
2.4.2 beforeAttempt
public AuthenticationInfo beforeAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException {
if (getStopAfterFirstSuccess() && aggregate != null && !isEmpty(aggregate.getPrincipals())) {
throw new ShortCircuitIterationException();
}
return aggregate;
}
這裏的邏輯是這樣,如果 getStopAfterFirstSuccess() 方法返回 true,並且當前認證結果的聚合不為空,那麼就直接拋出異常,一旦拋出異常,就會跳出當前循環,也就是不會調用當前 Realm 進行認證操作了。這個思路和 FirstSuccessfulStrategy 名字基本上是契合的。
不過這裏有一個方法 getStopAfterFirstSuccess(),看名字就知道是否在第一次成功後停止認證,默認情況下,該變量為 false,即即使第一次認證成功後,也還是會繼續後面 Realm 的認證。
如果我們希望當第一次認證成功後,後面的 Realm 就不認證了,那麼記得配置該屬性為 true。
2.4.3 afterAttempt
同 2.1.3 小節。
2.4.4 afterAllAttempts
同 2.1.4 小節。
2.4.5 merge
不知道小夥伴們是否還記得 merge 方法是在哪裏調用的,回顧 2.1.3 小節,如果當前 Realm 的認證和聚合結果都不為 null,就需要對結果進行合併,原本的合併是真正的去合併,這裏重寫了該方法,就沒有去執行合併了:
protected AuthenticationInfo merge(AuthenticationInfo info, AuthenticationInfo aggregate) {
if (aggregate != null && !isEmpty(aggregate.getPrincipals())) {
return aggregate;
}
return info != null ? info : aggregate;
}
這是三個策略中,唯一重寫 merge 方法的。
這裏的 merge 並沒有真正的 merge,而是:
- 如果聚合結果不為空,就直接返回聚合結果。
- 否則,如果當前認證結果不為空,就返回當前認證結果。
- 否則返回空。
可以看到,這裏的 merge 其實就是挑選一個認證的 info 返回。如果前面有認證成功的 Realm,後面 Realm 認證成功後返回的 info 是不會被使用的。
2.4.6 小結
好啦,現在小夥伴們可以總結出 FirstSuccessfulStrategy 和 AtLeastOneSuccessfulStrategy 的區別了:
- AtLeastOneSuccessfulStrategy:當存在多個 Realm 的時候,即使已經有一個 Realm 認證成功了,後面的 Realm 也還是會去認證,並且如果後面的 Realm 也認證成功了,那麼會將多個 Realm 認證成功的結果進行合併。
- FirstSuccessfulStrategy:當存在多個 Realm 的時候,默認情況下,即使已經有一個 Realm 認證成功了,後面的 Realm 也還是會去認證,但是如果後面的 Realm 也認證成功了,卻並不會使用後面認證成功的 Realm 返回的結果。如果我們希望當一個 Realm 認證成功後,後面的 Realm 就不再認證了,那麼可以配置 stopAfterFirstSuccess 屬性的值,配置方式如下:
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
<property name="authenticator">
<bean class="org.apache.shiro.authc.pam.ModularRealmAuthenticator">
<property name="authenticationStrategy">
<bean class="org.apache.shiro.authc.pam.FirstSuccessfulStrategy">
<property name="stopAfterFirstSuccess" value="true"/>
</bean>
</property>
<property name="realms">
<list>
<ref bean="myRealm01"/>
<ref bean="myRealm02"/>
</list>
</property>
</bean>
</property>
</bean>
3. 小結
好啦,這就是鬆哥和大家分享的 Shiro 多 Realm 情況,感興趣的小夥伴可以去試試哦~
公眾號後台回覆 shiro,獲取 Shiro 相關資料。