持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第24天,点击查看活动详情
背景
在日常开发中,有时候需要开放接口给第三方合作伙伴使用,就像微信、支付宝的开发者平台一样,开放指定功能的接口给到具备开发能力的人员使用;为了保证对应的接口安全性,我们在网关自然是要做拦截校验的,下面我们就来看看在Spring Cloud Zuul中如何实现。
解决方案
1.平台方给到用户生成的appKey和appSecurity,该appKey绑定开放的接口;
2.调用方在请求中携带appKey以及通过相关签名算法计算出来的结果sign;
3.请求经过网关时,需要平台方解开请求校验对应的appKey以及签名结果sign;
4.如果校验通过,那么请求下发给目标服务;否则告知调用方没有调用权限;
相关对接文档如下:
- 准备工作
先在开放平台创建应用程序,并勾选相关功能,最后由开放平台生成appKey和appSecret给到调用方;
- 请求发送
【请求头】 | 必选 | 类型 | 说明 |
---|---|---|---|
app-key | 是 | String | 由开放平台分配的appKey |
app-sign | 是 | String | 请求参数加密后的结果 |
- 计算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.所有校验通过后,我们需要将当前请求重新组装继续向后传递;
【校验请求参数】
我们在校验请求参数的实现中使用了策略模式,目前只支持GET
,POST
请求,代码如下:
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.开放平台需要把对应的
appKey
和appSecret
给到调用方;2.调用方需要使用
appKey
和appSecret
针对请求参数进行加密,得到加密结果sign
并放到请求头中;3.开发平台接收到请求后,判断是否是特定请求,如果属于需要拦截的请求,那么取出
appKey
去数据表中查询是否确实存在对应的权限,并同时查询出数据表中的appSecret
;如果数据表中查询不到,那么说明没有访问权限;4.开放平台同时还要拿出请求参数,并通过
appKey
与appSecret
进行同样的加密操作得到sign
结果,并与请求头中拿到的sign
进行对比;如果两者一致,权限通过;否则没有访问权限;5.权限处理完毕后,需要重新组装请求继续向后传递,这里需要通过自定义请求来处理;