spring cloud 出世之後,當然是基於微服務的服務發現註冊等一系列完整解決方案而言。但是,對於不同的企業,不同的應用現狀,不同的行業環境,系統的部署架構也不一樣,完全套用spring cloud的解決方案,需要對現有的工程及體系進行大量的改造。以我們目前的情況爲例,我們需要小程序訪問後臺服務,因爲行業加密要求和已有系統已經有一套部署體系,所以只需要一個網關,提供小程序後臺api的整體驗籤、加密、負載要求。基於以上,我研究了spring gateway,自己設計了一個解決方案,希望對有需要的朋友可以借鑑,並不成熟。
主要分爲以下幾個方面:
一、網路訪問的整體流程。
二、spring gateway部分的設計實現。
三、一些解釋。
一、網路訪問的整體流程如下:
小程序 <--(公網https)--> 域名服務器 <----> DMZ區nginx代理(https轉http)<--(內網)--> 網關 <----> 具體服務
小程序公網通過https訪問域名(也可以是公網IP地址),然後進入DMZ區進行代理,將https轉爲http,降低內網訪問加密,通過網關時,可以進行加簽驗籤,加密解密,負載,最後將解密報文和一些附加信息(如token)請求到具體的服務。此時,具體的服務只需要支持http restful即可,不需要實現服務的註冊和發現。
二、網關的設計和實現。
1、設計。因爲考慮到網關的可用性、擴展性,必須支持多種加簽驗籤、加密解密機制,同時,新接入的系統通過配置即可實現接入,不需要編寫代碼。請求必須根據情況實現負載,目前是restful負載,不能影響以後的服務發現負載。
2、實現。
2.1、加簽驗籤。對於不同的加簽驗籤方式,對於報文的處理也不同。常見的有token,RSA,MD5,SHA等。token需要提前獲取token之外,其他的則不需要提前獲取,在每次請求時驗證簽名即可。所以,在gateway配置路由和filter時,就需要區分每個接入系統以及接入方式。如此以來,gateway就不能使用spring 的配置文件方式,必須使用java config方式。
官網的java config路由示例:
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("rewrite_request_obj", r -> r.host("*.rewriterequestobj.org")
.filters(f -> f.prefixPath("/httpbin")
.modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE,
(exchange, s) -> return Mono.just(new Hello(s.toUpperCase())))).uri(uri))
.build();
}
拿token接入來說,必須分配兩種路徑,第一種路徑用來申請token,第二種路徑用來代理請求。如:
申請token:/api/sign
請求:/v/****
然後就需要針對這兩種路徑進行相應的加簽和驗籤的處理。因爲考慮到安全問題和報文規範,所以建議將加密信息通過http的請求頭進行傳輸。因爲一次請求要經過加簽驗籤、加密驗密,特別是http request body不可復讀的問題,所以filter必須一次執行完所有步驟,減少性能損耗。此處暫時先不貼代碼,懶得摘出來。一會加密解密完成一起貼。
2.2 加密驗密。加密驗密相比加簽驗籤,不需要區分路徑。因爲暫時我還沒及時將所有細節優化抽象,所以代碼裏都遵循了token的路徑區分。這都不重要。主要理解,請求過來,我們要做的事情是:解密--》請求--》相應---》加密。常用的加密方式有:RSA,AES,BASE64,URLEncoder等。其實,base64和URLEncoder不算加密,只是一種編碼方式。
在驗籤通過後,對請求報文進行報文解密,如果是token的話,附加token信息,將組裝後的報文請求到後臺具體服務;拿到響應報文時,對響應的報文最加密,然後返回。
以上兩部分都是基於gateway的已有filter配合route config實現,現將各部分一一貼出。
1、對於接入系統,定義配置文件in.xml。定義接入的名稱、加簽驗籤方式、加密解密方式、接出系統。
<?xml version="1.0" encoding="UTF-8"?>
<data>
<chnl>
<id>0001</id>
<safe>
<sign>
<type>TOKEN</type>
<signkey>aaaa</signkey>
<verifykey>aaaa</verifykey>
</sign>
<enc>
<type>BASE64</type>
<enckey></enckey>
<deckey></deckey>
</enc>
</safe>
<targets>
<target>app1</target>
<target>app2</target>
</targets>
</chnl>
</data>
每一個chnl代表接入的一個系統,如小程序a。sign代表加簽驗籤配置,enc代表加密驗密配置,targets代表允許該系統請求的目標系統,使用目標系統名稱,具體的路徑由下一章的負載實現。
2、gateway配置類讀取接入系統,並按照接入系統配置,逐一配置路由規則,在路由規則中,使用filter,實現request response body的攔截和重寫,實現加簽驗籤,加密解密步驟。
@Configuration
@Slf4j
public class GatewayConfig {
private Map<String, ChnlConf> chnlMap = new HashMap<>();
@Value("${gateway.chnl.config-file}")
String configFile;
@PostConstruct
void init() throws Exception {
InputStream in = null;
try {
String file = "config/in.xml";
if(!StringUtils.isEmpty(configFile)) {
in = new FileInputStream(configFile);
log.info("==>chnl config file:{}",configFile);
}else {
in = this.getClass().getClassLoader().getResourceAsStream(file);
log.info("==>chnl config file:{}",file);
}
Element root = XMLUtils.parseXML(in,
"UTF-8");
for (Element e : root.elements()) {
String chnl = e.elementText("id");
if (StringUtils.isEmpty(chnl)) {
throw new Exception("in.xml chnl id can't be null!");
}
Element targets = e.element("targets");
if (null == targets || targets.elements().isEmpty())
throw new Exception("in.xml chnl :" + chnl + " targets can't be empty!");
List<String> ts = new ArrayList<>();
for(Element target : targets.elements()) {
ts.add(target.getText());
}
ChnlConf c = new ChnlConf(chnl, ts);
Element safe = e.element("safe");
if (null == safe) {
chnlMap.put(chnl, c);
continue;
}
for (Element s : safe.elements()) {
String name = s.getName();
String type = s.elementText("type");
if (StringUtils.isEmpty(type)) {
throw new Exception("in.xml chnl :" + chnl + "type can't be empty!");
}
if ("sign".equals(name)) {
c.setSignType(type);
c.setSignKey(s.elementText("signkey"));
c.setVerifyKey(s.elementText("verifykey"));
} else {
c.setEncryptType(type);
c.setEncryptKey(s.elementText("enckey"));
c.setDecryptKey(s.elementText("deckey"));
}
}
chnlMap.put(chnl, c);
}
}finally {
if(null != in)
try {
in.close();
} catch (Exception e) {
}
}
log.info("==> chnl config:{}", JSON.toJSONString(chnlMap));
}
public ChnlConf getConfig(final String chnl) {
return chnlMap.get(chnl);
}
@Bean
public RequestLogFilter requestLogFilter() {
return new RequestLogFilter();
}
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
Builder routes = builder.routes();
for (ChnlConf chnl : chnlMap.values()) {
log.info("==>chnl config:{}",chnl);
configRoute(routes, chnl);
}
return routes.build();
}
private void configRoute(Builder routes, ChnlConf config) {
for(String ct : config.getTargets()) {
routes.route(config.getChnl(),
r -> r.header("source", config.getChnl())
.and().header("target",ct).and().path("/api/sign")
.filters(f ->
f.modifyRequestBody(String.class, String.class,
(exchange, s) -> check(exchange,s,config))
.modifyResponseBody(String.class,String.class,
(exchange, s) -> encrypt(exchange,s, config,true)))
.uri("hb://"+ct));
routes.route(config.getChnl(),
r -> r.header("source", config.getChnl())
.and().header("target",ct)
.and().path("/v/**")
.filters(f ->
f.stripPrefix(1)
//f.rewritePath("/api/v/(?<segment>/?.*)", "/api/${segment}")
.modifyRequestBody(String.class, String.class,
(exchange, s) -> verify(exchange,s,config))
.modifyResponseBody(String.class, String.class,
(exchange, s) -> encrypt(exchange,s,config,false)))
.uri("hb://"+ct));
}
}
private Mono<String> check(ServerWebExchange exchange,String source,ChnlConf config) {
try {
log.info("==>check source:{}",source);
source = decrypt(exchange,source,config);
log.info("==>check source after decrypt:{}",source);
switch (AlgorithmEnum.valueOf(config.getSignType())) {
case TOKEN:
if(StringUtils.isEmpty(source)) {
return Mono.error(new Exception("parameter invalid"));
}
JSONObject t = JSON.parseObject(source);
String sec = t.getString("secret");
if(StringUtils.isEmpty(sec)) {
return Mono.error(new Exception("parameter invalid"));
}
if(!sec.equals(config.getSignKey())) {
log.info("==>chnl key:{}",sec);
log.info("==>config key:{}",config.getSignKey());
return Mono.error(new Exception("parameter invalid"));
}
t.remove("secret");
return Mono.just(t.toJSONString());
case MD5:
;
case SHA1:
;
case SHA1withRSA:
;
default:
;
}
}catch (Exception e) {
log.error("==>input check error:",e);
return Mono.error(new PlatException(e,e.getMessage()));
}
return Mono.just(source);
}
private String sign(ServerWebExchange exchange,String source, ChnlConf config) throws Exception{
log.info("==>sign source:{}",source);
try {
if(StringUtils.isEmpty(source)) {
throw new PlatException("","token sign error:sign result is null");
}
JSONObject t = JSON.parseObject(source);
if("FAIL".equals(t.getString("code"))) {
throw new PlatException("","token sign error,sign result is fail");
}
switch (AlgorithmEnum.valueOf(config.getSignType())) {
case TOKEN:
TokenSub sub = new TokenSub();
sub.setId(config.getChnl());
sub.setIssuer("gateway");
sub.setSubject(t.toJSONString());
sub.setKey(config.getSignKey());
String token = JwtUtil.createJWT(sub);
t.put("token", token);
return t.toJSONString();
case MD5:
;
case SHA1:
;
case SHA1withRSA:
;
default:
;
}
}catch (Exception e) {
log.error("==>token sign error:",e);
throw new PlatException(e,"token sign error");
}
return source;
}
private Mono<String> verify(ServerWebExchange exchange,String source, ChnlConf config) {
try {
log.info("==>verify source:{}",source);
source = decrypt(exchange, source, config);
log.info("==>verify source after decrypt:{}",source);
if(StringUtils.isEmpty(source)) {
return Mono.just("{}");
}
switch (AlgorithmEnum.valueOf(config.getSignType())) {
case TOKEN:
try {
String token = exchange.getRequest().getHeaders().getFirst("sign");
String sub = JwtUtil.parseJWT(token,config.getVerifyKey()).getSubject();
log.info("==>token sign subject:{}",sub);
JSONObject r = new JSONObject();
r.put("data", JSON.parseObject(source));
r.put("sign", JSON.parseObject(sub));
String vr = r.toJSONString();
log.info("==>request data after repackage sign :{}",vr);
return Mono.just(vr);
}catch (Exception e) {
log.error("==>verify token error:{}",e);
return Mono.error(new PlatException(e,"verify token error"));
}
case MD5:
;
case SHA1:
;
case SHA1withRSA:
;
default:
;
}
}catch (Exception e) {
log.error("==>input verify error:",e);
return Mono.error(new PlatException(e,e.getMessage()));
}
return Mono.just(source);
}
private Mono<String> encrypt(ServerWebExchange exchange,String source, ChnlConf config,boolean needSign) {
log.info("==>encrypt source:{}",source);
try {
if(needSign) source = sign(exchange,source,config);
log.info("==>encrypt source after sign:{}",source);
if(StringUtils.isEmpty(source)) {
return Mono.just(JSON.toJSONString(ReturnMessage.failMsg("200", "", "")));
}
switch (AlgorithmEnum.valueOf(config.getEncryptType())) {
case BASE64:
String s = new String(Base64.encodeBase64(source.getBytes()));
s = JSON.toJSONString(ReturnMessage.failMsg("200", "", s));
return Mono.just(s);
case RSA:
;
default:
;
}
}catch (Exception e) {
log.error("==>encrypt error:",e);
return Mono.error(new PlatException(e,e.getMessage()));
}
return Mono.just(source);
}
private String decrypt(ServerWebExchange exchange,String source, ChnlConf config) throws Exception{
log.info("==>decrypt source:{}",source);
if(StringUtils.isEmpty(source)) {
return "";
}
try {
JSONObject s = JSON.parseObject(source);
String data = s.getString("data");
if(StringUtils.isEmpty(data)) {
return "";
}
switch (AlgorithmEnum.valueOf(config.getEncryptType())) {
case BASE64:
return new String(Base64.decodeBase64(data));
case RSA:
;
default:
;
}
}catch (Exception e) {
log.error("==>decrypt error:",e);
throw new PlatException(e,"decrypt error,check input");
}
return source;
}
}
3、負載。
上面的路由配置中可以看到,我們使用了接入系統中配置targets,定義uri爲 HB:// + target,這樣一來,不影響gateway默認的LB類型。接下來,就是如何知道HB什麼時候負載的問題。
我閱讀了很多相關文檔,最後鎖定了lb的實現,參考它完成自定義的hb負載。此時,我們需要一個target的路由表,我們將其定義爲配置文件out.xml
<?xml version="1.0" encoding="UTF-8"?>
<data>
<target>
<name>app1</name>
<address>
<url>http://localhost:8093</url>
<url>http://127.0.0.1:8093</url>
</address>
</target>
<target>
<name>app2</name>
<address>
<url>http://localhost:8091</url>
<url>http://127.0.0.1:8091</url>
</address>
</target>
</data>
定義配置類,讀取加載out.xml
@Configuration
@Slf4j
public class LoadBalanceConfig {
private Map<String, ChnlTarget> targetMap = new HashMap<>();
@Value("${gateway.target.config-file}")
String configFile;
@PostConstruct
void init() throws Exception {
InputStream in = null;
try {
String file = "config/out.xml";
if (!StringUtils.isEmpty(configFile)) {
in = new FileInputStream(configFile);
log.info("==>lb config file:{}", configFile);
} else {
in = this.getClass().getClassLoader().getResourceAsStream(file);
log.info("==>lb config file:{}", file);
}
Element root = XMLUtils.parseXML(in, "UTF-8");
for (Element e : root.elements()) {
String target = e.elementText("name");
if (StringUtils.isEmpty(target)) {
throw new Exception("out.xml target name can't be null!");
}
ChnlTarget t = new ChnlTarget(target);
Element targets = e.element("address");
if (null == targets || targets.elements().isEmpty())
throw new Exception("out.xml target :" + target + " address can't be empty!");
List<String> ts = new ArrayList<>();
for (Element url : targets.elements()) {
ts.add(url.getText());
}
t.setUrls(ts);
targetMap.put(target, t);
}
} finally {
if (null != in)
try {
in.close();
} catch (Exception e) {
}
}
log.info("==> target config:{}", JSON.toJSONString(targetMap));
}
public ChnlTarget getTarget(String name) {
return targetMap.get(name);
}
@Bean
public RobbinLoadBalanceClientFilter robbinLoadBalanceClientFilter() {
return new RobbinLoadBalanceClientFilter();
}
}
然後,如果一個請求被路由到了hb下,我們使用globalfilter來實現負載。
@Slf4j
public class RobbinLoadBalanceClientFilter implements GlobalFilter, Ordered {
@Resource
private LoadBalanceConfig loadBalanceConfig;
private static final String PERCENTAGE_SIGN = "%";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR);
if (url == null || (!"hb".equals(url.getScheme()) && !"hb".equals(schemePrefix))) {
return chain.filter(exchange);
}
if (log.isTraceEnabled()) {
log.trace(RobbinLoadBalanceClientFilter.class.getSimpleName() + " url before: " + url);
}
String t = exchange.getRequest().getHeaders().getFirst("target");
ChnlTarget target = loadBalanceConfig.getTarget(t);
Assert.notNull(target,"target not support");
//basic path
String burl = target.getBanlanceUrl();
URI nurl = URI.create(burl);
URI ourl = exchange.getRequest().getURI();
boolean encoded = containsEncodedParts(ourl);
URI turl = UriComponentsBuilder.fromUri(ourl).scheme(nurl.getScheme()).host(nurl.getHost()).port(nurl.getPort())
.build(encoded).toUri();
//request paramters
log.info("==>rewrite url:{}",turl );
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, turl);
exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR,"http");
if (log.isTraceEnabled()) {
log.trace("LoadBalancerClientFilter url chosen: " + target.getCurrentUrl());
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 10140;//before lb filter
}
private static boolean containsEncodedParts(URI uri) {
boolean encoded = (uri.getRawQuery() != null
&& uri.getRawQuery().contains(PERCENTAGE_SIGN))
|| (uri.getRawPath() != null
&& uri.getRawPath().contains(PERCENTAGE_SIGN))
|| (uri.getRawFragment() != null
&& uri.getRawFragment().contains(PERCENTAGE_SIGN));
// Verify if it is really fully encoded. Treat partial encoded as unencoded.
if (encoded) {
try {
UriComponentsBuilder.fromUri(uri).build(true);
return true;
}
catch (IllegalArgumentException ignore) {
}
return false;
}
return false;
}
}
其中,具體的負載url調用了對象的負載方法,默認用了輪詢路由。
public class ChnlTarget {
private String name;
private int visitIndex = 0;
private List<String> urls = new ArrayList<String>();
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getVisitIndex() {
return visitIndex;
}
public void setVisitIndex(int visitIndex) {
this.visitIndex = visitIndex;
}
public List<String> getUrls() {
return urls;
}
public void setUrls(List<String> urls) {
this.urls = urls;
}
public ChnlTarget(String name) {
super();
this.name = name;
}
public String getBanlanceUrl() {
Assert.notEmpty(urls,"target address unfound");
if(this.visitIndex>(urls.size()-1)) this.visitIndex=0;
String url = urls.get(visitIndex);
this.visitIndex++;
return url;
}
public String getCurrentUrl() {
Assert.notEmpty(urls,"target address unfound");
return urls.get(visitIndex-1);
}
}
主要代碼到這就結束了。其他的如默認異常熔斷之類的,網上很多方案,就不說了。
三、一些解釋。
此處對於不太瞭解加簽驗籤、加密驗密的同學。當然,我也不專業,只是學習瞭解。
加簽驗籤,好比A向B發送數據,需要提供一個簽名,讓B相信數據就是A的,而不是別人篡改後的,或者別人發給B的。簽名就是一個令牌、身份證明。一般https就有協議級別的簽名以及加密機制。
加密解密,好比A向B發送數據,需要提供數據保護,中途C看見數據,也因爲沒有密碼而無法破解報文內容。
這兩個用比喻來說,就是A給B送錢,A拿出身份證給B看,證明A就是A,這是驗籤;如果A直接手拿錢,那麼就是未加密,路上任何人都能看見A的錢,如果A拿箱子鎖住,那麼別人就看不到了,只有B能用鑰匙打開看,這就是加密解密。
RSA:非對稱加密技術,也可以作爲驗籤的機制。公鑰加密,私鑰解密,由於私鑰不傳輸,最爲安全。
AES:對稱加密技術,可逆,雙方祕鑰一致。
token:令牌驗證技術,一般具有時效性。
MD5,SHA: 數據校驗技術,一般生成數據的唯一標記值,不可逆,常用來驗證數據完整性。
BASE64,URLEncoder:報文編碼技術,可逆。
加簽時,MD5,SHA等簽名都是可逆的,所以如果沒有其他機制,容易僞造。token和RSA則不容易僞造。
加密時,RSA最難破解;AES一方私鑰泄露,另一方也泄露。BASE64,URLEncoder隨時可破解,比明文安全一點。