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随时可破解,比明文安全一点。