spring cloud gateway 實現基於非服務發現的應用報文簽名&加密&路由

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隨時可破解,比明文安全一點。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章