本文涉及的源碼地址:https://github.com/davidfantasy/shrio-with-jwt-spring-boot-starter
背景說明
用戶權限管理是每個信息系統(tǒng)最基本的需求,對基于Java的項目來說,最常用的權限管理框架就是大名鼎鼎的Apache Shiro。Apache Shiro功能非常強大,使用廣泛,幾乎成為了權限管理的代名詞。但對于普通項目來說,Shiro的設計理念因為追求靈活性,一些概念如Realm,Subject的抽象級別都比較高,顯得比較復雜。如果沒有對框架細節(jié)進行深入了解的話,很難理解其中的準確含義。要將其應用于實際項目,還需要針對項目的實際情況做大量的配置和改造,時間成本較高。
而且Shiro興起的時代主流應用還是傳統(tǒng)的基于Session的Web網(wǎng)站,并沒有過多的考慮目前流行的微服務等應用形式的權限管理需求。導致其并沒有提供一套無狀態(tài)微服務的開箱即用的整合方案。需要在項目層面對Shiro進行二次封裝和改進,開發(fā)難度較大。
我負責的幾個項目都使用了Shiro作為權限管理框架,感嘆其強大功能的也為每次都需要進行二次開發(fā)和封裝感到厭煩了,于是在對Shiro的結構有比較深入的了解之后,決定在Shrio的基礎上,對一些常用的開發(fā)場景進行封裝和整合,提高開發(fā)效率,降低配置難度,開發(fā)一套基于Spring Boot環(huán)境,適合于各類無狀態(tài)微服務應用的,開箱即用的輕量級權限框架。
使用Aceess Token替換Session
所謂的無狀態(tài),其實是把原來由后端服務負責維護的,基于Http Session的用戶會話信息交由客戶端(如果是普通的web應用,客戶端即是用戶的瀏覽器)進行維護,這樣后端服務的單元測試,負載均衡,橫向擴容都要方便很多。
但是用戶會話信息關乎數(shù)據(jù)安全,放到客戶端如何確保安全呢?常見的做法是由服務端根據(jù)客戶端首次提交的認證信息簽發(fā)一個accessToken,這個accessToken就相當于客戶端的身份證,以后每次交互的時候客戶端只需要出示這個憑證,服務端就能夠識別當前客戶端的身份。
實現(xiàn)accessToken的方式有很多,理論上只要確保一個accessToken無法被第三方解碼,能唯一標識一個客戶端,服務端能夠解析出token的創(chuàng)建時間,客戶端標識等內容就行了。但是自行設計的實現(xiàn)方法難免存在各種安全隱患,accessToken是要由客戶端進行維護的,我們無法確保客戶端都一定運行在完全安全的環(huán)境中。幸運的是現(xiàn)在有一種專門為此目的而設計的開放標準JWT(JSON Web token),它基于http交互中常見的數(shù)據(jù)格式JSON,提供緊湊而安全的Token生成處理機制。JWT的詳細內容這里就不多介紹了,有興趣可以自行查閱相關資料。
認證和授權
Shiro默認提供的實現(xiàn)是基于用戶Session的權限驗證模型,如何讓其支持基于accessToken的無狀態(tài)形式呢?Shiro功能的核心其實主要包含兩部分內容:認證(Authentication)和授權(Authorization)。認證就是核實用戶身份的過程,比如檢查客戶端提供的用戶名和密碼是否是合法的系統(tǒng)用戶。而授權的含義則是檢查該用戶是否能夠訪問具體的某個資源,也就是訪問控制。所以我們需要做的就是擴展Shiro對于認證和授權的默認實現(xiàn),使其能夠支持accessToken的形式。
我們先來看一下Shiro實現(xiàn)認證的流程:
從整個流程圖上可以看出,最終實現(xiàn)認證邏輯的組件是所謂的Realm,Shiro默認實現(xiàn)了很多不同的Realm,可以從數(shù)據(jù)庫,LADP等各個地方加載用戶的認證信息。
授權過程和認證過程差不多,核心也在于Realm的實現(xiàn):
所以,第一步要做的應該是先實現(xiàn)一個自定義的JWTShiroRealm,采用accessToken的方式來實現(xiàn)系統(tǒng)的認證和授權。
public class JWTShiroRealm extends AuthorizingRealm {
/**
* 可供擴展的權限加載器,由應用程序負責實現(xiàn)
*/
private JWTUserAuthService userAuthService;
private JWTHelper jwtHelper;
public JWTShiroRealm(JWTUserAuthService userAuthService, JWTHelper jwtHelper) {
this.jwtHelper = jwtHelper;
this.userAuthService = userAuthService;
}
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 用于獲取用戶權限(role,permissions),只有當需要檢測用戶權限的時候才會調用此方法,例如checkRole,checkPermission之類的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
JWTPrincipal principal = (JWTPrincipal) principals.getPrimaryPrincipal();
SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo();
UserInfo up = userAuthService.getUserInfo(principal.getAccount());
if (up != null && up.getPermissions() != null) {
authInfo.addStringPermissions(up.getPermissions());
}
return authInfo;
}
/**
* 調用subject.login時觸發(fā)此方法,用于驗證token的正確性
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 從token中獲取用戶的唯一標識
String account = jwtHelper.getAccount(token);
if (account== null) {
throw new AuthenticationException("無效的請求");
}
UserInfo user = userAuthService.getUserInfo(account);
if (user == null) {
throw new AuthenticationException("未找到用戶信息");
}
DecodedJWT jwt = jwtHelper.verify(token, account, user.getSecret());
if (jwt == null) {
throw new AuthenticationException("token已經(jīng)過期,請重新登錄");
}
JWTPrincipal principal = new JWTPrincipal();
principal.setAccount(user.getAccount());
principal.setExpiresAt(jwt.getExpiresAt().getTime());
//這里實際上會將AuthenticationToken.getCredentials()與傳入的第二個參數(shù)credentials進行比較
//第一個參數(shù)是登錄成功后,可以通過subject.getPrincipal獲取
return new SimpleAuthenticationInfo(principal, token, this.getName());
}
Realm核心的方法在于doGetAuthorizationInfo和doGetAuthenticationInfo,對應之前說的授權和認證過程。這里需要注意的地方是,解析了accessToken之后應該如何獲得用戶的認證和權限等信息呢?我的想法是,認證方式和獲取用戶權限每個應用系統(tǒng)都可能有不同的需求,沒辦法強行統(tǒng)一起來,所以這里應該預留一個擴展點。JWTShiroRealm只負責對accessToken的有效性進行認證,而把該Token是否對應一個合法的用戶以及用戶的具體權限委派給具體的應用去處理和實現(xiàn)。 而JWTUserAuthService就是這樣一個擴展點,它被定義成一個接口,負責根據(jù)accessToken中定義的唯一標示(一般就是用戶賬號)判斷用戶是否合法,以及通過用戶的唯一標示加載該用戶的實際權限,另外還可以自定義驗證失敗時的錯誤返回。每個應用程序需要根據(jù)業(yè)務特點實現(xiàn)自己的邏輯。
public interface JWTUserAuthService {
/**
* 根據(jù)用戶的唯一標示對用戶進行認證,并獲取用戶的權限等信息
* 如果account對應的用戶信息不存在,應返回null
* @param account 用戶的唯一標示
* @return 該用戶所擁有的權限信息
*/
UserInfo getUserInfo(String account);
/**
* 自定義訪問資源認證失敗時的處理方式,例如返回json格式的錯誤信息
* {\"code\":401,\"message\":\"用戶認證失敗!\")
*/
void onAuthenticationFailed(HttpServletRequest req, HttpServletResponse res);
/**
* 自定義訪問資源權限不足時的處理方式,例如返回json格式的錯誤信息
* {\"code\":403,\"message\":\"permission denied!\")
*/
void onAuthorizationFailed(HttpServletRequest req, HttpServletResponse res);
}
其中UserInfo類封裝了認證用戶所擁有的權限信息
public class UserInfo {
/**
* 用戶的唯一標識
*/
private String account;
/**
* accessToken的密鑰,用于對accessToken進行加密和解密
* 建議為每個用戶配置不同的密鑰(比如使用用戶的password)
*/
private String secret;
/**
* 用戶權限集合,含義類似于Shiro中的perms
*/
private Set<String> permissions;
}
通過將認證和授權邏輯與accessToken的處理進行分離,應用程序就可以僅僅關注于具體的權限管理模型的實現(xiàn),而無需操心accessToken的相關問題了。這里有一個地方與常見的權限模型有一些差異。通常的系統(tǒng)一般采用基于角色的訪問控制模型(RBAC),主要由三個主體構成:用戶(User) — 角色(Role)— 權限(Permission)。但我在這里省略掉了Role這樣一個主體,用戶的授權信息中直接包含了該用戶的權限(Permission),并沒有Role的相關信息。這樣設計最大的好處就是簡單,一個鏈接所對應的權限僅僅只有Permission。而不像Shiro原本那樣,一個鏈接的訪問權限既可以使用Role又可以使用Permission來控制,如果使用不當反而會出現(xiàn)安全漏洞。但如果系統(tǒng)中要求使用角色來控制權限怎么辦呢?其實在UserInfo中省略掉Role并不意味著不能有Role的存在,應用程序在實現(xiàn)權限模型的時候可以完全按照自身的需求,只是在最終返回UserInfo的時候需要將Role轉換成Permission。比如通過account去查詢用戶的角色,再將返回該用戶所有角色所具備的權限就行了。這樣的實現(xiàn)其實比Role更加的靈活,比如某些系統(tǒng)的用戶的權限不是由角色決定的,而是用戶所在的部門決定的,那只需要在實現(xiàn)getUserInfo方法的時候,返回用戶所在部門的權限就好了。
權限過濾器
說完最核心的認證和授權過程,我們再來看一看Shiro框架的Filter機制。Realm中的認證和授權過程最終就是在各個Filter中觸發(fā)的。這里的Filter并不是Java Servlet規(guī)范中定義的Filter,而是Shrio內置的用于控制資源訪問的不同規(guī)則。Shiro內置了很多Filter的實現(xiàn),但最常用的有4種:
- anon: 匿名訪問過濾器,添加了此過濾器的資源無需任何驗證即可訪問。例如:/login/**=anon
- authc:認證過濾器,通過調用subject.isAuthenticated來判斷當前用戶是否被認證過,資源需要通過認證(登錄)才能使用。例如:/api/**=authc
- roles: 角色過濾器,通過調用subject.hasRole來判斷當前用戶是否擁有指定的角色。例如:/admins/**=roles["admin"]
- perms: 權限過濾器, 通過調用subject.isPermitted來判斷當前用戶是否擁有指定的權限,例如:/api/data/add = perms['data:modify']
roles過濾器因為前述的原因我們這里不會涉及。anon,perms過濾器基本可以沿用Shiro的默認實現(xiàn),但authc過濾器默認是從用戶會員會話中去獲取用戶的認證狀態(tài)的,所以我們需要對其進行一定的改造。以下是傳統(tǒng)認證方式和無狀態(tài)認證方式的流程對比:
重新實現(xiàn)authc過濾器的邏輯,需要繼承org.apache.shiro.web.filter.AccessControlFilter,這個是Shiro用于資源訪問控制最基礎的filter。核心是要重寫isAccessAllowed和onAccessDenied方法,大致代碼如下:
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
//從header或URL參數(shù)中查找token
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader(headerKeyOfToken);
if (Strings.isNullOrEmpty(authorization)) {
authorization = req.getParameter(headerKeyOfToken);
}
JWTToken token = new JWTToken(authorization);
try {
getSubject(request, response).login(token);
} catch (Exception e) {
logger.error("認證失敗:" + e.getMessage());
return false;
}
return true;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
this.userAuthService.onAuthenticationFailed((HttpServletRequest) request, (HttpServletResponse) response);
return false;
}
資源和權限的映射
Shiro 最常用的兩種將資源和權限進行關聯(lián)的方式:
- 通過外部配置,將url和權限綁定。例如:
<property name="filterChainDefinitions">
<value>
/static/** = anon
/api/** = authc
/api/user = perms["user"]
</value>
</property>
- 基于annotation將類或方法的執(zhí)行與用戶權限進行綁定,例如:
//執(zhí)行這個方法,需要當前用戶具有user:modify權限
@RequiresPermissions("user:modify")
public void modifyUserInfo(){
}
這兩種方式都有一定的缺點。第一種將配置放到獨立的配置文件中,與代碼分離了。而系統(tǒng)暴露的服務地址隨時都有可能發(fā)生變更,一旦代碼與配置沒有及時同步更新,就會出現(xiàn)安全隱患;第二種基于Annotation的配置能很好的解決這個問題,但是Shiro原生的注解是基于AOP的, 必須要求被保護的類啟用動態(tài)代理。而且每個需要被保護的類或者方法都需要添加對應的注解,無法像配置url那樣使用模式匹配。
我在實現(xiàn)上將兩種方式綜合了一下,使用基于URL的注解方式來盡可能避免上述的缺陷。我定義了兩個新的注解AlowAnonymous和RequiresPerms,和Shiro原生注解的區(qū)別是這兩個注解必須要與Spirng的RequestMapping(包括GetMapping,PostMapping等)注解結合進行使用。無需動態(tài)代理,框架會通過獲取RequestMapping定義的url,將其自動與RequiresPerms標注的權限字段進行綁定,這也意味著這兩個注解只允許在Controller中進行使用。
@RestController
@RequestMapping("/api/user")
@RequiresPerms("user:basic")
public class UserController {
@AlowAnonymous
@PostMapping("/login")
public String login() {
return "ok";
}
@GetMapping("/detail")
public String getUserDetail() {
return "ok";
}
@PostMapping("/modify")
@RequiresPerms("user:modify")
public String modifyUser() {
return "ok";
}
@PostMapping("/delete")
@RequiresPerms({"system","user:delete"})
public String deleteUser() {
return "ok";
}
@PostMapping("/modify-logs")
@RequiresPerms(value={"system","user:logs"}, logical = Logical.OR)
public String deleteUser() {
return "ok";
}
}
例如上面的代碼等同于如下的Shiro配置:
/api/user/login = anon
/api/user/detail = perms[ user:basic ]
/api/user/modify = perms[ user:modify ]
/api/user/delete = perms[ system,user:delete ]
#默認的shiro配置并不支持配置OR的比較操作符,這里的anyPerms是自定義過濾器
/api/user/modify-logs= anyPerms[ system,user:logs ]
為了進一步減少一些無謂配置,框架默認所有被攔截的資源必須是要經(jīng)過認證的用戶才可以被訪問。即如果配置的攔截范圍是/api/,則會添加一條默認的驗證規(guī)則: /api/=authc。但任何通過注解添加的驗證規(guī)則都擁有比默認規(guī)則更高的優(yōu)先級。
accessToken的自動刷新
accessToken是客戶端用于訪問授權資源的重要憑證,accessToken本身是由客戶端進行維護的,存在泄漏或者被截取的危險。為了最大程度的保證安全,accessToken本身必須包含一個合理的有效期限。過期之后,必須重新進行客戶端的認證過程,獲取新的token。但這里存在一個問題,客戶端可能無法獲取到Token的實際超時時間(或者由于時鐘同步的原因不能精確的判定),如果等到服務端返回token失效的信息后再重新請求認證,必然會導致當前處理流程的中斷,如果是面向用戶的web系統(tǒng),則意味著用戶的操作被強制中斷需要重新進行登錄。這樣的用戶體驗顯然是不好的。考慮到傳統(tǒng)的基于Session的web應用,用戶的每次后臺操作都會刷新Session的過期時間,只要用戶持續(xù)的操作Session就不會過期,我在本框架中也引入了類似的Token刷新機制,大概流程圖如下:
如果開啟token的自動刷新,框架會自動注冊一個Spring HandlerInterceptor來攔截所有被保護的接口。在檢測到token即將過期,但還沒有超過最大生命周期時,就會自動刷新token并在響應的header中加入該token。這樣客戶端可以通過每次檢查請求的響應頭,如果發(fā)現(xiàn)攜帶了新的token,就自動更新自身存儲的token。這樣只要在token的生命周期內不斷有新的請求,則token就會不斷的刷新。