在使用Spring Cloud 進行微服務,分布式開發時,網關是請求的第一入口,所以一般把客戶端請求的權限驗證統一放在網關進行認證與鑒權。因為Spring Cloud Gateway使用是基於WebFlux與Netty開發的,所以與傳統的Servlet方式不同。而且網關一般不會直接請求數據庫,不提供用戶管理服務,所以如果想在網關處進行登陸驗證與授權就需要做一些額外的開發了。
需求設求
眾所周知,一切架構都必須按需求來設計,萬能構架基本上是不存在的,即使是像Spring Security安全架構也只是實現了一個能用方式,並不是放之四海而皆准的,但是一個構架的良好擴展性是必須的,可以讓使用者按照自己的需要進行擴展使用。所以為了說明本示例的實現,先假定這樣一個需求
1,需要有一個Web網關服務進行權限統一認證
2,網關后面有一個用戶管理服務,負責用戶賬號的管理
3,網關后面還存在其它的服務,但是這些服務需要認證成功之后才能訪問
4,需要支持同一個請求可以被多個角色訪問
服務搭建請參考源碼 https://gitee.com/wgslucky/Spring-Gateway-Security
主要技能點說明
修改默認登陸頁面
在項目中添加完spring security依賴之后,如果不添加任何額外的配置,這時不管發送任何請求,都會跳到spring security提供的默認登陸頁面。這顯然不是我們想要的,那么第一步就是要顯示自定義的登陸頁面。
在Spring Gateway 網關項目中添加Security的配置,如下面代碼所示:
@EnableWebFluxSecurity
public class WebSecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
RedirectServerAuthenticationEntryPoint loginPoint = new RedirectServerAuthenticationEntryPoint("/xinyue-server-a/account/index");
http.authorizeExchange().pathMatchers("/xinyue-server-a/easyui/**","/xinyue-server-a/js/**","/xinyue-server-a/account/index","/xinyue-server-a/account/login").permitAll()
.and().formLogin().loginPage("/xinyue-server-a/account/authen").authenticationEntryPoint(loginPoint)
.and().authorizeExchange().anyExchange().authenticated()
.and().csrf().disable();
SecurityWebFilterChain chain = http.build();
return chain;
}
}
這里有一個容易出現理解錯誤的地址,網上有好多示例說是直接只配置loginPage("/my/login")即可,這樣配置的話,需要你的登陸頁面,和提交登陸信息的form的action都必須是一致的,只不過,一個是get方式請求/my/login,一個是post方式請求/my/login,但是大多數據情況下,我們的登陸頁面地址,和登陸form的action地址是分離的,所以需要按我上面的方式進行配置才可以。
http.authorizeExchange().pathMatchers("/xinyue-server-a/easyui/**","/xinyue-server-a/js/**","/xinyue-server-a/account/index","/xinyue-server-a/account/login").permitAll()
這個配置表示這些請求都不做驗證,直接放過。
.and().formLogin().loginPage("/xinyue-server-a/account/authen").authenticationEntryPoint(loginPoint)
這段配置表示需要認證的請求是/xinyue-server-a/account/authen(對手正常的Springmvc 服務來說,這個應該是登陸時form的action請求地址),如果沒有認證,跳轉到loginPoint設置的地址:/xinyue-server-a/account/index,即登陸頁面。
.and().authorizeExchange().anyExchange().authenticated()
這段配置表示其它請求都必須是認證(登陸成功)之后才可以訪問。
Spring Cloud Gateway 認證方式
如果是微服務模式,在Spring cloud gateway網關處進行用戶認證與授權有兩種方式:
1,在Spring Cloud Gateway服務這里添加數據庫訪問,直接檢測登陸信息是否正確,如果正確,再給此用戶授權。
2,在網關后面專門的認證服務進行登陸信息認證,如果登陸成功,在返回的Header中添加用戶認證與授權需要的信息,然后在網關處理再完成認證與授權
Ajax Post登陸與認證
本示例采用第二種方式,首先是客戶端向xinyue-server-a服務發送登陸請求,如下面代碼所示:
<script type="text/javascript">
function postAjax(url, json, success) {
$.ajax({
type : "POST",
url : url,
data : JSON.stringify(json),
dataType : "json",
contentType : "application/json",
success : function(data) {
if (data.code == 0) {
success(data);
} else {
alert("服務器異常,請聯系開發者");
}
},
error : function(data) {
alert(url + "請求錯誤:" + JSON.stringify(data));
}
});
}
function submitForm() {
$("#errorTips").html("");
var username = $("#username").val();
var password = $("#password").val();
var url = "/xinyue-server-a/account/login";
var json = {
"username" : username,
"password" : password
};
postAjax(
url,
json,
function(data) {
if (data.code == 0) { //如果登陸成功,發送認證請求
var authUrl = "/xinyue-server-a/account/authen";
var param = {};
postAjax(
authUrl,
param,
function(data) {
if (data.code == 0) {//認證成功之后,跳轉請求
window.location.href = "/xinyue-server-a/account/main";
} else {
$("#errorTips").html(data.msg);
}
});
} else {
$("#errorTips").html(data.msg);
}
});
}
</script>
這里使用ajax post方式向服務端發送登陸請求,如果登陸成功,然后再發送認證請求,在網關處完成認證。
登陸成功之后,返回用戶信息,緩存在網關session中
在本示例的源碼中,在xinyue-server-a服務中模擬用戶登陸成功,並返回此登陸用戶的信息,主要是權限信息,如下面代碼所示:
@RequestMapping("login")
@ResponseBody
public Object login(HttpServletResponse response) {
JSONObject userInfo = new JSONObject();
userInfo.put("username", "xinyues");
List<String> roles = new ArrayList<>();
roles.add("Admin");
roles.add("Dev");
userInfo.put("roles", roles);//添加角色信息
response.addHeader("AccountInfo", userInfo.toJSONString());//將信息放入響應的包頭
JSONObject result = new JSONObject();
result.put("code", 0);
return result;
}
然后在網關處添加過濾器,攔截登陸請求的響應信息,如下面代碼所示:
@Service
public class AuthenticationGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
private Logger logger = LoggerFactory.getLogger(AuthenticationGatewayFilterFactory.class);
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> chain.filter(exchange).then(Mono.fromRunnable(() -> {
List<String> gmAccountInfoJsons = exchange.getResponse().getHeaders().get("AccountInfo");
exchange.getResponse().getHeaders().remove("AccountInfo");//移除包頭中的用戶信息不需要返回給客戶端
if(gmAccountInfoJsons != null && gmAccountInfoJsons.size() > 0) {
String gmAccountInfoJson = gmAccountInfoJsons.get(0);//獲取header中的用戶信息
//將信息放在session中,在后面認證的請求中使用
exchange.getSession().block().getAttributes().put("AccountInfo", gmAccountInfoJson);
}
logger.debug("登陸返回信息:{}",gmAccountInfoJsons);
}));
}
}
請求認證過濾器,AuthenticationWebFilter
當有請求過來時,AuthenticationWebFilter用來攔截認證請求,如果客戶端是認證請求的話,在這里實現對此客戶端的認證,一般來說攔截的是登陸form中的action地址,可以從form提交的數據中獲取用戶名和密碼,然后使用用戶和密碼進行用戶驗證。但是本示例中並沒有使用form提交登陸,而是使用Ajax Post方式在網關后面的xinyue-server-a服務中進行的登陸驗證。在AuthenticationWebFilter中可以看到,如果是認證請求的話,需要使用.flatMap( matchResult -> this.authenticationConverter.convert(exchange))
方式從認證請求獲取認證需要的信息,默認是獲取登陸的用戶名和密碼。但是我們在上面已經將登陸信息存在session中了,所示需要重新提供一個authenticationConverter類,如下面代碼所示:
public class XinyueAuthenticationConverter extends ServerFormLoginAuthenticationConverter{
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
//從session中獲取登陸用戶信息
String value = exchange.getSession().block().getAttribute("AccountInfo");
if(value == null) {
return Mono.empty();
} else {
List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
//獲取權限信息
List<String> roels = JSON.parseObject(value).getJSONArray("roles").toJavaList(String.class);
roels.forEach(role->{
//這里必須添加前綴,參考:AuthorityReactiveAuthorizationManager.hasRole(role)
SimpleGrantedAuthority auth = new SimpleGrantedAuthority("ROLE_" + role);
simpleGrantedAuthorities.add(auth);
});
//添加用戶信息到spring security之中。
XinyueAccountAuthentication xinyueAccountAuthentication = new XinyueAccountAuthentication(null, value, simpleGrantedAuthorities);
return Mono.just(xinyueAccountAuthentication);
}
}
}
然后將XinyueAuthenticationConverter添加到WebSecurityConfig配置中(完成代碼請參考源碼)
SecurityWebFilterChain chain = http.build();
Iterator<WebFilter> weIterable = chain.getWebFilters().toIterable().iterator();
while(weIterable.hasNext()) {
WebFilter f = weIterable.next();
if(f instanceof AuthenticationWebFilter) {
AuthenticationWebFilter webFilter = (AuthenticationWebFilter) f;
//將自定義的AuthenticationConverter添加到過濾器中
webFilter.setServerAuthenticationConverter(new XinyueAuthenticationConverter());
}
}
然后添加認證成功操作,如下面代碼所示:
@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager() {
return new ReactiveAuthenticationManagerAdapter((authentication)->{
if(authentication instanceof XinyueAccountAuthentication) {
XinyueAccountAuthentication gmAccountAuthentication = (XinyueAccountAuthentication) authentication;
if(gmAccountAuthentication.getPrincipal() != null) {
authentication.setAuthenticated(true);
return authentication;
} else {
return authentication;
}
} else {
return authentication;
}
});
}
到此,就可以算是認證成功了,登陸成功之后,就會跳到轉到主頁面了。
請求權限驗證
一般來說,在管理系統中,用戶擁有不同的角色,不同的角色擁有不同的權限,那么在收到請求的時候,就需要在網關驗證當前用戶是否擁有訪問這個請求的權限,或是否是某一個角色,如果是才能進行訪問,否則返回用戶權限不足,拒絕訪問。
現在給下面這個請求配置必須擁有Manager權限才可以訪問
.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").hasRole("Manager")
如果這個時候再登陸,會發現服務器返回Access Denied
,如果配置為Dev權限
.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").hasRole("Dev")
因為此用戶擁有Dev權限(模擬賬號),所以可以正常訪問。
多個角色判斷
目前Spring Security提供的模式是一個請求配置一個角色,有些復雜的系統,要求一個請求的訪問權限可以被多個角色共同擁有。這就需要我們自定義一個權限的驗證了。
比如添加如下配置:
.and().authorizeExchange().pathMatchers("/xinyue-server-a/account/main").access(new XinyueReactiveAuthorizationManager("Manager", "Dev"))
表示這個請求需要Manager或Dev其中一個角色就可以訪問。
然后在XinyueReactiveAuthorizationManager中實現權限驗證判斷,詳細請考參源碼
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext object) {
return authentication
.filter(a -> a.isAuthenticated())
.flatMapIterable( a -> a.getAuthorities())
.map( g-> g.getAuthority())
.any(c->{
//檢測權限是否匹配
String[] roles = c.split(",");
for(String role:roles) {
if(authorities.contains(role)) {
return true;
}
}
return false;
})
.map( hasAuthority -> new AuthorizationDecision(hasAuthority))
.defaultIfEmpty(new AuthorizationDecision(false));
}
到此,Spring Cloud Gateway + Spring Security配置完畢,在實際應用中,可以根據自己的需求再進行適當的封裝。歡迎關注公眾號交流。
源碼地址:https://gitee.com/wgslucky/Spring-Gateway-Security
更多技術干貨:http://www.xinyues.com