前言
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的過濾器鏈
因爲時間有限,這部分參考張開濤和另一位大神的博文: