Spring Cloud Zuul如何实现开放平台接口的拦截校验

278 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第24天,点击查看活动详情

背景

在日常开发中,有时候需要开放接口给第三方合作伙伴使用,就像微信、支付宝的开发者平台一样,开放指定功能的接口给到具备开发能力的人员使用;为了保证对应的接口安全性,我们在网关自然是要做拦截校验的,下面我们就来看看在Spring Cloud Zuul中如何实现。

解决方案

1.平台方给到用户生成的appKey和appSecurity,该appKey绑定开放的接口;

2.调用方在请求中携带appKey以及通过相关签名算法计算出来的结果sign;

3.请求经过网关时,需要平台方解开请求校验对应的appKey以及签名结果sign;

4.如果校验通过,那么请求下发给目标服务;否则告知调用方没有调用权限;

相关对接文档如下:

  • 准备工作

先在开放平台创建应用程序,并勾选相关功能,最后由开放平台生成appKey和appSecret给到调用方;

  • 请求发送
【请求头】必选类型说明
app-keyString由开放平台分配的appKey
app-signString请求参数加密后的结果
  • 计算app-sign

【请求参数加密前需要先进行排序,针对排序后拼接出来的字符串做HmacSHA256加密】

【排序示例】

appKey=具体参数值
//排序前请求参数
name=jack
age=11
gender=男
//排序后等待加密字符串:
age=11&gender=男&name=jack&appKey=具体参数值

提示:如果Java代码,可使用Collections.sort针对List排序;如果是Map容器,可以使用TreeMap排序。

【加密方式】

private static final String MACSHA256 = "HmacSHA256";
​
public static String macSha2Base64(String message, String secret) {
    byte[] digestBytes = signBySha256(message, secret);
    try {
        return bytes2Base64(digestBytes, UTF8);
    } catch (Exception e) {
        return null;
    }
}
​
public static byte[] signBySha256(String message, String secret) {
    Mac hmacSha256;
    try {
        hmacSha256 = Mac.getInstance(MACSHA256);
        byte[] keyBytes = secret.getBytes(UTF8);
        byte[] messageBytes = message.getBytes(UTF8);
        hmacSha256.init(new SecretKeySpec(keyBytes, 0, keyBytes.length, MACSHA256));
        // 使用HmacSHA256对二进制数据消息Bytes计算摘要
        return hmacSha256.doFinal(messageBytes);
    } catch (Exception e) {
            return null;
    }
}
​
private static String bytes2Base64(byte[] bytes, String charset)
            throws UnsupportedEncodingException {
    return new String(Base64.getEncoder().encode(bytes), charset);
}
​

示例:

//待加密字符串
age=11&gender=男&name=zouwei&appKey=123456
//测试appSecurity
appSecurity=plokmijnuhb
//加密结果
lHw8EijUbCXnSzAOplMQE2Kwfu8ckTXHy5gITtOtlhw=

以上便是调用方的接口对接方案。

开放平台如何实现接口安全性校验

  • 创建数据表
-- ----------------------------
-- Table structure for app_security_tbl
-- ----------------------------
DROP TABLE IF EXISTS `app_security_tbl`;
CREATE TABLE `app_security_tbl` (
  `id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
  `user_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户ID',
  `app_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT 'app key',
  `app_secret` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin COMMENT '加密secret',
  `create_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `modified_date` timestamp NULL DEFAULT NULL COMMENT '修改时间',
  `merchant_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '商户号',
  `remark` varchar(256) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
​
-- ----------------------------
-- Table structure for app_api_permission_tbl
-- ----------------------------
DROP TABLE IF EXISTS `app_api_permission_tbl`;
CREATE TABLE `app_api_permission_tbl` (
  `id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
  `app_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT 'app key',
  `path` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '接口路径',
  `method` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '接口请求方法',
  `description` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '接口描述',
  `create_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `modified_date` timestamp NULL DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

1.app_security_tbl表用来管理用户与app_key的绑定关系;

2.app_api_permission_tbl表用来管理app_key与接口api的绑定关系;

  • 网关实现
import com.google.common.collect.Maps;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.zx.silverfox.api.auth.dto.AppSecurityResponse;
import com.zx.silverfox.api.auth.dto.HasPermissionRequest;
import com.zx.silverfox.common.config.api.ApiSecurityConst;
import com.zx.silverfox.common.exception.GlobalException;
import com.zx.silverfox.common.util.JsonUtil;
import com.zx.silverfox.common.vo.CommonResponse;
import com.zx.silverfox.gateway.filter.strategy.MethodSecurityStrategy;
import com.zx.silverfox.gateway.rpc.IAuthServiceAPI;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
​
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.Objects;
​
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;
​
/**
 * @author zouwei
 * @className ApiSecurityFilter
 * @date: 2020/9/26 上午11:40
 * @description:
 */
@Component
@Slf4j
public class ApiSecurityFilter extends ZuulFilter {
​
    @Autowired(required = false)
    private List<MethodSecurityStrategy> strategies;
​
    @Autowired private IAuthServiceAPI authServiceAPI;
​
    @Override
    public String filterType() {
        return PRE_TYPE;
    }
​
    @Override
    public int filterOrder() {
        return -9;
    }
​
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
      // 判断请求头中是否有指定的appKey,如果有指定的appKey就要准备拦截
        return !StringUtils.equalsIgnoreCase(request.getRequestURI(), "/error")
                && StringUtils.isNotBlank(request.getHeader(ApiSecurityConst.API_KEY));
    }
​
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        // 验证接口访问权限
        AppSecurityResponse securityResponse = verifyPermission(request);
        // 返回值为空,或者鉴权失败,直接结束此次请求
        if (Objects.isNull(securityResponse)
                || !appSecurityAuthentication(request, securityResponse)) {
            noPermissionResponse(context);
            return null;
        }
        // 鉴权成功,添加头部信息
        addAppSecurityToHeader(context, context.getRequest(), securityResponse);
        return null;
    }
​
    /**
     * 没有权限
     *
     * @param context
     */
    private void noPermissionResponse(RequestContext context) {
        Map<String, String> map = Maps.newHashMap();
        map.put("data", "暂无权限!");
        context.setSendZuulResponse(Boolean.FALSE);
        context.setResponseStatusCode(401);
        context.getResponse().setCharacterEncoding("UTF-8");
        context.getResponse().setContentType("application/json;charset=UTF-8");
        context.setResponseBody(JsonUtil.obj2String(map));
    }
​
    /**
     * 验证appKey
     *
     * @param request
     * @return
     */
    private AppSecurityResponse verifyPermission(HttpServletRequest request) {
        HasPermissionRequest permissionRequest = new HasPermissionRequest();
        String apiKey = request.getHeader(ApiSecurityConst.API_KEY);
        permissionRequest.setAppKey(apiKey);
        permissionRequest.setPath(request.getRequestURI());
        permissionRequest.setMethod(request.getMethod());
        try {
            CommonResponse<AppSecurityResponse> response =
                    authServiceAPI.hasPermission(permissionRequest);
            if (response.isSuccess()) {
                return response.getData();
            }
        } catch (GlobalException e) {
            return null;
        }
        return null;
    }
​
    /**
     * 添加请求头
     *
     * @param context
     * @param request
     * @param securityResponse
     */
    private void addAppSecurityToHeader(
            RequestContext context,
            HttpServletRequest request,
            AppSecurityResponse securityResponse) {
        CustomHeaderServletRequest customHeaderServletRequest =
                new CustomHeaderServletRequest(request);
        customHeaderServletRequest.setHeaders(
                ApiSecurityConst.API_USER_ID, securityResponse.getUserId());
        customHeaderServletRequest.setHeaders(
                ApiSecurityConst.API_SECURITY_KEY, securityResponse.getSecurityKey());
        customHeaderServletRequest.setHeaders(
                ApiSecurityConst.API_MERCHANT_CODE, securityResponse.getMerchantCode());
        context.setRequest(customHeaderServletRequest);
    }
​
    /**
     * 鉴权
     *
     * @return
     */
    private boolean appSecurityAuthentication(
            HttpServletRequest request, AppSecurityResponse securityResponse) {
        String method = request.getMethod();
        for (MethodSecurityStrategy strategy : strategies) {
            if (strategy.isTest(method)) {
                return strategy.test(request, securityResponse.getSecurityKey());
            }
        }
        return false;
    }
}

1.网关需要判断请求头中是否存在指定的appKey字段,如果包含,那么就符合拦截条件,否则直接跳过;

2.拦截到请求后,从请求头中获取appKey,并从数据库中查询出对应的appSecret;如果数据库中找不到appKey,那么说明没有调用权限;

3.解析出请求参数,通过对请求参数进行排序后再与查询出来的appSecret进行加密得到sign结果;

4.对比请求头中获取的sign与加密得到的sign结果,如果结果一致,那么说明允许接口被调用,否则加密结果不一致没有访问权限;

5.所有校验通过后,我们需要将当前请求重新组装继续向后传递;

【校验请求参数】

我们在校验请求参数的实现中使用了策略模式,目前只支持GETPOST请求,代码如下:

import javax.servlet.http.HttpServletRequest;
​
/**
 * @author zouwei
 * @className MethodSecurityStrategy
 * @date: 2020/11/25 上午11:45
 * @description:
 */
public interface MethodSecurityStrategy {
​
    String JOIN_STR = "&";
​
    boolean test(HttpServletRequest request, String securityKey);
​
    boolean isTest(String requestMethod);
}
​
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.netflix.zuul.context.RequestContext;
import com.zx.silverfox.common.config.api.ApiSecurityConst;
import com.zx.silverfox.common.util.CastUtil;
import com.zx.silverfox.common.util.JsonUtil;
import com.zx.silverfox.common.util.MD5Util;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.StreamUtils;
​
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.*;
​
/**
 * @author zouwei
 * @className AbstractMethodSecurityStrategy
 * @date: 2020/11/25 下午4:07
 * @description:
 */
@Slf4j
public abstract class AbstractMethodSecurityStrategy implements MethodSecurityStrategy {
​
    @Override
    public boolean test(HttpServletRequest request, String securityKey) {
        // 名称需要排序
        String str = joinGetRequestParams(request);
        return checkSign(request, str, securityKey);
    }
​
    private String requestSign(HttpServletRequest request) {
        return request.getHeader(ApiSecurityConst.SIGN_KEY);
    }
​
    private String appKey(HttpServletRequest request) {
        return request.getHeader(ApiSecurityConst.API_KEY);
    }
​
    /**
     * 校验签名
     *
     * @param request 请求
     * @param str 需要加密的字符串
     * @param securityKey 加密密钥
     * @return
     */
    protected boolean checkSign(HttpServletRequest request, String str, String securityKey) {
        if (StringUtils.isBlank(str)) {
            return false;
        }
        // 通过securityKey加密
        String sign = MD5Util.macSha2Base64(str, securityKey);
​
        return StringUtils.equals(requestSign(request), sign);
    }
​
    /**
     * 拼接GET请求参数
     *
     * @param request
     * @return
     */
    protected String joinGetRequestParams(HttpServletRequest request) {
        Enumeration<String> enumeration = request.getParameterNames();
        List<String> list = Lists.newArrayList();
        while (enumeration.hasMoreElements()) {
            list.add(enumeration.nextElement());
        }
        Collections.sort(list);
        StringJoiner sj = new StringJoiner(JOIN_STR);
        for (String name : list) {
            String value = request.getParameter(name);
            sj.add(name + "=" + value);
        }
        sj.add("appKey=" + appKey(request));
        return sj.toString();
    }
    /**
     * 拼接POST请求参数
     *
     * @param request
     * @return
     */
    protected String joinPostRequestParams(HttpServletRequest request) {
        String requestBody;
        try {
            BodyReaderHttpServletRequestWrapper requestWrapper =
                    new BodyReaderHttpServletRequestWrapper(request);
            RequestContext currentContext = RequestContext.getCurrentContext();
            requestBody = requestWrapper.getBody();
            currentContext.setRequest(requestWrapper);
        } catch (Exception e) {
            e.printStackTrace();
            return StringUtils.EMPTY;
        }
        Map<String, Object> map = JsonUtil.string2Obj(requestBody, Map.class);
        // 准备排序
        TreeMap<String, Object> treeMap = Maps.newTreeMap();
​
        if (Objects.nonNull(map)) {
            treeMap.putAll(map);
        }
​
        // 获取parameter
        Enumeration<String> enumeration = request.getParameterNames();
        while (enumeration.hasMoreElements()) {
            String key = enumeration.nextElement();
            treeMap.put(key, request.getParameter(key));
        }
​
        // 拼接字符串
        StringJoiner sj = new StringJoiner(JOIN_STR);
        for (Map.Entry<String, Object> e : treeMap.entrySet()) {
            String name = e.getKey();
            String value = CastUtil.castString(e.getValue());
            sj.add(name + "=" + value);
        }
        sj.add("appKey=" + appKey(request));
        return sj.toString();
    }
​
    public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
​
        private final byte[] bodyBytes;
​
        private final String body;
​
        public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
            super(request);
            this.bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
            body = new String(this.bodyBytes, Charset.forName("UTF-8"));
        }
​
        public String getBody() {
            return this.body;
        }
​
        @Override
        public BufferedReader getReader() throws IOException {
            return new BufferedReader(new InputStreamReader(getInputStream()));
        }
​
        @Override
        public int getContentLength() {
            return this.bodyBytes.length;
        }
​
        @Override
        public long getContentLengthLong() {
            return this.bodyBytes.length;
        }
​
        @Override
        public ServletInputStream getInputStream() throws IOException {
            final ByteArrayInputStream bais = new ByteArrayInputStream(bodyBytes);
            return new ServletInputStream() {
​
                @Override
                public boolean isFinished() {
                    return false;
                }
​
                @Override
                public boolean isReady() {
                    return true;
                }
​
                @Override
                public void setReadListener(ReadListener listener) {}
​
                @Override
                public int read() throws IOException {
                    return bais.read();
                }
            };
        }
    }
}
​
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
​
/**
 * @author zouwei
 * @className GetSecurityStrategy
 * @date: 2020/11/25 上午11:45
 * @description:
 */
@Component
public class GetSecurityStrategy extends AbstractMethodSecurityStrategy
        implements MethodSecurityStrategy {
​
    @Override
    public boolean isTest(String requestMethod) {
        return StringUtils.equalsIgnoreCase(requestMethod, HttpMethod.GET.name());
    }
}
​
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
​
import javax.servlet.http.HttpServletRequest;
​
/**
 * @author zouwei
 * @className GetSecurityStrategy
 * @date: 2020/11/25 上午11:45
 * @description:
 */
@Component
public class PostSecurityStrategy extends AbstractMethodSecurityStrategy
        implements MethodSecurityStrategy {
​
    @Override
    public boolean test(HttpServletRequest request, String securityKey) {
        // 解析请求体
        String str = joinPostRequestParams(request);
        // 校验签名
        return checkSign(request, str, securityKey);
    }
​
    @Override
    public boolean isTest(String requestMethod) {
        return StringUtils.equalsIgnoreCase(requestMethod, HttpMethod.POST.name());
    }
}

【组装请求】

在请求被网关解开后,是不能继续向后传递的,那么网关需要重新组装一个请求对象并把之前取出来的数据放进去,这样才能让用户的请求继续向后传递;

import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.zx.silverfox.common.util.CastUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
​
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.*;
​
/**
 * @author zouwei
 * @className CustomHeaderServletRequest
 * @date: 2020/9/24 下午1:48
 * @description:
 */
public class CustomHeaderServletRequest extends HttpServletRequestWrapper {
    private Map<String, String> headers = Maps.newHashMap();
​
    public CustomHeaderServletRequest(HttpServletRequest request) {
        super(request);
    }
​
    public void setHeaders(String key, String value) {
        headers.put(key, value);
    }
​
    private HttpServletRequest _getHttpServletRequest() {
        return (HttpServletRequest) super.getRequest();
    }
​
    @Override
    public String getHeader(String name) {
        String value = this._getHttpServletRequest().getHeader(name);
        if (StringUtils.isBlank(value)) {
            return this.headers.get(name);
        }
        return value;
    }
​
    @Override
    public Enumeration<String> getHeaders(String name) {
        Enumeration<String> values = this._getHttpServletRequest().getHeaders(name);
        String value = this.headers.get(name);
        if (StringUtils.isBlank(value)) {
            return values;
        }
        Collection<String> collection = Lists.newArrayList();
        collection.add(value);
        while (values.hasMoreElements()) {
            collection.add(values.nextElement());
        }
        return Collections.enumeration(collection);
    }
​
    @Override
    public Enumeration<String> getHeaderNames() {
        Enumeration<String> values = this._getHttpServletRequest().getHeaderNames();
        Set<String> keys = this.headers.keySet();
        if (CollectionUtils.isEmpty(keys)) {
            return values;
        }
        Collection<String> collection = Lists.newArrayList();
        while (values.hasMoreElements()) {
            collection.add(values.nextElement());
        }
        collection.addAll(keys);
        return Collections.enumeration(collection);
    }
​
    @Override
    public long getDateHeader(String name) {
        long value = this._getHttpServletRequest().getDateHeader(name);
        if (value <= -1) {
            return CastUtil.castInt(headers.get(name), -1);
        }
        return value;
​
    }
​
    /**
     * The default behavior of this method is to return getIntHeader(String name) on the wrapped
     * request object.
     */
    @Override
    public int getIntHeader(String name) {
        int value = this._getHttpServletRequest().getIntHeader(name);
        if (value <= -1) {
            return CastUtil.castInt(headers.get(name), -1);
        }
        return value;
    }
}

我们通过继承HttpServletRequestWrapper类来实现一个自定义的请求类,来根据元数据可以重新创建一个请求,这样的话,才能让用户请求继续传递下去。

小结

我们为了保证开放平台api的安全性,需要在网关针对特性请求进行拦截来校验,根据以上代码演示及概述,我们可以做出如下小结:

1.开放平台需要把对应的appKeyappSecret给到调用方;

2.调用方需要使用appKeyappSecret针对请求参数进行加密,得到加密结果sign并放到请求头中;

3.开发平台接收到请求后,判断是否是特定请求,如果属于需要拦截的请求,那么取出appKey去数据表中查询是否确实存在对应的权限,并同时查询出数据表中的appSecret;如果数据表中查询不到,那么说明没有访问权限;

4.开放平台同时还要拿出请求参数,并通过appKeyappSecret进行同样的加密操作得到sign结果,并与请求头中拿到的sign进行对比;如果两者一致,权限通过;否则没有访问权限;

5.权限处理完毕后,需要重新组装请求继续向后传递,这里需要通过自定义请求来处理;