本文涉及的源碼地址:https://github.com/davidfantasy/shrio-with-jwt-spring-boot-starter
背景說明
用戶權(quán)限管理是每個(gè)信息系統(tǒng)最基本的需求枫慷,對基于Java的項(xiàng)目來說渊迁,最常用的權(quán)限管理框架就是大名鼎鼎的Apache Shiro。Apache Shiro功能非常強(qiáng)大亚皂,使用廣泛,幾乎成為了權(quán)限管理的代名詞甥雕。但對于普通項(xiàng)目來說剩晴,Shiro的設(shè)計(jì)理念因?yàn)樽非箪`活性,一些概念如Realm几迄,Subject的抽象級別都比較高,顯得比較復(fù)雜冰评。如果沒有對框架細(xì)節(jié)進(jìn)行深入了解的話映胁,很難理解其中的準(zhǔn)確含義。要將其應(yīng)用于實(shí)際項(xiàng)目甲雅,還需要針對項(xiàng)目的實(shí)際情況做大量的配置和改造解孙,時(shí)間成本較高。
而且Shiro興起的時(shí)代主流應(yīng)用還是傳統(tǒng)的基于Session的Web網(wǎng)站抛人,并沒有過多的考慮目前流行的微服務(wù)等應(yīng)用形式的權(quán)限管理需求弛姜。導(dǎo)致其并沒有提供一套無狀態(tài)微服務(wù)的開箱即用的整合方案。需要在項(xiàng)目層面對Shiro進(jìn)行二次封裝和改進(jìn)妖枚,開發(fā)難度較大廷臼。
我負(fù)責(zé)的幾個(gè)項(xiàng)目都使用了Shiro作為權(quán)限管理框架,感嘆其強(qiáng)大功能的也為每次都需要進(jìn)行二次開發(fā)和封裝感到厭煩了,于是在對Shiro的結(jié)構(gòu)有比較深入的了解之后荠商,決定在Shrio的基礎(chǔ)上寂恬,對一些常用的開發(fā)場景進(jìn)行封裝和整合,提高開發(fā)效率结啼,降低配置難度掠剑,開發(fā)一套基于Spring Boot環(huán)境,適合于各類無狀態(tài)微服務(wù)應(yīng)用的郊愧,開箱即用的輕量級權(quán)限框架。
使用Aceess Token替換Session
所謂的無狀態(tài)井佑,其實(shí)是把原來由后端服務(wù)負(fù)責(zé)維護(hù)的属铁,基于Http Session的用戶會話信息交由客戶端(如果是普通的web應(yīng)用,客戶端即是用戶的瀏覽器)進(jìn)行維護(hù)躬翁,這樣后端服務(wù)的單元測試焦蘑,負(fù)載均衡,橫向擴(kuò)容都要方便很多盒发。
但是用戶會話信息關(guān)乎數(shù)據(jù)安全例嘱,放到客戶端如何確保安全呢?常見的做法是由服務(wù)端根據(jù)客戶端首次提交的認(rèn)證信息簽發(fā)一個(gè)accessToken宁舰,這個(gè)accessToken就相當(dāng)于客戶端的身份證拼卵,以后每次交互的時(shí)候客戶端只需要出示這個(gè)憑證,服務(wù)端就能夠識別當(dāng)前客戶端的身份蛮艰。
實(shí)現(xiàn)accessToken的方式有很多腋腮,理論上只要確保一個(gè)accessToken無法被第三方解碼,能唯一標(biāo)識一個(gè)客戶端壤蚜,服務(wù)端能夠解析出token的創(chuàng)建時(shí)間即寡,客戶端標(biāo)識等內(nèi)容就行了。但是自行設(shè)計(jì)的實(shí)現(xiàn)方法難免存在各種安全隱患袜刷,accessToken是要由客戶端進(jìn)行維護(hù)的聪富,我們無法確保客戶端都一定運(yùn)行在完全安全的環(huán)境中著蟹。幸運(yùn)的是現(xiàn)在有一種專門為此目的而設(shè)計(jì)的開放標(biāo)準(zhǔn)JWT(JSON Web token),它基于http交互中常見的數(shù)據(jù)格式JSON墩蔓,提供緊湊而安全的Token生成處理機(jī)制。JWT的詳細(xì)內(nèi)容這里就不多介紹了草则,有興趣可以自行查閱相關(guān)資料钢拧。
認(rèn)證和授權(quán)
Shiro默認(rèn)提供的實(shí)現(xiàn)是基于用戶Session的權(quán)限驗(yàn)證模型,如何讓其支持基于accessToken的無狀態(tài)形式呢炕横?Shiro功能的核心其實(shí)主要包含兩部分內(nèi)容:認(rèn)證(Authentication)和授權(quán)(Authorization)源内。認(rèn)證就是核實(shí)用戶身份的過程,比如檢查客戶端提供的用戶名和密碼是否是合法的系統(tǒng)用戶。而授權(quán)的含義則是檢查該用戶是否能夠訪問具體的某個(gè)資源膜钓,也就是訪問控制嗽交。所以我們需要做的就是擴(kuò)展Shiro對于認(rèn)證和授權(quán)的默認(rèn)實(shí)現(xiàn),使其能夠支持accessToken的形式颂斜。
我們先來看一下Shiro實(shí)現(xiàn)認(rèn)證的流程:
從整個(gè)流程圖上可以看出夫壁,最終實(shí)現(xiàn)認(rèn)證邏輯的組件是所謂的Realm,Shiro默認(rèn)實(shí)現(xiàn)了很多不同的Realm沃疮,可以從數(shù)據(jù)庫盒让,LADP等各個(gè)地方加載用戶的認(rèn)證信息。
授權(quán)過程和認(rèn)證過程差不多司蔬,核心也在于Realm的實(shí)現(xiàn):
所以邑茄,第一步要做的應(yīng)該是先實(shí)現(xiàn)一個(gè)自定義的JWTShiroRealm,采用accessToken的方式來實(shí)現(xiàn)系統(tǒng)的認(rèn)證和授權(quán)俊啼。
public class JWTShiroRealm extends AuthorizingRealm {
/**
* 可供擴(kuò)展的權(quán)限加載器肺缕,由應(yīng)用程序負(fù)責(zé)實(shí)現(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;
}
/**
* 用于獲取用戶權(quán)限(role,permissions),只有當(dāng)需要檢測用戶權(quán)限的時(shí)候才會調(diào)用此方法,例如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;
}
/**
* 調(diào)用subject.login時(shí)觸發(fā)此方法授帕,用于驗(yàn)證token的正確性
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 從token中獲取用戶的唯一標(biāo)識
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());
//這里實(shí)際上會將AuthenticationToken.getCredentials()與傳入的第二個(gè)參數(shù)credentials進(jìn)行比較
//第一個(gè)參數(shù)是登錄成功后,可以通過subject.getPrincipal獲取
return new SimpleAuthenticationInfo(principal, token, this.getName());
}
Realm核心的方法在于doGetAuthorizationInfo和doGetAuthenticationInfo跛十,對應(yīng)之前說的授權(quán)和認(rèn)證過程彤路。這里需要注意的地方是,解析了accessToken之后應(yīng)該如何獲得用戶的認(rèn)證和權(quán)限等信息呢偶器?我的想法是斩萌,認(rèn)證方式和獲取用戶權(quán)限每個(gè)應(yīng)用系統(tǒng)都可能有不同的需求,沒辦法強(qiáng)行統(tǒng)一起來屏轰,所以這里應(yīng)該預(yù)留一個(gè)擴(kuò)展點(diǎn)颊郎。JWTShiroRealm只負(fù)責(zé)對accessToken的有效性進(jìn)行認(rèn)證,而把該Token是否對應(yīng)一個(gè)合法的用戶以及用戶的具體權(quán)限委派給具體的應(yīng)用去處理和實(shí)現(xiàn)霎苗。 而JWTUserAuthService就是這樣一個(gè)擴(kuò)展點(diǎn)姆吭,它被定義成一個(gè)接口,負(fù)責(zé)根據(jù)accessToken中定義的唯一標(biāo)示(一般就是用戶賬號)判斷用戶是否合法唁盏,以及通過用戶的唯一標(biāo)示加載該用戶的實(shí)際權(quán)限内狸,另外還可以自定義驗(yàn)證失敗時(shí)的錯誤返回。每個(gè)應(yīng)用程序需要根據(jù)業(yè)務(wù)特點(diǎn)實(shí)現(xiàn)自己的邏輯厘擂。
public interface JWTUserAuthService {
/**
* 根據(jù)用戶的唯一標(biāo)示對用戶進(jìn)行認(rèn)證昆淡,并獲取用戶的權(quán)限等信息
* 如果account對應(yīng)的用戶信息不存在,應(yīng)返回null
* @param account 用戶的唯一標(biāo)示
* @return 該用戶所擁有的權(quán)限信息
*/
UserInfo getUserInfo(String account);
/**
* 自定義訪問資源認(rèn)證失敗時(shí)的處理方式刽严,例如返回json格式的錯誤信息
* {\"code\":401,\"message\":\"用戶認(rèn)證失敯毫椤!\")
*/
void onAuthenticationFailed(HttpServletRequest req, HttpServletResponse res);
/**
* 自定義訪問資源權(quán)限不足時(shí)的處理方式,例如返回json格式的錯誤信息
* {\"code\":403,\"message\":\"permission denied眨补!\")
*/
void onAuthorizationFailed(HttpServletRequest req, HttpServletResponse res);
}
其中UserInfo類封裝了認(rèn)證用戶所擁有的權(quán)限信息
public class UserInfo {
/**
* 用戶的唯一標(biāo)識
*/
private String account;
/**
* accessToken的密鑰管削,用于對accessToken進(jìn)行加密和解密
* 建議為每個(gè)用戶配置不同的密鑰(比如使用用戶的password)
*/
private String secret;
/**
* 用戶權(quán)限集合,含義類似于Shiro中的perms
*/
private Set<String> permissions;
}
通過將認(rèn)證和授權(quán)邏輯與accessToken的處理進(jìn)行分離撑螺,應(yīng)用程序就可以僅僅關(guān)注于具體的權(quán)限管理模型的實(shí)現(xiàn)含思,而無需操心accessToken的相關(guān)問題了。這里有一個(gè)地方與常見的權(quán)限模型有一些差異甘晤。通常的系統(tǒng)一般采用基于角色的訪問控制模型(RBAC)含潘,主要由三個(gè)主體構(gòu)成:用戶(User) — 角色(Role)— 權(quán)限(Permission)。但我在這里省略掉了Role這樣一個(gè)主體安皱,用戶的授權(quán)信息中直接包含了該用戶的權(quán)限(Permission)调鬓,并沒有Role的相關(guān)信息。這樣設(shè)計(jì)最大的好處就是簡單酌伊,一個(gè)鏈接所對應(yīng)的權(quán)限僅僅只有Permission。而不像Shiro原本那樣缀踪,一個(gè)鏈接的訪問權(quán)限既可以使用Role又可以使用Permission來控制居砖,如果使用不當(dāng)反而會出現(xiàn)安全漏洞。但如果系統(tǒng)中要求使用角色來控制權(quán)限怎么辦呢驴娃?其實(shí)在UserInfo中省略掉Role并不意味著不能有Role的存在奏候,應(yīng)用程序在實(shí)現(xiàn)權(quán)限模型的時(shí)候可以完全按照自身的需求,只是在最終返回UserInfo的時(shí)候需要將Role轉(zhuǎn)換成Permission唇敞。比如通過account去查詢用戶的角色蔗草,再將返回該用戶所有角色所具備的權(quán)限就行了。這樣的實(shí)現(xiàn)其實(shí)比Role更加的靈活疆柔,比如某些系統(tǒng)的用戶的權(quán)限不是由角色決定的咒精,而是用戶所在的部門決定的,那只需要在實(shí)現(xiàn)getUserInfo方法的時(shí)候旷档,返回用戶所在部門的權(quán)限就好了模叙。
權(quán)限過濾器
說完最核心的認(rèn)證和授權(quán)過程,我們再來看一看Shiro框架的Filter機(jī)制鞋屈。Realm中的認(rèn)證和授權(quán)過程最終就是在各個(gè)Filter中觸發(fā)的范咨。這里的Filter并不是Java Servlet規(guī)范中定義的Filter,而是Shrio內(nèi)置的用于控制資源訪問的不同規(guī)則厂庇。Shiro內(nèi)置了很多Filter的實(shí)現(xiàn)渠啊,但最常用的有4種:
- anon: 匿名訪問過濾器,添加了此過濾器的資源無需任何驗(yàn)證即可訪問权旷。例如:/login/**=anon
- authc:認(rèn)證過濾器替蛉,通過調(diào)用subject.isAuthenticated來判斷當(dāng)前用戶是否被認(rèn)證過,資源需要通過認(rèn)證(登錄)才能使用。例如:/api/**=authc
- roles: 角色過濾器灭返,通過調(diào)用subject.hasRole來判斷當(dāng)前用戶是否擁有指定的角色盗迟。例如:/admins/**=roles["admin"]
- perms: 權(quán)限過濾器, 通過調(diào)用subject.isPermitted來判斷當(dāng)前用戶是否擁有指定的權(quán)限,例如:/api/data/add = perms['data:modify']
roles過濾器因?yàn)榍笆龅脑蛭覀冞@里不會涉及熙含。anon罚缕,perms過濾器基本可以沿用Shiro的默認(rèn)實(shí)現(xiàn),但authc過濾器默認(rèn)是從用戶會員會話中去獲取用戶的認(rèn)證狀態(tài)的怎静,所以我們需要對其進(jìn)行一定的改造邮弹。以下是傳統(tǒng)認(rèn)證方式和無狀態(tài)認(rèn)證方式的流程對比:
重新實(shí)現(xiàn)authc過濾器的邏輯,需要繼承org.apache.shiro.web.filter.AccessControlFilter蚓聘,這個(gè)是Shiro用于資源訪問控制最基礎(chǔ)的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("認(rèn)證失敗:" + 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;
}
資源和權(quán)限的映射
Shiro 最常用的兩種將資源和權(quán)限進(jìn)行關(guān)聯(lián)的方式:
- 通過外部配置夜牡,將url和權(quán)限綁定与纽。例如:
<property name="filterChainDefinitions">
<value>
/static/** = anon
/api/** = authc
/api/user = perms["user"]
</value>
</property>
- 基于annotation將類或方法的執(zhí)行與用戶權(quán)限進(jìn)行綁定,例如:
//執(zhí)行這個(gè)方法塘装,需要當(dāng)前用戶具有user:modify權(quán)限
@RequiresPermissions("user:modify")
public void modifyUserInfo(){
}
這兩種方式都有一定的缺點(diǎn)急迂。第一種將配置放到獨(dú)立的配置文件中,與代碼分離了蹦肴。而系統(tǒng)暴露的服務(wù)地址隨時(shí)都有可能發(fā)生變更僚碎,一旦代碼與配置沒有及時(shí)同步更新,就會出現(xiàn)安全隱患阴幌;第二種基于Annotation的配置能很好的解決這個(gè)問題勺阐,但是Shiro原生的注解是基于AOP的, 必須要求被保護(hù)的類啟用動態(tài)代理。而且每個(gè)需要被保護(hù)的類或者方法都需要添加對應(yīng)的注解矛双,無法像配置url那樣使用模式匹配渊抽。
我在實(shí)現(xiàn)上將兩種方式綜合了一下,使用基于URL的注解方式來盡可能避免上述的缺陷背零。我定義了兩個(gè)新的注解AlowAnonymous和RequiresPerms腰吟,和Shiro原生注解的區(qū)別是這兩個(gè)注解必須要與Spirng的RequestMapping(包括GetMapping,PostMapping等)注解結(jié)合進(jìn)行使用。無需動態(tài)代理徙瓶,框架會通過獲取RequestMapping定義的url毛雇,將其自動與RequiresPerms標(biāo)注的權(quán)限字段進(jìn)行綁定,這也意味著這兩個(gè)注解只允許在Controller中進(jìn)行使用侦镇。
@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 ]
#默認(rèn)的shiro配置并不支持配置OR的比較操作符灵疮,這里的anyPerms是自定義過濾器
/api/user/modify-logs= anyPerms[ system,user:logs ]
為了進(jìn)一步減少一些無謂配置,框架默認(rèn)所有被攔截的資源必須是要經(jīng)過認(rèn)證的用戶才可以被訪問壳繁。即如果配置的攔截范圍是/api/,則會添加一條默認(rèn)的驗(yàn)證規(guī)則: /api/=authc震捣。但任何通過注解添加的驗(yàn)證規(guī)則都擁有比默認(rèn)規(guī)則更高的優(yōu)先級荔棉。
accessToken的自動刷新
accessToken是客戶端用于訪問授權(quán)資源的重要憑證,accessToken本身是由客戶端進(jìn)行維護(hù)的蒿赢,存在泄漏或者被截取的危險(xiǎn)润樱。為了最大程度的保證安全,accessToken本身必須包含一個(gè)合理的有效期限羡棵。過期之后壹若,必須重新進(jìn)行客戶端的認(rèn)證過程,獲取新的token皂冰。但這里存在一個(gè)問題店展,客戶端可能無法獲取到Token的實(shí)際超時(shí)時(shí)間(或者由于時(shí)鐘同步的原因不能精確的判定),如果等到服務(wù)端返回token失效的信息后再重新請求認(rèn)證秃流,必然會導(dǎo)致當(dāng)前處理流程的中斷赂蕴,如果是面向用戶的web系統(tǒng),則意味著用戶的操作被強(qiáng)制中斷需要重新進(jìn)行登錄舶胀。這樣的用戶體驗(yàn)顯然是不好的概说。考慮到傳統(tǒng)的基于Session的web應(yīng)用嚣伐,用戶的每次后臺操作都會刷新Session的過期時(shí)間席怪,只要用戶持續(xù)的操作Session就不會過期,我在本框架中也引入了類似的Token刷新機(jī)制纤控,大概流程圖如下:
如果開啟token的自動刷新,框架會自動注冊一個(gè)Spring HandlerInterceptor來攔截所有被保護(hù)的接口碉纺。在檢測到token即將過期船万,但還沒有超過最大生命周期時(shí),就會自動刷新token并在響應(yīng)的header中加入該token骨田。這樣客戶端可以通過每次檢查請求的響應(yīng)頭耿导,如果發(fā)現(xiàn)攜帶了新的token,就自動更新自身存儲的token态贤。這樣只要在token的生命周期內(nèi)不斷有新的請求舱呻,則token就會不斷的刷新。