Shiro身份認證入門和簡析

前言

Apache Shiro是一個由Java編寫的安全框架,在項目中常用來做身份認證和權限控制。Shiro上手難度不高,而且可以通過繼承和重寫來實現定製化的擴展。

在本帖中將分爲如下三節:

1、 簡要介紹Shiro的結構;

2、 以基於token的身份認證(重點在認證的思路)爲例,介紹shiro的基本用法;

3、 分析Shiro的過濾器鏈。

本文前兩個板塊面向Shiro初學者,主要是介紹Shiro的用法。


一、Shiro的結構

對於一個好的框架,從外部來看應該具有非常簡單易於使用的API,從內部來看應該有一個可擴展的架構,即非常容易插入用戶自定義實現。Shiro也是這樣,不會去維護用戶和權限,爲了實現特定場景的認證校驗與權限控制,我們需要重寫相關的類與方法,然後通過相應的接口注入給Shiro。

我們從應用程序角度的來觀察Shiro是如何工作的:

在這裏插入圖片描述

Application code就是外部代碼,作用就是將需要的身份信息傳過來。Shiro中直接交互的對象是Subject,對於每一個正在訪問的用戶,shiro將爲其創建一個Subject來代表他的身份。

Subject

主體,代表了當前正在訪問的用戶。所有Subject都綁定到SecurityManager,與Subject的所有交互都會委託給SecurityManager。可以把Subject認爲是一個門面,SecurityManager纔是實際的執行者。

SecurityManager

安全管理器,是Shiro的運作核心,它管理着所有Subject。所有與安全有關的操作都會與SecurityManager交互。如果學習過SpringMVC,你可以把它看成DispatcherServlet前端控制器。

Realm

域,說得通俗點就是用戶數據源。Shiro從Realm獲取真實數據(如用戶、角色、權限)。也就是說SecurityManager要驗證用戶身份,會從Realm獲取該用戶的信息,然後再進行比對。也可以從Realm得到該用戶相應的角色/權限。Realm是一個必須定製化的地方。

Filters

過濾器,是真正控制請求是否能通過Shiro的API。即一個請求經過過濾器,如果它滿足了裏面的所有要求,Shiro纔會放行。如果說SecurityManager是Shiro的運作核心,那麼Shiro的過濾器就是Shiro的可擴展核心,用戶可以重寫Filter的方法來達到不同的控制效果。

形象的來說,你寫的controller接口是目的地,SecurityManager就是安全經理,Filter是目的地的門衛,每個人都帶有一個表示身份的票Subject。如果你的票有效,就可以在有效期內多次進門。如果無效,你就會被門衛攔住,然後被指引去經理那憑賬號和密碼換取一個新的有效票然後進門。經理會查Realm庫檢查你的賬號密碼是否正確來決定是否給你換票。

二、認證校驗實例

在本例中,將從零開始介紹如何使用Shiro做身份認證的步驟和思路,權限控制部分暫不做討論。

目標:從前端來的HTTP請求的Header裏附帶token的內容,Shiro根據該token是否有效來判斷該用戶是否處於已認證狀態。如果未認證將返回未認證錯誤碼,如果已認證將放行該請求。前端得到錯誤碼後用username、password請求登錄URL執行認證,認證成功返回token,認證錯誤返回認證錯誤碼。

在編寫的過程中,還會穿插一些講解。建議在寫完代碼後,再跟蹤源碼調試幾遍。特別說明,本例關於token的部分不會展開編碼,重點在於基本使用的思路。

1、在pom.xml導入依賴

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-all</artifactId>
    <version>1.2.4</version>
</dependency>

2、在web.xml中配置shiroFilter過濾器代理

<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>

Shiro對Servlet容器的FilterChain進行了代理。ShiroFilter在繼續Servlet容器的Filter鏈的執行之前,通過ProxiedFilterChain對Servlet容器的FilterChain進行了代理。即先執行Shiro自己的Filter鏈,再執行Servlet容器的Filter鏈(即原始的Filter)。

3、創建application-shiro.xml配置文件

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" />

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

配一個securityManager,一個shiroFilter,再將這個securityManager注入到shiroFilter裏面。這裏的id = "shiroFilter"要和web.xml裏面的filter-name保持一致。

4、自定義Realm

realm是用戶數據源,在登錄的時候會被SecurityManager調用到。我們寫一個自定義的MyAuthRealm繼承AuthorizingRealm,並重寫如下兩個方法:

public class MyAuthRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo
        doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        return null;
    }
}

其中第一個方法用於授權的,一般的做法是從PrincipalCollection對象裏拿到用戶名,根據用戶名查詢出當前用戶的權限或角色封裝到一個AuthorizationInfo的對象返回就好,權限的內容不展開講解。

第二個就是用於認證的。我們可以從token對象(此token是一個Shiro的對象,非我們傳來的token)裏面獲取到請求傳來的賬號和密碼(在過濾器中,我們將說明是什麼時候將傳來的賬號和密碼放入這個對象的)。我們根據用戶名去數據庫查詢真實的用戶數據,將用戶名和真實的密碼封裝到AuthenticationInfo的對象返回就完成了,代碼如下:

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
							throws AuthenticationException {
    //獲取請求傳來的賬號,之後用戶傳來的username都用account表示
    String account = (String) token.getPrincipal();
    String password = (String) token.getCredentials();    //獲取請求傳來的密碼

    //getUserByAccount是一個自定義的private方法,這裏不復現了,內容是:
    //從數據庫根據account查詢,將結果封裝到一個UserInfo對象裏返回
    UserInfo userInfo = getUserByAccount(account);

    //如果查詢不到,拋出表示未知用戶的異常
    if (Objects.isNull(userInfo)) {
        throw new UnknownAccountException("The user is not exist");
    }

    //如果查詢到了,將查詢到的數據庫的數據封裝到這個對象裏,注意:是數據庫的數據,這個數據將會被
    //送到其他地方和用戶傳來的數據比對,如果匹配成功則會繼續,如果失敗,會拋出異常表示驗證失敗
  	//其中,ByteSource.Util.bytes(user.getUserNo)表示MD5加密的鹽
  	//特別說明:這裏簡化的加密方式,項目裏實際加密方式要複雜得多
    return new SimpleAuthenticationInfo(user.getUserNo, user.getPassword(),
                           ByteSource.Util.bytes(user.getUserNo), getName());
}

然後我們將這個realm配置到xml裏,再將myRealm注入到securityManager的realm屬性裏:

<bean id="myRealm" class="com.shiro.demo.security.MyAuthRealm">
  <!-- 配置密碼加密驗證方式,數據庫裏存儲的密碼也是用同樣的方式加密 -->
    <property name="credentialsMatcher">
        <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
          	<!-- 用MD5方式加密 -->
            <property name="hashAlgorithmName" value="MD5" />
          	<!-- 迭代3次,防止解密 -->
            <property name="hashIterations" value="3" />
        </bean>
    </property>
</bean>	

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <!-- 注入自定義realm -->
    <property name="realm" ref="myRealm"/>
</bean>

5、自定義過濾器

在這個步驟會有較多講解。首先貼一張Shiro原生過濾器的繼承關係備用:

在這裏插入圖片描述

就和我們熟知的過濾器一樣,Shiro過濾器也是通過FilterChain對象的chain.doFilter(request, response)控制請求是否可以繼續到達Servlet。但是自AdviceFilter之後,控制變得更加簡單。AdviceFilter的源碼如下(中文註釋都是我加的):

public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) 
							throws ServletException, IOException {
    //打印日誌...
    boolean continueChain = preHandle(request, response);    //預處理

    if (continueChain) {      //如果預處理結果正確,返回true,執行過濾器鏈,返回false則不會執行
        executeChain(request, response, chain);    
    }

    postHandle(request, response);    //後續處理
    //打印日誌...
}

protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
    return true;
}

protected void executeChain(ServletRequest request, ServletResponse response, 
				FilterChain chain) throws Exception {
    chain.doFilter(request, response);
}

protected void postHandle(ServletRequest request, ServletResponse response) throws Exception {
}

其中,doFilterInternal是上層過濾器的抽象方法,這裏進行了重寫。內容是把過濾器的放行從chain.doFilter(request, response)控制轉化爲preHandle方法返回的boolean值控制。而且如果放行了過濾器鏈後,還會執行一個postHandle方法。這樣做就好像給過濾器加了Spring AOP的前置、後置通知一樣。

preHandle方法控制過濾器的放行,而在AdviceFilter的子類PathMatchingFilter中,控制權交給了onPreHandle方法。

而在PathMatchingFilter的子類AccessControlFilter中,控制權再一次轉移到isAccessAllowed和onAccessDenied兩個方法上,源碼如下:

@Override
public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) 
		throws Exception {    
    //isAccessAllowed的意思是“是否允許通行”,這個方法的作用就是判斷用戶是否已經驗證通過
    //onAccessDenied的意思是“在通行拒絕時”,這個方法的作用就是在未驗證的時候做的操作
    return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, 
    	response, mappedValue);
}

protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, 
		Object mappedValue) throws Exception;

protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) 
		throws Exception {
    return onAccessDenied(request, response);
}

protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) 
		throws Exception;

而在AccessControlFilter的子類AuthenticationFilter中,isAccessAllowed得到了具體實現,源碼如下:

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    Subject subject = getSubject(request, response);    //獲取代表當前用戶的Subject
    return subject.isAuthenticated();    //用戶是否已經登錄,如果是返回true,否返回false
}

而在他的子類AuthenticatingFilter中,isAccessAllowed再一次重寫,源碼如下:

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    //如果已認證,直接返回true。如果未認證,該請求不是登錄請求且無需攔截,返回true放行,否則
  	//返回false,從而執行onAccessDenied方法。可見onAccessDenied方法要執行登錄操作。
    return super.isAccessAllowed(request, response, mappedValue) ||
        (!isLoginRequest(request, response) && isPermissive(mappedValue));
}

反觀PathMatchingFilter的onPreHandle方法,如果這個通過則直接返回true放行過濾器,如果爲false則會走onAccessDenied。onAccessDenied表達的是“在通行拒絕時”的意思。

分析到這裏我們好像明白可以怎麼操作了,下面分析哪些地方是需要重寫的。

首先把登錄的url配置進來:

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <!-- 登錄接口,即獲取token的接口 -->
    <property name="loginUrl" value="/token" />
</bean>

然後編寫一個MyAuthenticatingFilter繼承AuthenticatingFilter:

public class MyAuthenticatingFilter extends AuthenticatingFilter {}

再將過濾器配置到配置文件:

<bean id="tokenAuthc" class="com.shiro.demo.security.MyAuthenticatingFilter" />

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="/token" />
    <!-- 配置過濾器鏈,作用是用指定的過濾器去攔截指定的url -->
    <property name="filterChainDefinitions">
        <value>
            <!-- 所有請求都採用上面定義的過濾器攔截(tokenAuthc是自定義過濾器的id) -->
            <!-- 不做任何認證就可以通過的過濾器是anon,用法:/user/get = anon -->
            /** = tokenAuthc
        </value>
    </property>
</bean>

isAccessAllowed方法上面已經敘述,滿足我們的要求,下面重寫onAccessDenied方法:

@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
    if (isLoginAttempt(request, response)) {    //判斷是否是登錄操作
        //根據用戶創建token(header裏的那個獨一無二的字符串)
        String userToken = createUserToken(request);
        //將token和過期時間設置到request裏面,用於後面如果登錄成功將token存入redis
        //在這裏先透露一下,我們的token是否過期是用redis做控制的,驗證成功後會將生產的token
        //存入redis,且會設置過期時間。生成Subject的時候會從redis比對。
        request.setAttribute("___USER_TOKEN___", userToken);
        request.setAttribute("___USER_EXPIRED_TIME___", "1600");

        executeLogin(request, response);    //執行登錄,成功返回true,失敗返回false
    }

    return false;
}

而executeLogin方法是AuthenticatingFilter中的一個方法,源碼如下:

protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    //用請求傳來的account和password創建一個token對象,這個對象就是之前Realm裏用來獲取
    //請求傳來的username、password的那個對象
    AuthenticationToken token = createToken(request, response);
    if (token == null) {
        String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " +
            "must be created in order to execute a login attempt.";
        throw new IllegalStateException(msg);
    }
    try {
        Subject subject = getSubject(request, response);     //獲取Subject
        //執行登錄,成功則會繼續,不成功拋出AuthenticationException異常
        //特別說明,這裏登錄成功會重新創建新的已認證的Subject表示當前用戶,如果失敗則不會創建
        subject.login(token);  
        //登錄成功後進行的操作,返回登錄成功
        return onLoginSuccess(token, subject, request, response);
    } catch (AuthenticationException e) {
        //登錄失敗後進行的操作,返回失敗錯誤碼和錯誤信息
        return onLoginFailure(token, e, request, response);
    }
}

//抽象方法,我們重寫它,目標就是從request裏面將username和password拿出來放進token對象裏
protected abstract AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception;

於是重寫createToken方法:

@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) 
			throws Exception {
    //getPrincipalsAndCredentials是一個自定義的私有方法,作用是將request裏面的
    //username和password拿出來放到一個map裏,這裏不再贅述
    Map<String, String> map = getPrincipalsAndCredentials(request);
    //AuthenticationToken是一個接口,UsernamePasswordToken是他的實現類
    return new UsernamePasswordToken(map.get("username"), map.get("password"));
}

重寫onLoginSuccess方法,登錄成功後,從request的attribute裏面拿出onAccessDenied中生成的token包裝後返回給櫃機端:

@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, 
			ServletRequest request, ServletResponse response) throws Exception {
    String userToken = (String) request.getAttribute("___USER_TOKEN___");

    if (!StringUtils.isEmpty()) {
        //buildResp是一個自定義的私有方法,作用是包裝錯誤碼、返回值、返回msg成統一返回格式,不再贅述
        byte[] result = buildResp(0, userToken, "success");
        WebTools.outPrint(response, result);
    }

    return true;
}

重寫onLoginFailure方法,登錄失敗後,返回失敗信息:

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
                                 ServletRequest request, ServletResponse response) {
    byte[] result = buildResp(2, null, "auth failure");
    //自定義的util,作用是用流將返回值寫到頁面,不在贅述
    WebTools.outPrint(response, result);
    //登錄失敗
    return false;
}

到這裏,過濾器部分的代碼就寫完了。

其實Shiro爲我們準備了一個已經寫好的Filter:FormAuthenticationFilter。這個Filter可以完成常規的賬號密碼登錄的操作和身份攔截,只需要在配置文件裏將url配置到這個過濾器,這樣就不要重寫Filter的代碼了。

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="/token" />
    <property name="filterChainDefinitions">
        <value>
            <!-- 所有請求用FormAuthenticationFilter攔截 -->
            /** = authc
        </value>
    </property>
</bean>

讀者也可以自行跟調Shiro的代碼,本文詳細展開的過濾器代碼可從AdviceFilter的doFilterInternal方法跟起。

6、自定義SubjectDAO

這部分涉及Subject的創建和保存,主要介紹思路。

我們寫一個RedisSubjectDAOImpl繼承SubjectDAO, SubjectFactory ,重寫createSubject、save、delete三個方法

public class RedisSubjectDAOImpl implements SubjectDAO, SubjectFactory {
    @Autowired
    private RedisService redisService;

    @Override
    //該方法用於創建subject:獲取header裏面的token和redis裏面的token比對,如果正確
    //返回一個已經驗證的Subject。如果錯誤,則判斷是否通過驗證,如果驗證通過,則返回一個
    //已經驗證的Subject,否則返回一個未驗證的Subject
  public Subject createSubject(SubjectContext context) {
      //...
  }

    //該方法用於保存登錄狀態:原生方法是將狀態保存到session,我們將從request的attribute
    //裏面獲取token和過期時間,並將此token保存到redis
    @Override
  public Subject save(Subject subject) {
      //...
  }

    //該方法用於刪除登錄狀態:將redis裏面的token刪掉
    @Override
  public void delete(Subject subject) {
      //...
  }
}

然後將此RedisSubjectDAOImpl配到配置文件裏

<bean id="redisSubjectDAO" class="com.shiro.demo.security.RedisSubjectDAOImpl">
</bean>

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="myRealm" />
    <property name="subjectDAO" ref="redisSubjectDAO" />
    <property name="subjectFactory" ref="redisSubjectDAO" />
</bean>

7、結語

寫到這就全部講完了。如果不想用token作爲身份憑證、redis作爲身份保存,可以有關token的代碼和第6部分的重寫,直接用request自帶的sessionID作爲身份憑證,Shiro原生的session作身份保存,而這些都是不要重寫的。

現在回顧開篇說的:

你寫的controller接口是目的地,SecurityManager就是安全經理,Filter是目的地的門衛,每個人都帶有一個表示身份的票Subject。如果你的票有效,就可以在有效期內多次進門。如果無效,你就會被門衛攔住,然後被指引去經理那憑賬號和密碼換取一個新的有效票然後進門。經理會查Realm庫檢查你的賬號密碼是否正確來決定是否給你換票。

是不是更加明白了一些?


三、Shiro的過濾器鏈

因爲時間有限,這部分參考張開濤和另一位大神的博文:

攔截器機制——《跟我學Shiro》

安全認證框架Shiro (二)- shiro過濾器工作原理

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