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。定义接入的名称、加签验签方式、加密解密方式、接出系统。
0001
TOKEN
aaaa
aaaa
BASE64
app1
app2
每一个chnl代表接入的一个系统,如小程序a。sign代表加签验签配置,enc代表加密验密配置,targets代表允许该系统请求的目标系统,使用目标系统名称,具体的路径由下一章的负载实现。
2、gateway配置类读取接入系统,并按照接入系统配置,逐一配置路由规则,在路由规则中,使用filter,实现request response body的拦截和重写,实现加签验签,加密解密步骤。
@Configuration
@Slf4j
public class GatewayConfig {
private Map 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 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/(?/?.*)", "/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 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 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 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
app1
http://localhost:8093
http://127.0.0.1:8093
app2
http://localhost:8091
http://127.0.0.1:8091
定义配置类,读取加载out.xml
@Configuration
@Slf4j
public class LoadBalanceConfig {
private Map 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 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 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 urls = new ArrayList();
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 getUrls() {
return urls;
}
public void setUrls(List 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随时可破解,比明文安全一点。