其實我不僅會 Spring Security,Shiro 也略懂一二!

和大家分享一個松哥原創的 Shiro 教程吧,還沒寫完,先整一部分,剩下的敬請期待。

1.Shiro簡介

Apache Shiro是一個開源安全框架,提供身份驗證、授權、密碼學和會話管理。Shiro框架具有直觀、易用等特性,同時也能提供健壯的安全性,雖然它的功能不如SpringSecurity那麼強大,但是在普通的項目中也夠用了。

1.1 由來

Shiro的前身是JSecurity,2004年,Les Hazlewood和Jeremy Haile創辦了Jsecurity。當時他們找不到適用於應用程序級別的合適Java安全框架,同時又對JAAS非常失望。2004年到2008年期間,JSecurity託管在SourceForge上,貢獻者包括Peter Ledbrook、Alan Ditzel和Tim Veil。2008年,JSecurity項目貢獻給了Apache軟件基金會(ASF),並被接納成爲Apache Incubator項目,由導師管理,目標是成爲一個頂級Apache項目。期間,Jsecurity曾短暫更名爲Ki,隨後因商標問題被社區更名爲“Shiro”。隨後項目持續在Apache Incubator中孵化,並增加了貢獻者Kalle Korhonen。2010年7月,Shiro社區發佈了1.0版,隨後社區創建了其項目管理委員會,並選舉Les Hazlewood爲主席。2010年9月22日,Shrio成爲Apache軟件基金會的頂級項目(TLP)。

1.2 有哪些功能

Apache Shiro是一個強大而靈活的開源安全框架,它乾淨利落地處理身份認證,授權,企業會話管理和加密。Apache Shiro的首要目標是易於使用和理解。安全有時候是很複雜的,甚至是痛苦的,但它沒有必要這樣。框架應該儘可能掩蓋複雜的地方,露出一個乾淨而直觀的API,來簡化開發人員在應用程序安全上所花費的時間。

以下是你可以用Apache Shiro 所做的事情:

  1. 驗證用戶來覈實他們的身份

  2. 對用戶執行訪問控制,如:判斷用戶是否被分配了一個確定的安全角色;判斷用戶是否被允許做某事

  3. 在任何環境下使用Session API,即使沒有Web容器

  4. 在身份驗證,訪問控制期間或在會話的生命週期,對事件作出反應

  5. 聚集一個或多個用戶安全數據的數據源,並作爲一個單一的複合用戶“視圖”

  6. 單點登錄(SSO)功能

  7. 爲沒有關聯到登錄的用戶啓用"Remember Me"服務

    等等

Apache Shiro是一個擁有許多功能的綜合性的程序安全框架。下面的圖表展示了Shiro的重點:

p306

Shiro中有四大基石——身份驗證,授權,會話管理和加密。

  1. Authentication:有時也簡稱爲“登錄”,這是一個證明用戶是誰的行爲。
  2. Authorization:訪問控制的過程,也就是決定“誰”去訪問“什麼”。
  3. Session Management:管理用戶特定的會話,即使在非Web 或EJB 應用程序。
  4. Cryptography:通過使用加密算法保持數據安全同時易於使用。

除此之外,Shiro也提供了額外的功能來解決在不同環境下所面臨的安全問題,尤其是以下這些:

  1. Web Support:Shiro的web支持的API能夠輕鬆地幫助保護Web應用程序。
  2. Caching:緩存是Apache Shiro中的第一層公民,來確保安全操作快速而又高效。
  3. Concurrency:Apache Shiro利用它的併發特性來支持多線程應用程序。
  4. Testing:測試支持的存在來幫助你編寫單元測試和集成測試。
  5. "Run As":一個允許用戶假設爲另一個用戶身份(如果允許)的功能,有時候在管理腳本很有用。
  6. "Remember Me":在會話中記住用戶的身份,這樣用戶只需要在強制登錄時候登錄。

2.從一個簡單的案例開始身份認證

2.1 shiro下載

要學習shiro,我們首先需求去shiro官網下載shiro,官網地址地址https://shiro.apache.org/,截至本文寫作時,shiro的最新穩定版本爲1.7.1(Shiro 在 2017-2019 曾經停更了兩年,我一度以爲以爲這個項目 gg 了),本文將採用這個版本。當然,shiro我們也可以從github上下載到源碼。兩個源碼下載地址如下:

1.apache shiro 2.github-shiro

上面我主要是和小夥伴們介紹下源碼的下載,並沒有涉及到jar包的下載,jar包我們到時候直接使用maven即可。

2.2 創建演示工程

這裏我們先不急着寫代碼,我們先打開剛剛下載到的源碼,源碼中有一個samples目錄,如下:

p307

這個samples目錄是官方給我們的一些演示案例,其中有一個quickstart項目,這個項目是一個maven項目,參考這個quickstart,我們來創建一個自己的演示工程。

1.首先使用maven創建一個JavaSE工程 工程創建成功後在pom文件中添加如下依賴:

<dependency>
	<groupid>org.apache.shiro</groupid>
	<artifactid>shiro-all</artifactid>
	<version>RELEASE</version>
</dependency>

2.配置用戶

參考quickstart項目中的shiro.ini文件,我們來配置一個用戶,配置方式如下:首先在resources目錄下創建一個shiro.ini文件,文件內容如下:

[users]
sang=123,admin
[roles]
admin=*

以上配置表示我們創建了一個名爲sang的用戶,該用戶的密碼是123,該用戶的角色是admin,而admin具有操作所有資源的權限。

3.執行登錄

OK,做完上面幾步之後,我們就可以來看看如何實現一次簡單的登錄操作了。這個登錄操作我們依然是參考quickstart項目中的類來實現,首先我們要通過shiro.ini創建一個SecurityManager,再將這個SecurityManager設置爲單例模式,如下:

Factory<org.apache.shiro.mgt.securitymanager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
org.apache.shiro.mgt.SecurityManager securityManager = factory.getInstance();
SecurityUtils.setSecurityManager(securityManager);

如此之後,我們就配置好了一個基本的Shiro環境,注意此時的用戶和角色信息我們配置在shiro.ini這個配置文件中,接下來我們就可以獲取一個Subject了,這個Subject就是我們當前的用戶對象,獲取方式如下:

Subject currentUser = SecurityUtils.getSubject();

拿到這個用戶對象之後,接下來我們可以獲取一個session了,這個session和我們web中的HttpSession的操作基本上是一致的,不同的是,這個session不依賴任何容器,可以隨時隨地獲取,獲取和操作方式如下:

//獲取session
Session session = currentUser.getSession();
//給session設置屬性值
session.setAttribute("someKey", "aValue");
//獲取session中的屬性值
String value = (String) session.getAttribute("someKey");

說了這麼多,我們的用戶到現在還沒有登錄呢,Subject中有一個isAuthenticated方法用來判斷當前用戶是否已經登錄,如果isAuthenticated方法返回一個false,則表示當前用戶未登錄,那我們就可以執行登陸,登錄方式如下:

if (!currentUser.isAuthenticated()) {
    UsernamePasswordToken token = new UsernamePasswordToken("sang", "123");
    try {
        currentUser.login(token);
    } catch (UnknownAccountException uae) {
        log.info("There is no user with username of " + token.getPrincipal());
    } catch (IncorrectCredentialsException ice) {
        log.info("Password for account " + token.getPrincipal() + " was incorrect!");
    } catch (LockedAccountException lae) {
        log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                "Please contact your administrator to unlock it.");
    }
    catch (AuthenticationException ae) {
    }
}

首先構造UsernamePasswordToken,兩個參數就是我們的用戶名和密碼,然後調用Subject中的login方法執行登錄,當用戶名輸錯,密碼輸錯、或者賬戶鎖定等問題出現時,系統會通過拋異常告知調用者這些問題。

當登錄成功之後,我們可以通過如下方式獲取當前登陸用戶的用戶名:

log.info("User [" + currentUser.getPrincipal() + "] logged in successfully.");

我們也可以通過調用Subject中的hasRole和isPermitted方法來判斷當前用戶是否具備某種角色或者某種權限,如下:

if (currentUser.hasRole("admin")) {
    log.info("May the Schwartz be with you!");
} else {
    log.info("Hello, mere mortal.");
}
if (currentUser.isPermitted("lightsaber:wield")) {
    log.info("You may use a lightsaber ring.  Use it wisely.");
} else {
    log.info("Sorry, lightsaber rings are for schwartz masters only.");
}

最後,我們可以通過logout方法註銷本次登錄,如下:

currentUser.logout();

OK,至此,我們通過官方案例給小夥伴們簡單介紹了Shiro中的登錄操作,完整案例大家可以參考官方的demo。

3. 聊一聊Shiro中的Realm

3.1 登錄流程是什麼樣的

首先我們來看shiro官方文檔中這樣一張登錄流程圖:

p308

參照此圖,我們的登錄一共要經過如下幾個步驟:

  1. 應用程序代碼調用Subject.login方法,傳遞創建好的包含終端用戶的Principals(身份)和Credentials(憑證)的AuthenticationToken實例(即上文例子中的UsernamePasswordToken)。
  2. Subject實例,通常是DelegatingSubject(或子類)委託應用程序的SecurityManager通過調用securityManager.login(token)開始真正的驗證工作(在DelegatingSubject類的login方法中打斷點即可看到)。
  3. SubjectManager作爲一個基本的“保護傘”的組成部分,接收token以及簡單地委託給內部的Authenticator實例通過調用authenticator.authenticate(token)。這通常是一個ModularRealmAuthenticator實例,支持在身份驗證中協調一個或多個Realm實例。ModularRealmAuthenticator本質上爲Apache Shiro 提供了PAM-style 範式(其中在PAM 術語中每個Realm 都是一個'module')。
  4. 如果應用程序中配置了一個以上的Realm,ModularRealmAuthenticator實例將利用配置好的AuthenticationStrategy來啓動Multi-Realm認證嘗試。在Realms 被身份驗證調用之前,期間和以後,AuthenticationStrategy被調用使其能夠對每個Realm的結果作出反應。如果只有一個單一的Realm 被配置,它將被直接調用,因爲沒有必要爲一個單一Realm的應用使用AuthenticationStrategy。
  5. 每個配置的Realm用來幫助看它是否支持提交的AuthenticationToken。如果支持,那麼支持Realm的getAuthenticationInfo方法將會伴隨着提交的token被調用。

OK,通過上面的介紹,相信小夥伴們對整個登錄流程都有一定的理解了,小夥伴可以通過打斷點來驗證我們上文所說的五個步驟。那麼在上面的五個步驟中,小夥伴們看到了有一個Realm承擔了很重要的一部分工作,那麼這個Realm到底是個什麼東西,接下來我們就來仔細看一看。

3.2 什麼是Realm

根據Realm文檔上的解釋,Realms擔當Shiro和你的應用程序的安全數據之間的“橋樑”或“連接器”。當它實際上與安全相關的數據如用來執行身份驗證(登錄)及授權(訪問控制)的用戶帳戶交互時,Shiro從一個或多個爲應用程序配置的Realm 中尋找許多這樣的東西。在這個意義上說,Realm 本質上是一個特定安全的DAO:它封裝了數據源的連接詳細信息,使Shiro 所需的相關的數據可用。當配置Shiro 時,你必須指定至少一個Realm 用來進行身份驗證和/或授權。SecurityManager可能配置多個Realms,但至少有一個是必須的。Shiro 提供了立即可用的Realms 來連接一些安全數據源(即目錄),如LDAP,關係數據庫(JDBC),文本配置源,像INI 及屬性文件,以及更多。你可以插入你自己的Realm 實現來代表自定義的數據源,如果默認地Realm不符合你的需求。

看了上面這一段解釋,可能還有小夥伴雲裏霧裏,那麼接下來我們來通過一個簡單的案例來看看Realm到底扮演了一個什麼樣的作用,注意,本文的案例在上文案例的基礎上完成。首先自定義一個MyRealm,內容如下:

public class MyRealm implements Realm {
    public String getName() {
        return "MyRealm";
    }
    public boolean supports(AuthenticationToken token) {
        return token instanceof UsernamePasswordToken;
    }
    public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String password = new String(((char[]) token.getCredentials()));
        String username = token.getPrincipal().toString();
        if (!"sang".equals(username)) {
            throw new UnknownAccountException("用戶不存在");
        }
        if (!"123".equals(password)) {
            throw new IncorrectCredentialsException("密碼不正確");
        }
        return new SimpleAuthenticationInfo(username, password, getName());
    }
}

自定義Realm實現Realm接口,該接口中有三個方法,第一個getName方法用來獲取當前Realm的名字,第二個supports方法用來判斷這個realm所支持的token,這裏我假設值只支持UsernamePasswordToken類型的token,第三個getAuthenticationInfo方法則進行了登陸邏輯判斷,從token中取出用戶的用戶名密碼等,進行判斷,當然,我這裏省略掉了數據庫操作,當登錄驗證出現問題時,拋異常即可,這裏拋出的異常,將在執行登錄那裏捕獲到(注意,由於我這裏定義的MyRealm是實現了Realm接口,所以這裏的用戶名和密碼都需要我手動判斷是否正確,後面的文章我會介紹其他寫法)。

OK,創建好了MyRealm之後還不夠,我們還需要做一個簡單配置,讓MyRealm生效,將shiro.ini文件中的所有東西都註釋掉,添加如下兩行:

MyRealm= org.sang.MyRealm
securityManager.realms=$MyRealm

第一行表示定義了一個realm,第二行將這個定義好的交給securityManger,這裏實際上會調用到RealmSecurityManager類的setRealms方法。OK,做好這些之後,小夥伴們可以在MyRealm類中的一些關鍵節點打上斷點,再次執行main方法,看看整個的登錄流程。

4. 再來聊一聊Shiro中的Realm

4.1 Realm的繼承關係

通過查看類的繼承關係,我們發現Realm的子類實際上有很多種,這裏我們就來看看有代表性的幾種:

  1. IniRealm

可能我們並不知道,實際上這個類在我們第二篇文章中就已經用過了。這個類一開始就有如下兩行定義:

public static final String USERS_SECTION_NAME = "users";
public static final String ROLES_SECTION_NAME = "roles";

這兩行配置表示shiro.ini文件中,[users]下面的表示表用戶名密碼還有角色,[roles]下面的則是角色和權限的對應關係。

  1. PropertiesRealm

PropertiesRealm則規定了另外一種用戶、角色定義方式,如下:

user.user1=password,role1 role.role1=permission1

  1. JdbcRealm

這個顧名思義,就是從數據庫中查詢用戶的角色、權限等信息。打開JdbcRealm類,我們看到源碼中有如下幾行:

protected static final String DEFAULT_AUTHENTICATION_QUERY = "select password from users where username = ?";
protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY = "select password, password_salt from users where username = ?";
protected static final String DEFAULT_USER_ROLES_QUERY = "select role_name from user_roles where username = ?";
protected static final String DEFAULT_PERMISSIONS_QUERY = "select permission from roles_permissions where role_name = ?";

根據這幾行預設的SQL我們就可以大致推斷出數據庫中表的名稱以及字段了,當然,我們也可以自定義SQL。JdbcRealm實際上是AuthenticatingRealm的子類,關於AuthenticatingRealm我們在後面還會詳細說到,這裏先不展開。接下來我們就來詳細說說這個JdbcRealm。

4.2 JdbcRealm

  1. 準備工作

使用JdbcRealm,涉及到數據庫操作,要用到數據庫連接池,這裏我使用Druid數據庫連接池,因此首先添加如下依賴:

<dependency>
    <groupid>com.alibaba</groupid>
    <artifactid>druid</artifactid>
    <version>RELEASE</version>
</dependency>
<dependency>
    <groupid>mysql</groupid>
    <artifactid>mysql-connector-java</artifactid>
    <version>5.1.27</version>
</dependency>
  1. 數據庫創建

想要使用JdbcRealm,那我首先要創建數據庫,根據JdbcRealm中預設的SQL,我定義的數據庫表結構如下:

p309

這裏爲了大家能夠直觀的看到表的關係,我使用了外鍵,實際工作中,視情況而定。然後向表中添加幾條測試數據。數據庫腳本小夥伴可以在github上下載到(https://github.com/lenve/shiroSamples/blob/v4/shiroDemo.sql)。

  1. 配置文件處理

然後將shiro.ini中的所有配置註釋掉,添加如下配置:

jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://localhost:3306/shiroDemo
dataSource.username=root
dataSource.password=123
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true
securityManager.realms=$jdbcRealm

這裏的配置文件都很簡單,不做過多贅述,小夥伴唯一需要注意的是permissionsLookupEnabled需要設置爲true,否則一會JdbcRealm就不會去查詢權限用戶權限。

  1. 測試

OK,做完上面幾步就可以測試了,測試方式和第二篇文章中一樣,我們可以測試下用戶登錄,用戶角色和用戶權限。

  1. 自定義查詢SQL

小夥伴們看懂了上文,對於自定義查詢SQL就沒什麼問題了。我這裏舉一個簡單的例子,比如我要自定義authenticationQuery對對應的SQL,查看JdbcRealm源碼,我們發現authenticationQuery對應的SQL本來是select password from users where username = ?,如果需要修改的話,比如說我的表名不是users而是employee,那麼在shiro.ini中添加如下配置即可:

jdbcRealm.authenticationQuery=select password from employee where username = ?

OK,這個小夥伴下來自己做嘗試,我這裏就不演示了。

5. Shiro中多Realm的認證策略問題

5.1 多Realm認證策略

不知道小夥伴們是否還記得這張登錄流程圖:

p308

從這張圖中我們可以清晰看到Realm是可以有多個的,不過到目前爲止,我們所有的案例都還是單Realm,那麼我們先來看一個簡單的多Realm情況。

前面的文章我們自己創建了一個MyRealm,也用過JdbcRealm,但都是單獨使用的,現在我想將兩個一起使用,只需要修改shiro.ini配置即可,如下:

MyRealm= org.sang.MyRealm

jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://localhost:3306/shiroDemo
dataSource.username=root
dataSource.password=123
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true
securityManager.realms=$jdbcRealm,$MyRealm

但是此時我數據庫中用戶的信息是sang/123,MyRealm中配置的信息也是sang/123,我把MyRealm中的用戶信息修改爲江南一點雨/456,此時,我的MyRealm的getAuthenticationInfo方法如下:

public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    String password = new String(((char[]) token.getCredentials()));
    String username = token.getPrincipal().toString();
    if (!"江南一點雨".equals(username)) {
        throw new UnknownAccountException("用戶不存在");
    }
    if (!"456".equals(password)) {
        throw new IncorrectCredentialsException("密碼不正確");
    }
    return new SimpleAuthenticationInfo(username, password, getName());
}

這個時候我們就配置了兩個Realm,還是使用我們一開始的測試代碼進行登錄測試,這個時候我們發現我既可以使用江南一點雨/456進行登錄,也可以使用sang/123進行登錄,用sang/123登錄成功之後用戶的角色信息和之前是一樣的,而用江南一點雨/456登錄成功之後用戶沒有角色,這個也很好理解,因爲我們在MyRealm中沒有給用戶配置任何權限。總而言之,就是當我有了兩個Realm之後,現在只需要這兩個Realm中的任意一個認證成功,就算我當前用戶認證成功。

5.2 原理追蹤

好了,有了上面的問題後,接下來我們在Subject的login方法上打斷點,跟隨程序的執行步驟,我們來到了ModularRealmAuthenticator類的doMultiRealmAuthentication方法中,如下:

protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
    this.assertRealmsConfigured();
    Collection<realm> realms = this.getRealms();
    return realms.size() == 1?this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken):this.doMultiRealmAuthentication(realms, authenticationToken);
}

在這個方法中,首先會獲取當前一共有多少個realm,如果只有一個則執行doSingleRealmAuthentication方法進行處理,如果有多個realm,則執行doMultiRealmAuthentication方法進行處理。doSingleRealmAuthentication方法部分源碼如下:

protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
    ...
    ...
    AuthenticationInfo info = realm.getAuthenticationInfo(token);
    if(info == null) {
        String msg = "Realm [" + realm + "] was unable to find account data for the submitted AuthenticationToken [" + token + "].";
        throw new UnknownAccountException(msg);
    } else {
        return info;
    }
}

小夥伴們看到這裏就明白了,這裏調用了realm的getAuthenticationInfo方法,這個方法實際上就是我們自己實現的MyRealm中的getAuthenticationInfo方法。

那如果有多個Realm呢?我們來看看doMultiRealmAuthentication方法的實現,部分源碼如下:

protected AuthenticationInfo doMultiRealmAuthentication(Collection<realm> realms, AuthenticationToken token) {
    AuthenticationStrategy strategy = this.getAuthenticationStrategy();
    AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);
    Iterator var5 = realms.iterator();
    while(var5.hasNext()) {
        Realm realm = (Realm)var5.next();
        aggregate = strategy.beforeAttempt(realm, token, aggregate);
        if(realm.supports(token)) {
            AuthenticationInfo info = null;
            Throwable t = null;
            try {
                info = realm.getAuthenticationInfo(token);
            } catch (Throwable var11) {
            }
            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;
}

我這裏主要來說下這個方法的實現思路:

  1. 首先獲取多Realm認證策略

  2. 構建一個AuthenticationInfo用來存放一會認證成功之後返回的信息

  3. 遍歷Realm,調用每個Realm中的getAuthenticationInfo方法,看是否能夠認證成功

  4. 每次獲取到AuthenticationInfo之後,都調用afterAttempt方法進行結果合併

  5. 遍歷完所有的Realm之後,調用afterAllAttempts進行結果合併,這裏主要判斷下是否一個都沒匹配上

5.3 自由配置認證策略

OK,經過上面的簡單解析,小夥伴們對認證策略應該有一個大致的認識了,那麼在Shiro中,一共支持三種不同的認證策略,如下:

  1. AllSuccessfulStrategy,這個表示所有的Realm都認證成功纔算認證成功

  2. AtLeastOneSuccessfulStrategy,這個表示只要有一個Realm認證成功就算認證成功,默認即此策略

  3. FirstSuccessfulStrategy,這個表示只要第一個Realm認證成功,就算認證成功

配置方式也很簡單,在shiro.ini中進行配置,在上面配置的基礎上,增加如下配置:

authenticator=org.apache.shiro.authc.pam.ModularRealmAuthenticator
securityManager.authenticator=$authenticator
allSuccessfulStrategy=org.apache.shiro.authc.pam.AllSuccessfulStrategy
securityManager.authenticator.authenticationStrategy=$allSuccessfulStrategy

此時,我們再進行登錄測試,則會要求每個Realm都認證通過纔算認證通過。

6. Shiro中密碼加密

6.1 密碼爲什麼要加密

2011年12月21日,有人在網絡上公開了一個包含600萬個CSDN用戶資料的數據庫,數據全部爲明文儲存,包含用戶名、密碼以及註冊郵箱。事件發生後CSDN在微博、官方網站等渠道發出了聲明,解釋說此數據庫系2009年備份所用,因不明原因泄露,已經向警方報案。後又在官網網站發出了公開道歉信。在接下來的十多天裏,金山、網易、京東、噹噹、新浪等多家公司被捲入到這次事件中。整個事件中最觸目驚心的莫過於CSDN把用戶密碼明文存儲,由於很多用戶是多個網站共用一個密碼,因此一個網站密碼泄露就會造成很大的安全隱患。由於有了這麼多前車之鑑,我們現在做系統時,密碼都要加密處理。

密碼加密我們一般會用到散列函數,又稱散列算法、哈希函數,是一種從任何一種數據中創建小的數字“指紋”的方法。散列函數把消息或數據壓縮成摘要,使得數據量變小,將數據的格式固定下來。該函數將數據打亂混合,重新創建一個叫做散列值的指紋。散列值通常用一個短的隨機字母和數字組成的字符串來代表。好的散列函數在輸入域中很少出現散列衝突。在散列表和數據處理中,不抑制衝突來區別數據,會使得數據庫記錄更難找到。我們常用的散列函數有如下幾種:

  1. MD5消息摘要算法

MD5消息摘要算法是一種被廣泛使用的密碼散列函數,可以產生出一個128位(16字節)的散列值,用於確保信息傳輸完整一致。MD5由美國密碼學家羅納德·李維斯特設計,於1992年公開,用以取代MD4算法。這套算法的程序在 RFC 1321中被加以規範。將數據(如一段文字)運算變爲另一固定長度值,是散列算法的基礎原理。1996年後被證實存在弱點,可以被加以破解,對於需要高度安全性的數據,專家一般建議改用其他算法,如SHA-2。2004年,證實MD5算法無法防止碰撞,因此不適用於安全性認證,如SSL公開密鑰認證或是數字簽名等用途。

  1. 安全散列算法

安全散列算法(Secure Hash Algorithm)是一個密碼散列函數家族,是FIPS所認證的安全散列算法。能計算出一個數字消息所對應到的,長度固定的字符串(又稱消息摘要)的算法。且若輸入的消息不同,它們對應到不同字符串的機率很高。SHA家族的算法,由美國國家安全局所設計,並由美國國家標準與技術研究院發佈,是美國的政府標準,其分別是:SHA-0:1993年發佈,是SHA-1的前身;SHA-1:1995年發佈,SHA-1在許多安全協議中廣爲使用,包括TLS和SSL、PGP、SSH、S/MIME和IPsec,曾被視爲是MD5的後繼者。但SHA-1的安全性在2000年以後已經不被大多數的加密場景所接受。2017年荷蘭密碼學研究小組CWI和Google正式宣佈攻破了SHA-1;SHA-2:2001年發佈,包括SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。雖然至今尚未出現對SHA-2有效的攻擊,它的算法跟SHA-1基本上仍然相似;因此有些人開始發展其他替代的散列算法;SHA-3:2015年正式發佈,SHA-3並不是要取代SHA-2,因爲SHA-2目前並沒有出現明顯的弱點。由於對MD5出現成功的破解,以及對SHA-0和SHA-1出現理論上破解的方法,NIST感覺需要一個與之前算法不同的,可替換的加密散列算法,也就是現在的SHA-3。

6.2 Shiro中如何加密

Shiro中對以上兩種散列算法都提供了支持,對於MD5,Shiro中生成消息摘要的方式如下:

Md5Hash md5Hash = new Md5Hash("123", null, 1024);

第一個參數是要生成密碼的明文,第二個參數密碼的鹽值,第三個參數是生成消息摘要的迭代次數。

Shiro中對於安全散列算法的支持如下(支持多種算法,這裏我舉一個例子):

Sha512Hash sha512Hash = new Sha512Hash("123", null, 1024);

這裏三個參數含義與上文基本一致,不再贅述。shiro中也提供了通用的算法,如下:

SimpleHash md5 = new SimpleHash("md5", "123", null, 1024);
SimpleHash sha512 = new SimpleHash("sha-512", "123", null, 1024);

當用戶註冊時,我們可以通過上面的方式對密碼進行加密,將加密後的字符串存入數據庫中。我這裏爲了簡單,就不寫註冊功能了,就把昨天數據庫中用戶的密碼123改成sha512所對應的字符串,如下:

cb5143cfcf5791478e057be9689d2360005b3aac951f947af1e6e71e3661bf95a7d14183dadfb0967bd6338eb4eb2689e9c227761e1640e6a033b8725fabc783

同時,爲了避免其他Realm的干擾,數據庫中我只配置一個JdbcRealm。

此時如果我不做其他修改的話,登錄必然會失敗,原因很簡單:我登錄時輸入的密碼是123,但是數據庫中的密碼是一個很長的字符串,所以登錄肯定不會成功。通過打斷點,我們發現最終的密碼比對是在SimpleCredentialsMatcher類中的doCredentialsMatch方法中進行密碼比對的,比對的方式也很簡單,直接使用了對用戶輸入的密碼和數據庫中的密碼生成byte數組然後進行比較,最終的比較在MessageDigest類的isEqual方法中。部分邏輯如下:

protected boolean equals(Object tokenCredentials, Object accountCredentials) {
        ...
        ...
        //獲取用戶輸入密碼的byte數組
        byte[] tokenBytes = this.toBytes(tokenCredentials);
        //獲取數據庫中密碼的byte數組
        byte[] accountBytes = this.toBytes(accountCredentials);
        return MessageDigest.isEqual(tokenBytes, accountBytes);
        ...
}

MessageDigest的isEqual方法如下:

public static boolean isEqual(byte[] digesta, byte[] digestb) {
    if (digesta == digestb) return true;
    if (digesta == null || digestb == null) {
        return false;
    }
    if (digesta.length != digestb.length) {
        return false;
    }

    int result = 0;
    // time-constant comparison
    for (int i = 0; i &lt; digesta.length; i++) {
        result |= digesta[i] ^ digestb[i];
    }
    return result == 0;
}

都是很容易理解的比較代碼,這裏不贅述。我們現在之所以登錄失敗是因爲沒有對用戶輸入的密碼進行加密,通過對源代碼的分析,我們發現是因爲在AuthenticatingRealm類的assertCredentialsMatch方法中獲取了一個名爲SimpleCredentialsMatcher的密碼比對器,這個密碼比對器中比對的方法就是簡單的比較,因此如果我們能夠將這個密碼比對器換掉就好了。我們來看一下CredentialsMatcher的繼承關係:

p310

我們發現這個剛好有一個Sha512CredentialsMatcher比對器,這個比對器的doCredentialsMatch方法在它的父類HashedCredentialsMatcher,方法內容如下:

public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
    Object tokenHashedCredentials = hashProvidedCredentials(token, info);
    Object accountCredentials = getCredentials(info);
    return equals(tokenHashedCredentials, accountCredentials);
}

這時我們發現獲取tokenHashedCredentials的方式不像以前那樣簡單粗暴了,而是調用了hashProvidedCredentials方法,而hashProvidedCredentials方法最終會來到下面這個重載方法中:

protected Hash hashProvidedCredentials(Object credentials, Object salt, int hashIterations) {
    String hashAlgorithmName = assertHashAlgorithmName();
    return new SimpleHash(hashAlgorithmName, credentials, salt, hashIterations);
}

這幾行代碼似曾相識,很明顯,是系統幫我們對用戶輸入的密碼進行了轉換。瞭解了這些之後,那我只需要將shiro.ini修改成如下樣子即可實現登錄了:

sha512=org.apache.shiro.authc.credential.Sha512CredentialsMatcher
# 迭代次數
sha512.hashIterations=1024
jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://localhost:3306/shiroDemo
dataSource.username=root
dataSource.password=123
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true
# 修改JdbcRealm中的credentialsMatcher屬性
jdbcRealm.credentialsMatcher=$sha512
securityManager.realms=$jdbcRealm

如此之後,我們再進行登錄測試,就可以登錄成功了。

本小節案例下載:https://github.com/lenve/shiroSamples/archive/refs/tags/v6.zip

7. Shiro中密碼加鹽

7.1 密碼爲什麼要加鹽

不管是消息摘要算法還是安全散列算法,如果原文一樣,生成密文也是一樣的,這樣的話,如果兩個用戶的密碼原文一樣,存到數據庫中密文也就一樣了,還是不安全,我們需要做進一步處理,常見解決方案就是加鹽。鹽從那裏來呢?我們可以使用用戶id(因爲一般情況下,用戶id是唯一的),也可以使用一個隨機字符,我這裏採用第一種方案。

7.2 Shiro中如何實現加鹽

shiro中加鹽的方式很簡單,在用戶註冊時生成密碼密文時,就要加入鹽,如下幾種方式:

Md5Hash md5Hash = new Md5Hash("123", "sang", 1024);
Sha512Hash sha512Hash = new Sha512Hash("123", "sang", 1024);
SimpleHash md5 = new SimpleHash("md5", "123", "sang", 1024);
SimpleHash sha512 = new SimpleHash("sha-512", "123", "sang", 1024)

然後我們首先將sha512生成的字符串放入數據庫中,接下來我要配置一下我的jdbcRealm,因爲我要指定我的鹽是什麼。在這裏我的鹽就是我的用戶名,每個用戶的用戶名是不一樣的,因此這裏沒法寫死,在JdbcRealm中,系統提供了四種不同的SaltStyle,如下:

SaltStyle 含義
NO_SALT 默認,密碼不加鹽
CRYPT 密碼是以Unix加密方式儲存的
COLUMN salt是單獨的一列儲存在數據庫中
EXTERNAL salt沒有儲存在數據庫中,需要通過JdbcRealm.getSaltForUser(String)函數獲取

四種不同的SaltStyle對應了四種不同的密碼處理方式,部分源碼如下:

switch (saltStyle) {
case NO_SALT:
    password = getPasswordForUser(conn, username)[0];
    break;
case CRYPT:
    // TODO: separate password and hash from getPasswordForUser[0]
    throw new ConfigurationException("Not implemented yet");
    //break;
case COLUMN:
    String[] queryResults = getPasswordForUser(conn, username);
    password = queryResults[0];
    salt = queryResults[1];
    break;
case EXTERNAL:
    password = getPasswordForUser(conn, username)[0];
    salt = getSaltForUser(username);
}

在COLUMN這種情況下,SQL查詢結果應該包含兩列,第一列是密碼,第二列是鹽,這裏默認執行的SQL在JdbcRealm一開頭就定義好了,如下:

protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY = "select password, password_salt from users where username = ?";

即系統默認的鹽是數據表中的password_salt提供的,但是我這裏是username字段提供的,所以這裏我一會要自定義這條SQL。自定義方式很簡單,修改shiro.ini文件,添加如下兩行:

jdbcRealm.saltStyle=COLUMN
jdbcRealm.authenticationQuery=select password,username from users where username=?

首先設置saltStyle爲COLUMN,然後重新定義authenticationQuery對應的SQL。注意返回列的順序很重要,不能隨意調整。如此之後,系統就會自動把username字段作爲鹽了。

不過,由於ini文件中不支持枚舉,saltStyle的值實際上是一個枚舉類型,所以我們在測試的時候,需要增加一個枚舉轉換器在我們的main方法中,如下:

BeanUtilsBean.getInstance().getConvertUtils().register(new AbstractConverter() {
    @Override
    protected String convertToString(Object value) throws Throwable {
        return ((Enum) value).name();
    }

    @Override
    protected Object convertToType(Class type, Object value) throws Throwable {
        return Enum.valueOf(type, value.toString());
    }

    @Override
    protected Class getDefaultType() {
        return null;
    }
}, JdbcRealm.SaltStyle.class);

當然,以後當我們將shiro和web項目整合之後,就不需要這個轉換器了。

如此之後,我們就可以再次進行登錄測試了,會發現沒什麼問題了。

7.3 非JdbcRealm如何配置鹽

OK,剛剛是在JdbcRealm中配置了鹽,如果沒用JdbcRealm,而是自己定義的普通Realm,要怎麼解決配置鹽的問題?

首先要說明一點是,我們前面的文章在自定義Realm時都是通過實現Realm接口實現的,這種方式有一個缺陷,就是密碼比對需要我們自己完成,一般在項目中,我們自定義Realm都是通過繼承AuthenticatingRealm或者AuthorizingRealm,因爲這兩個方法中都重寫了getAuthenticationInfo方法,而在getAuthenticationInfo方法中,調用doGetAuthenticationInfo方法獲取登錄用戶,獲取到之後,會調用assertCredentialsMatch方法進行密碼比對,而我們直接實現Realm接口則沒有這一步,部分源碼如下:

public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    AuthenticationInfo info = getCachedAuthenticationInfo(token);
    if (info == null) {
        //調用doGetAuthenticationInfo獲取info,這個doGetAuthenticationInfo是我們在自定義Realm中自己實現的
        info = doGetAuthenticationInfo(token);
        log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
        if (token != null &amp;&amp; info != null) {
            cacheAuthenticationInfoIfPossible(token, info);
        }
    } else {
        log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
    }
    if (info != null) {
        //獲取到info之後,進行密碼比對
        assertCredentialsMatch(token, info);
    } else {
        log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}].  Returning null.", token);
    }

    return info;
}

基於上面所述的原因,這裏我先繼承AuthenticatingRealm,如下:

public class MyRealm extends AuthenticatingRealm {
    public String getName() {
        return "MyRealm";
    }
    public boolean supports(AuthenticationToken token) {
        return token instanceof UsernamePasswordToken;
    }
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = token.getPrincipal().toString();
        if (!"sang".equals(username)) {
            throw new UnknownAccountException("用戶不存在");
        }
        String dbPassword = "a593ccad1351a26cf6d91d5f0f24234c6a4da5cb63208fae56fda809732dcd519129acd74046a1f9c5992db8903f50ebf3c1091b3aaf67a05c82b7ee470d9e58";
        return new SimpleAuthenticationInfo(username, dbPassword, ByteSource.Util.bytes(username), getName());
    }
}

關於這個類,我說如下幾點:

  1. 用戶名我這裏還是手動判斷了下,實際上這個地方要從數據庫查詢用戶信息,如果查不到用戶信息,則直接拋UnknownAccountException

  2. 返回的SimpleAuthenticationInfo中,第二個參數是密碼,正常情況下,這個密碼是從數據庫中查詢出來的,我這裏直接寫死了

  3. 第三個參數是鹽值,這樣構造好SimpleAuthenticationInfo之後返回,shiro會去判斷用戶輸入的密碼是否正確

上面的核心步驟是第三步,系統去自動比較密碼輸入是否正確,在比對的過程中,需要首先對用戶輸入的密碼進行加鹽加密,既然加鹽加密,就會涉及到credentialsMatcher,這裏我們要用的credentialsMatcher實際上和在JdbcRealm中用的credentialsMatcher一樣,只需要在配置文件中增加如下一行即可:

MyRealm.credentialsMatcher=$sha512

sha512和我們上文定義的一致,這裏就不再重複說了。

本小節案例下載:https://github.com/lenve/shiroSamples/archive/refs/tags/v7.zip

8. Shiro中自定義帶角色和權限的Realm

密碼加密加鹽小夥伴們應該沒有問題了,但是前面幾篇文章又給我們帶來了一個新的問題:我們前面IniRealm、JdbcRealm以及自定義的MyRealm,其中前兩個我們都能實現用戶認證以及授權,即既能管理用戶登錄,又能管理用戶角色,而我們自定義的MyRealm,目前還只能實現登錄,不能實現授權,本文我們就來看看自定義Realm如何實現授權。

8.1 問題追蹤

上篇文章我們沒有實現自定義Realm的授權操作,但是這個並不影響我們調用hasRole方法去獲取用戶的權限,我在上文測試代碼上的currentUser.hasRole上面打斷點,通過層層追蹤,我們發現最終來到了ModularRealmAuthorizer類的hasRole方法中,部分源碼如下:

public boolean hasRole(PrincipalCollection principals, String roleIdentifier) {
    assertRealmsConfigured();
    for (Realm realm : getRealms()) {
        if (!(realm instanceof Authorizer)) continue;
        if (((Authorizer) realm).hasRole(principals, roleIdentifier)) {
            return true;
        }
    }
    return false;
}

我們看到在這裏會遍歷所有的realm,如果這個realm是Authorizer的實例,則會進行進一步的授權操作,如果不是Authorizer的實例,則直接跳過,而我們只有一個自定義的MyRealm繼承自AuthenticatingRealm,很明顯不是Authorizer的實例,所以這裏必然返回false,授權失敗,所以要解決授權問題,第一步,得先讓我們的MyRealm成爲Authorizer的實例。

8.2 解決方案

如下圖是Authorizer的繼承關係:

p311

小夥伴們看到,在Authorizer的實現類中有一個AuthorizingRealm,打開這個類,我們發現它的繼承關係如下:

public abstract class AuthorizingRealm extends AuthenticatingRealm
        implements Authorizer, Initializable, PermissionResolverAware, RolePermissionResolverAware {
            ...
        }

我們發現,這個AuthorizingRealm不僅是Authorizer的實現類,同時也是我們上文所用的AuthenticatingRealm的實現類,既然AuthorizingRealm同時是這兩個類的實現類,那麼我把MyRealm的繼承關係由AuthenticatingRealm改爲AuthorizingRealm,肯定不會影響我上文的功能,修改之後的MyRealm如下(部分關鍵代碼):

public class MyRealm extends AuthorizingRealm {
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = token.getPrincipal().toString();
        if (!"sang".equals(username)) {
            throw new UnknownAccountException("用戶不存在");
        }
        String dbPassword = "a593ccad1351a26cf6d91d5f0f24234c6a4da5cb63208fae56fda809732dcd519129acd74046a1f9c5992db8903f50ebf3c1091b3aaf67a05c82b7ee470d9e58";
        return new SimpleAuthenticationInfo(username, dbPassword, ByteSource.Util.bytes(username), getName());
    }

    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        Set<string> roles = new HashSet<string>();
        if ("sang".equals(principals.getPrimaryPrincipal().toString())) {
            roles.add("普通用戶");
        }
        return new SimpleAuthorizationInfo(roles);
    }
}

繼承了AuthorizingRealm之後,需要我們實現doGetAuthorizationInfo方法。在這個方法中,我們配置用戶的權限。這裏我爲了方便,直接添加了普通用戶這個權限,實際上,這裏應該根據用戶名去數據庫裏查詢權限,查詢方式不贅述。

通過源碼追蹤,我們發現最終授權會來到AuthorizingRealm類的如下兩個方法中:

public boolean hasRole(PrincipalCollection principal, String roleIdentifier) {
    AuthorizationInfo info = getAuthorizationInfo(principal);
    return hasRole(roleIdentifier, info);
}

protected boolean hasRole(String roleIdentifier, AuthorizationInfo info) {
    return info != null &amp;&amp; info.getRoles() != null &amp;&amp; info.getRoles().contains(roleIdentifier);
}

這兩個方法的邏輯很簡單,第一個方法中調用的getAuthorizationInfo方法會最終調用到我們自定義的doGetAuthorizationInfo方法,第二個hasRole方法接收的兩個參數,第一個是用戶申請的角色,第二個是用戶具備的角色集,一個簡單的contains函數就判斷出用戶是否具備某個角色了。

但是這個時候,用戶只有角色,沒有權限,我們可以對doGetAuthorizationInfo方法做進一步的完善,如下:

protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    Set<string> roles = new HashSet<string>();
    Set<string> permiss = new HashSet<string>();
    if ("sang".equals(principals.getPrimaryPrincipal().toString())) {
        roles.add("普通用戶");
        permiss.add("book:update");
    }
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(roles);
    info.setStringPermissions(permiss);
    return info;
}

當然,正常情況下,權限也應當是從數據庫中查詢得到的,我這裏簡化下。

那麼這個角色是怎麼驗證的呢?追蹤源碼我們來到了AuthorizingRealm類的如下兩個方法中:

public boolean isPermitted(PrincipalCollection principals, Permission permission) {
    AuthorizationInfo info = getAuthorizationInfo(principals);
    return isPermitted(permission, info);
}

//visibility changed from private to protected per SHIRO-332
protected boolean isPermitted(Permission permission, AuthorizationInfo info) {
    Collection<permission> perms = getPermissions(info);
    if (perms != null &amp;&amp; !perms.isEmpty()) {
        for (Permission perm : perms) {
            if (perm.implies(permission)) {
                return true;
            }
        }
    }
    return false;
}

第一個isPermitted方法中調用了getAuthorizationInfo方法,而getAuthorizationInfo方法最終會調用到我們自己定義的doGetAuthorizationInfo方法,即獲取到用戶的角色權限信息,然後在第二個方法中進行遍歷判斷,查看是否具備相應的權限,第二個isPermitted方法的第一個參數就是用戶要申請的權限。

本小節案例下載:https://github.com/lenve/shiroSamples/archive/refs/tags/v8.zip

9. Shiro整合Spring

9.1 Spring&SpringMVC環境搭建

Spring和SpringMVC環境的搭建,整體上來說,還是比較容易的,因爲這個不是本文的重點,因此這裏我不做詳細介紹,小夥伴可以在文末下載源碼查看Spring+SpringMVC環境的搭建。同時,由於MyBatis的整合相對要容易很多,這裏爲了降低項目複雜度,我也就先不引入MyBatis。

對於項目依賴,除了Spring、SpringMVC、Shiro相關的依賴,還需要加入Shiro和Spring整合的jar,如下:

<dependency>
    <groupid>org.apache.shiro</groupid>
    <artifactid>shiro-spring</artifactid>
    <version>RELEASE</version>
</dependency>

9.2 整合Shiro

搭建好Spring+SpringMVC環境之後,整合Shiro我們主要配置兩個地方:

  1. web.xml中配置代理過濾器,如下:
<filter>
    <filter-name>shiroFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
        <param-name>targetFilterLifecycle</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>shiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

這樣之後,當DelegatingFilterProxy攔截到所有請求之後,都會委託給shiroFilter來處理,shiroFilter是我們第二步在Spring容器中配置的一個實例。

  1. 配置Spring容器

在Spring容器中至少有兩個Bean需要我們配置,一個就是第一步中的shiroFilter,還有一個就是SecurityManager,完整配置如下:

<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
</bean>
<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
    <property name="securityManager" ref="securityManager" />
    <property name="loginUrl" value="/login.jsp"></property>
    <property name="successUrl" value="/success.jsp" />
    <property name="unauthorizedUrl" value="/unauthorized.jsp" />
    <property name="filterChainDefinitions">
        <value>
            /**=authc
        </value>
    </property>
</bean>

這是一個非常簡單的配置,我們在以後的文章中還會繼續完善它,關於這個配置我說如下幾點:

  1. 首先我們需要配置一個securityManager,到時候我們的realm要配置在這裏。

  2. 還要配置一個名爲shiroFilter的bean,這個名字要和web.xml中代理過濾器的名字一致。

  3. shiroFilter中,loginUrl表示登錄頁面地址。

  4. successUrl表示登錄成功地址。

  5. unauthorizedUrl表示授權失敗地址。

  6. filterChainDefinitions中配置的/**=authc表示所有的頁面都需要認證(登錄)之後才能訪問。

  7. authc實際上是一個過濾器,這個我們在後文還會再詳細說到。

  8. 匹配符遵循Ant風格路徑表達式,這裏可以配置多個,匹配順序從上往下匹配到了就不再匹配了。比如下面這個寫法:

/a/b/*=anon
/a/**=authc

假設我的路徑是/a/b/c那麼就會匹配到第一個過濾器anon,而不會匹配到authc,所以這裏的順序很重要。

OK,這些配置寫完後,在webpap目錄下創建對應的jsp文件,如下:

p312

此時,啓動項目去瀏覽器中訪問,無論我們訪問什麼地址,最後都會回到login.jsp頁面,因爲所有的頁面(即使不存在的地址)都需要認證後纔可以訪問。

本小節案例:https://github.com/lenve/shiroSamples/archive/refs/tags/v9.zip

10. Shiro處理登錄的三種方式

10.1 準備工作

很明顯,不管是那種登錄,都離不開數據庫,這裏數據庫我採用我們前面的數據庫,這裏不做贅述(文末可以下載數據庫腳本),但是我這裏需要首先配置JdbcRealm,在applicationContext.xml中首先配置數據源,如下:

<context:property-placeholder location="classpath:db.properties" />
<bean class="com.alibaba.druid.pool.DruidDataSource" id="dataSource">
    <property name="username" value="${db.username}" />
    <property name="password" value="${db.password}" />
    <property name="url" value="${db.url}" />
</bean>

有了數據源之後,接下來配置JdbcRealm,如下:

<bean class="org.apache.shiro.realm.jdbc.JdbcRealm" id="jdbcRealm">
    <property name="dataSource" ref="dataSource" />
    <property name="credentialsMatcher">
        <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <property name="hashAlgorithmName" value="sha-512" />
            <property name="hashIterations" value="1024" />
        </bean>
    </property>
    <property name="saltStyle" value="COLUMN" />
    <property name="authenticationQuery" value="select password, username from users where username = ?" />
</bean>

JdbcRealm中這幾個屬性和我們本系列第七篇文章基本是一致的,首先我們配置了密碼比對器爲HashedCredentialsMatcher,相應的算法爲sha512,密碼加密迭代次數爲1024次,然後我們配置了密碼的鹽從數據表的列中來,username列就是我們的鹽,這些配置和前文都是一致的,不清楚的小夥伴可以參考我們本系列第七篇文章。

10.2 自定義登錄邏輯

自定義登錄邏輯比較簡單,首先我們把login.jsp頁面進行簡單改造:

<form action="/login" method="post">
    <table>
        <tbody><tr>
            <td>用戶名:</td>
            <td><input type="text" name="username"></td>
        </tr>
        <tr>
            <td>密碼:</td>
            <td><input type="password" name="password"></td>
        </tr>
        <tr>
            <td colspan="2"><input type="submit" value="登錄"></td>
        </tr>
    </tbody></table>
</form>

然後創建我們的登錄處理Controller,如下:

@PostMapping("/login")
public String login(String username, String password) {
    Subject currentUser = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    try {
        currentUser.login(token);
        return "success";
    } catch (AuthenticationException e) {
    }
    return "login";
}

登錄成功我們就去success頁面,登錄失敗就回到登錄頁面。做完這兩步之後,我們還要修改shiroFilter中的filterChainDefinitions屬性,要設置/login接口可以匿名訪問,如下:

<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
    <property name="securityManager" ref="securityManager" />
    <property name="loginUrl" value="/login.jsp"></property>
    <property name="successUrl" value="/success.jsp" />
    <property name="unauthorizedUrl" value="/unauthorized.jsp" />
    <property name="filterChainDefinitions">
        <value>
            /login=anon
            /**=authc
        </value>
    </property>
</bean>

做完這些之後,就可以去login.jsp頁面測試登錄了。

上面中方式是我們自己寫登錄邏輯,shiro也給我們提供了兩種不用自己寫登錄邏輯的登錄方式,請繼續往下看。

10.3 基於HTTP的認證

shiro中也提供了基於http協議的認證,當然,這種認證也得有數據庫的輔助,數據配置和前文一樣,我們只需要修改一個配置即可,如下:

<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
    <property name="securityManager" ref="securityManager" />
    <property name="filterChainDefinitions">
        <value>
            /**=authcBasic
        </value>
    </property>
</bean>

這個表示所有的頁面都要經過基於http的認證。此時我們打開任意一個頁面,認證方式如下:

p313

10.4 表單登錄

表單登錄和基於HTTP的登錄類似,都是不需要我們自己寫登錄邏輯的登錄,但是出錯的邏輯還是要稍微處理下,首先修改shiroFilter:

<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
    <property name="securityManager" ref="securityManager" />
    <property name="loginUrl" value="/login" />
    <property name="successUrl" value="/success.jsp" />
    <property name="filterChainDefinitions">
        <value>
            /**=authc
        </value>
    </property>
</bean>

配置登錄頁面,也配置登錄成功後的跳轉頁面,同時設置所有頁面都要登錄後才能訪問。

配置登錄頁面請求,如下:

@RequestMapping("/login")
public String login(HttpServletRequest req, Model model) {
    String shiroLoginFailure = (String) req.getAttribute("shiroLoginFailure");
    if (UnknownAccountException.class.getName().equals(shiroLoginFailure)) {
        model.addAttribute("error", "賬戶不存在!");
    }
    if (IncorrectCredentialsException.class.getName().equals(shiroLoginFailure)) {
        model.addAttribute("error", "密碼不正確!");
    }
    return "login";
}

如果登錄失敗,那麼在request中會有一個shiroLoginFailure的屬性中保存了登錄失敗的異常類名,通過判斷這個類名,我們就可以知道是什麼原因導致了登錄失敗。

OK,配置好這兩步之後,就可以去登錄頁面測試了。

10.5 註銷登錄

註銷登錄比較簡單,就一個過濾器,按如下方式配置:

<property name="filterChainDefinitions">
    <value>
        /logout=logout
        /**=authc
    </value>
</property>

通過get請求訪問/logout即可註銷登錄。

本小節有三個案例,下載地址如下:

11. Shiro中的授權問題

11.1 配置角色

本文的案例在上文的基礎上完成,因此Realm這一塊我依然採用JdbcRealm,相關的授權就不必配置了。但是這裏的數據庫腳本有更新,小夥伴需要下載重新執行(https://github.com/lenve/shiroSamples/blob/v11/shiroDemo.sql)。

先來介紹下目前數據庫中用戶的情況,數據庫中有兩個用戶,sang具有admin的角色,同時具有book:*author:create兩個權限,lisi具有user的角色,同時具有user:infouser:delete兩個權限。修改shiroFilter,如下:

<bean class="org.apache.shiro.spring.web.ShiroFilterFactoryBean" id="shiroFilter">
    <property name="securityManager" ref="securityManager" />
    <property name="loginUrl" value="/login" />
    <property name="successUrl" value="/success.jsp" />
    <property name="unauthorizedUrl" value="/unauthorized.jsp" />
    <property name="filterChainDefinitions">
        <value>
            /admin.jsp=authc,roles[admin]
            /user.jsp=authc,roles[user]
            /logout=logout
            /**=authc
        </value>
    </property>
</bean>

關於這裏的配置,我說如下幾點:

  1. unauthorizedUrl表示授權失敗時展示的頁面
  2. filterChainDefinitions中我們配置了admin.jsp頁面必須登錄後才能訪問,同時登錄的用戶必須具有admin角色,user.jsp也是必須登錄後才能訪問,同時登錄的用戶必須具有user角色

11.2 測試

測試時我們分別用sang/123和lisi/123進行登錄,登錄成功後分別訪問user.jsp和admin.jsp就能看到效果。

11.3 配置權限

上面的方式是配置角色,但是還沒有配置權限,要配置權限,首先要在jdbcRealm中添加允許權限信息的查詢:

<property name="permissionsLookupEnabled" value="true" />

然後配置下shiroFilter:

<property name="filterChainDefinitions">
    <value>
        /admin.jsp=authc,roles[admin]
        /user.jsp=authc,roles[user]
        /userinfo.jsp=authc,perms[user:info]
        /bookinfo.jsp=authc,perms[book:info]
        /logout=logout
        /**=authc
    </value>
</property>

這裏假設訪問userinfo.jsp需要user:info權限,訪問bookinfo.jsp需要book:info權限。

OK,做完這些之後就可以測試了,分別用sang/123和lisi/123進行登錄,登錄成功後分別訪問bookinfo.jsp和userinfo.jsp就可以看到不同效果了。

本小節案例下載:https://github.com/lenve/shiroSamples/archive/refs/tags/v11.zip

12. Shiro中的JSP標籤

12.1 緣起

上篇文章中,我們在success.jsp中寫了很多像下面這種超鏈接:

<h1>登錄成功!</h1>
<h3><a href="/logout">註銷</a></h3>
<h3><a href="/admin.jsp">admin.jsp</a></h3>
<h3><a href="/user.jsp">user.jsp</a></h3>
<h3><a href="/bookinfo.jsp">bookinfo.jsp</a></h3>
<h3><a href="/userinfo.jsp">userinfo.jsp</a></h3>

但是對於不同身份的用戶,並不是每一個鏈接都是有效的,點擊無效的鏈接會進入到未授權的頁面,這樣用戶體驗並不好,最好能夠把不可達的鏈接自動隱藏起來,同時,我也希望能夠方便獲取當前登錄用戶的信息等,考慮到這些需求,我們來聊聊shiro中的jsp標籤。

12.2 標籤介紹

shiro中的標籤並不多,主要有如下幾種:

  1. shiro:guest

shiro:guest標籤只有在當前未登錄時顯示裏邊的內容,如下:

<shiro:guest>
    歡迎【遊客】訪問!
</shiro:guest>
  1. shiro:user

shiro:user是在用戶登錄之後顯示該標籤中的內容,無論是通過正常的登錄還是通過Remember Me登錄,如下:

<shiro:user>
    歡迎【<shiro:principal />】訪問!
</shiro:user>
  1. shiro:principal

shiro:principal用來獲取當前登錄用戶的信息,顯示效果如下:

p314

4.shiro:authenticated

和shiro:user相比,shiro:authenticated的範圍變小,當用戶認證成功且不是通過Remember Me認證成功,這個標籤中的內容纔會顯示出來:

<shiro:authenticated>
    用戶【<shiro:principal />】身份認證通過,不是通過Remember Me認證!
</shiro:authenticated>
  1. shiro:notAuthenticated

shiro:notAuthenticated也是在用戶未認證的情況下顯示內容,和shiro:guest不同的是,對於通過Remember Me方式進行的認證,shiro:guest不會顯示內容,而shiro:notAuthenticated會顯示內容(因爲此時並不是遊客,但是又確實未認證),如下:

<shiro:notauthenticated>
    用戶未進行身份認證
</shiro:notauthenticated>
  1. shiro:lacksRole

當用戶不具備某個角色時候,顯示內容,如下:

<shiro:lacksrole name="admin">
    用戶不具備admin角色
</shiro:lacksrole>
  1. shiro:lacksPermission

當用戶不具備某個權限時顯示內容:

<shiro:lackspermission name="book:info">
    用戶不具備book:info權限
</shiro:lackspermission>
  1. shiro:hasRole

當用戶具備某個角色時顯示的內容:

<shiro:hasrole name="admin">
    <h3><a href="/admin.jsp">admin.jsp</a></h3>
</shiro:hasrole>
  1. shiro:hasAnyRoles

當用戶具備多個角色中的某一個時顯示的內容:

<shiro:hasanyroles name="user,aaa">
    <h3><a href="/user.jsp">user.jsp</a></h3>
</shiro:hasanyroles>
  1. shiro:hasPermission

當用戶具備某一個權限時顯示的內容:

<shiro:haspermission name="book:info">
    <h3><a href="/bookinfo.jsp">bookinfo.jsp</a></h3>
</shiro:haspermission>

本小節案例下載:https://github.com/lenve/shiroSamples/archive/refs/tags/v12.zip

13.Shiro 中的緩存機制

13.1 添加依賴

使用緩存,首先需要添加相關依賴,如下:

<dependency>
    <groupid>org.apache.shiro</groupid>
    <artifactid>shiro-ehcache</artifactid>
    <version>1.4.0</version>
</dependency>

13.2 添加配置文件

ehcache的配置文件主要參考官方的配置,在resources目錄下創建ehcache.xml文件,內容如下:

<ehcache>
    <diskstore path="java.io.tmpdir/shiro-spring-sample" />
    <defaultcache maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="120" timeToLiveSeconds="120" overflowToDisk="false" diskPersistent="false" diskExpiryThreadIntervalSeconds="120" />
    <cache name="shiro-activeSessionCache" maxElementsInMemory="10000" eternal="true" overflowToDisk="true" diskPersistent="true" diskExpiryThreadIntervalSeconds="600" />
    <cache name="org.apache.shiro.realm.SimpleAccountRealm.authorization" maxElementsInMemory="100" eternal="false" timeToLiveSeconds="600" overflowToDisk="false" />
</ehcache>

這些都是ehcache緩存中常規的配置,含義我就不一一解釋了,文末下載源碼有註釋。

13.3 緩存配置

接下來我們只需要在applicationContext中簡單配置下緩存即可,配置方式如下:

<bean class="org.apache.shiro.cache.ehcache.EhCacheManager" id="cacheManager">
    <property name="cacheManagerConfigFile" value="classpath:ehcache.xml" />
</bean>
<bean class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" id="securityManager">
    <property name="realm" ref="jdbcRealm" />
    <property name="cacheManager" ref="cacheManager" />
</bean>

首先配置EhCacheManager類,指定緩存位置,然後在DefaultWebSecurityManager中引入cacheManager即可,如此之後,我們的緩存就應用上了。

13.4 測試

由於我這裏使用了JdbcRealm,如果使用了自定義Realm那麼可以通過打日誌看是否使用了緩存,使用了JdbcRealm之後,我們可以通過打斷點來查看是否應用了緩存,比如我執行如下代碼:

subject.checkRole("admin");
subject.checkPermission("book:info");

通過斷點跟蹤,發現最終會來到AuthorizingRealm的getAuthorizationInfo方法中,在該方法中,首先會去緩存中檢查數據,如果緩存中有數據,則不會執行doGetAuthorizationInfo方法(數據庫操作就在doGetAuthorizationInfo方法中進行),如果緩存中沒有數據,則會執行doGetAuthorizationInfo方法,並且在執行成功後將數據保存到緩存中(前提是配置了緩存,cache不爲null),此時我們通過斷點,發現執行了緩存而沒有查詢數據庫中的數據,部分源碼如下:

protected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) {
    AuthorizationInfo info = null;
    Cache<object, authorizationinfo> cache = getAvailableAuthorizationCache();
    if (cache != null) {
        Object key = getAuthorizationCacheKey(principals);
        info = cache.get(key);
    }
    if (info == null) {
        info = doGetAuthorizationInfo(principals);
        if (info != null &amp;&amp; cache != null) {
            Object key = getAuthorizationCacheKey(principals);
            cache.put(key, info);
        }
    }
    return info;
}

OK,整體來說shiro中的緩存配置還是非常簡單的。

That's all.

本小節案例下載地址:https://github.com/lenve/shiroSamples/archive/v13.zip

待續。。。</object,></permission></string></string></string></string></string></string></realm></realm></org.apache.shiro.mgt.securitymanager>

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章