開源地址:https://gitee.com/tianyalei/zuulauth
在單體應用架構下,常見的用戶-角色-菜單權限控制模式,譬如shiro,就是在每個接口方法上加RequireRole,RequirePermission,當調用到該方法時,可以從配置的數據庫、緩存中來進行匹配,通過這種方式來進行的權限控制。
而在微服務架構下,我們會使用網關來作爲所有服務的入口,由網關來完成鑑權、分發、限流等功能。
也就是從前由各個單體服務完成的各自的權限驗證,現在全部交給zuul來統一管理,這樣能夠將權限控制到單點裏,便於統一管理,也能避免大量的非法請求、權限不足的請求落到後面的微服務裏,從而減少對網關後面的服務造成衝擊。
針對這種情況,很多方案是採用上圖的方式。具體的我也思考過,首先問題比較明顯:
1:zuul作爲集羣的入口,要承擔大量的請求,還要保證性能,如果每個請求都去和另一個服務做交互,必然會有性能損失,至少在網絡開銷上會不小。
2:AuthServer是否能夠完成精確的權限控制?大部分情況下,都是用戶-角色-菜單這種模型,關鍵在於菜單這塊,現實情況是很多接口並不是菜單,也不是按鈕,在界面上沒有任何體現,就是個接口而已。我想對接口的權限進行控制,譬如只允許某個角色的用戶才能訪問。倘若將全部接口都寫入菜單管理裏,明顯是不合適的,也很容易遺漏,工作量也很大。
比較理想的狀態還是shiro的那種寫法,譬如直接在controller或接口方法上加role、permission的註解,標註該接口的所需權限,然後在菜單管理裏添加一些重要的接口Permission權限,而不是全部的接口。然後呢,每個微服務都完成好自己的權限標註後,當有用戶請求時,就在網關層進行鑑別,由網關來控制是否放行。這樣,在每個微服務裏,就不需要做權限控制了。
這種該怎麼實現呢,單個微服務的權限信息如何告知網關,並且如何保持權限信息的同步?
我的實現方式如圖,首先各個微服務在啓動後,就上傳自己的所有權限信息到redis,zuul監聽redis的變化,及時將各微服務的接口權限變更信息更新到內存。然後auth這個微服務就是用戶、角色、菜單的控制檯,也將相應的信息更新到redis中,zuul也監聽用戶、角色、菜單的變更信息,存入內存。
當有用戶請求時,zuul就根據自己緩存的信息,對請求的接口地址進行匹配,判斷用戶角色、權限是否和各微服務裏映射的權限信息相符,然後決定是否放行。
這一套結構我已封裝爲一個框架,可以直接在pom裏添加依賴並使用,源碼在地址。
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.tianyaleixiaowu</groupId>
<artifactId>zuulauth</artifactId>
<version>13a6001c25</version>
</dependency>
微服務端
使用方法很簡單,添加好依賴,配置好redis的連接地址,然後在代碼裏啓用權限控制,加上@EnableClientAuth註解即可。當應用啓動後就會自動上傳所有的權限信息到redis裏。
authServer端
該端是負責用戶、角色、菜單的增刪改查的,並且要負責把這些信息放到redis裏。
第一步:添加依賴,配置redis地址
第二步:通過AuthCache類來完成信息的存儲和刪除
譬如當添加了role-menu的映射後,就用authCache來save一下。當刪除了role時,就remove掉即可。
zuul端
第一步:添加好依賴在pom.xml,配置redis連接地址
第二步:創建好一個zuulFilter,在裏面做權限控制。
在zuul裏,用戶發起請求後,譬如使用的是jwt或其他,我們需要先取到userId或者roleId。
然後調用AuthInfoHolder.findxxx方法,來獲取用戶的角色roleSet,codeSet(某個角色的權限集合),
之後調用AuthCheck的check方法,來確定用戶權限是否匹配。
check方法需要幾個參數,分別是微服務的名字,該請求的方法(get、post、put、delete),請求的地址(/menu/add),該用戶的角色(或角色集合,Set<String>),該用戶的權限集合(Set<String>).
由於獲取用戶角色和角色權限,都是基於內存獲取,倘若用戶在authServer端修改了某個role的權限,那麼在二次查詢前,事實上redis裏是沒有這個role的權限的,只有當調用了authServer的查詢該role的權限接口後,從redis獲取失敗,那麼就會走數據庫查詢獲取,並緩存到redis,然後zuul的內存才能知道。所以在89行,判斷讀取不到時,就調用authServer的接口來獲取。那麼之後,就已經緩存了。
實例代碼:
package com.mm.dmp.zuulnacos.filter;
import com.mm.dmp.zuulnacos.exception.NoLoginException;
import com.mm.dmp.zuulnacos.filter.feign.AuthFeignClient;
import com.mm.dmp.zuulnacos.tool.JwtUtils;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.tianyalei.zuul.zuulauth.tool.FastJsonUtils;
import com.tianyalei.zuul.zuulauth.zuul.AuthChecker;
import com.tianyalei.zuul.zuulauth.zuul.AuthInfoHolder;
import io.jsonwebtoken.Claims;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Set;
import static com.mm.dmp.zuulnacos.Constant.*;
import static com.tianyalei.zuul.zuulauth.zuul.AuthChecker.*;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
/**
* @author wuweifeng wrote on 2019/8/12.
*/
@Component
public class PermissionFilter extends ZuulFilter {
@Resource
private JwtUtils jwtUtils;
private Logger logger = LoggerFactory.getLogger(getClass());
@Resource
private RouteLocator routeLocator;
@Resource
private AuthChecker authChecker;
@Resource
private AuthFeignClient authFeignClient;
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return 2;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest serverHttpRequest = ctx.getRequest();
String jwtToken = serverHttpRequest.getHeader(AUTHORIZATION);
if (jwtToken == null) {
//沒有Authorization
throw new NoLoginException();
}
Claims claims = jwtUtils.getClaimByToken(jwtToken);
if (claims == null) {
throw new NoLoginException();
}
logger.info("token的過期時間是:" + (claims.getExpiration()));
if (jwtUtils.isTokenExpired(claims.getExpiration())) {
throw new NoLoginException();
}
//校驗role
String userId = claims.get(USER_ID) + "";
String roleId = claims.get(ROLE_ID) + "";
String userType = (String) claims.get(USER_TYPE);
//從自己內存讀取,可能爲空,說明redis裏沒有,就需要從auth服務讀取
Set<String> userCodes = AuthInfoHolder.findByRole(roleId);
if (CollectionUtils.isEmpty(userCodes)) {
String codes = authFeignClient.findCodesByRole(Long.valueOf(roleId));
userCodes = FastJsonUtils.toBean(codes, Set.class);
}
//類似於 /zuuldmp/core/test
String requestPath = serverHttpRequest.getRequestURI();
//獲取請求的method
String method = serverHttpRequest.getMethod().toUpperCase();
//獲取所有路由信息,找到該請求對應的appName
List<Route> routeList = routeLocator.getRoutes();
//Route{id='one', fullPath='/zuuldmp/auth/**', path='/**', location='auth', prefix='/zuuldmp/auth',
String appName = null;
String path = null;
for (Route route : routeList) {
if (requestPath.startsWith(route.getPrefix())) {
//取到該請求對應的微服務名字
appName = route.getLocation();
path = requestPath.replace(route.getPrefix(), "");
}
}
if (appName == null) {
throw new NoLoginException(404, "不存在的服務");
}
//取到該用戶的role、permission
//訪問 auth 服務的 GET /project/my 接口
int code = authChecker.check(appName,
method,
path,
userType,
userCodes);
switch (code) {
case CODE_NO_APP:
throw new NoLoginException(code, "不存在的服務");
case CODE_404:
throw new NoLoginException(code, "無此接口或GET POST方法不對");
case CODE_NO_ROLE:
throw new NoLoginException(code, "用戶無該接口所需role");
case CODE_NO_CODE:
throw new NoLoginException(code, "用戶無該接口所需權限");
case CODE_OK:
ctx.addZuulRequestHeader(USER_ID, userId);
ctx.addZuulRequestHeader(USER_TYPE, userType);
ctx.addZuulRequestHeader(ROLE_ID, roleId);
default:
break;
}
return null;
}
}