背景
互联网公司随着业务的发展,系统间或多或少会开放一些对外接口,这些接口都会以API的形式提供给外部。为了方便统一管理,统一鉴权,统一签名认证机制,流量预警等引入了统一网关。API网关是一是对外接口唯一入口。
开放接口的安全性
对外开放的接口,如何保证安全通信,防止数据被恶意篡改等攻击呢?怎么证明是你发的请 求呢?
比较流行的方式一搬是- 加密
- 加签 注:加密是密文传输,接收方需要解密。加签是明文加签名传输,接收方验签防止数据篡改
Java版开放接口设计
本文用到的主要技术点
1.java泛型 2.rsa加签验签 3.springBoot 4.hibernate-validator注解式参数校验统一网关接口介绍
公共参数
参数 | 类型 | 是否必填 | 最大长度 | 描述 |
---|---|---|---|---|
app_id | String | 是 | 32 | 业务方appId |
method | String | 是 | 128 | 请求方法 |
version | String | 是 | 10 | 默认:1.0 |
api_request_id | String | 是 | 32 | 随机请求标识,用于区分每一次请求 |
charset | String | 是 | 16 | 默认:UTF-8 |
sign_type | String | 是 | 10 | 签名类型:RSA或RSA2 |
sign | String | 是 | - | 签名 |
content | String | 是 | - | 业务内容 :json 格式字符串 |
返回内容
参数 | 类型 | 是否必填 | 最大长度 | 描述 |
---|---|---|---|---|
success | boolean | 是 | 16 | 是否成功 |
data | Object | 是 | - | 返回业务信息(具体见业务接口) |
error_code | String | 是 | 10 | 错误码(success为false时必填) |
error_msg | String | 是 | 128 | 错误信息码(success为false时必填) |
签名规则
① 签名参数剔除sign_type 、 sign ② 将剩余参数第一个字符按照ASCII码排序(字母升序排序),遇到相同字母则按第二个字符ASCII码排序,以此类推 ③ 将排序后的参数按照组合“参数=参数值”的格式拼接,并用&字符连接起来,生成的字符串为待签名字符串 ④ 使用RSA算法通过私钥生成签名 RSA === SHA1 --> base64 RSA2 === SHA256 --> base64
代码实践
注:源码见文章末
maven依赖
open-api-project > pom4.0.0 com.open.api open-api-parent 1.0.0 pom org.springframework.boot spring-boot-starter-parent 1.5.14.RELEASE open-api-common open-api-web 1.0.0.20190312 UTF-8 UTF-8 1.5.14.RELEASE 4.3.17.RELEASE 3.5.4 3.4 3.2.2 2.5 3.2 1.10 1.12 4.5.2 1.2.39 1.0 2.8.1 3.3.6 1.7.25 4.12 21.0 3.1.0 3.0.9 1.16.4 5.4.1.Final 0.1.54 com.google.code.gson gson 2.2.4 org.springframework.boot spring-boot-dependencies ${spring.boot.version} pom import org.springframework spring-core ${spring.version} org.springframework spring-web ${spring.version} org.springframework spring-beans ${spring.version} org.springframework spring-oxm ${spring.version} org.springframework spring-tx ${spring.version} org.springframework spring-jdbc ${spring.version} org.springframework spring-webmvc ${spring.version} org.springframework spring-aop ${spring.version} org.springframework spring-context-support ${spring.version} org.springframework spring-test ${spring.version} test org.redisson redisson ${redisson.version} org.apache.commons commons-lang3 ${commons-lang3.version} commons-collections commons-collections ${commons-collections.version} commons-io commons-io ${commons-io.version} commons-net commons-net ${commons-net.version} commons-codec commons-codec ${commons-codec.version} org.apache.commons commons-compress ${commons-compress.version} org.apache.httpcomponents httpclient ${httpclient.version} commons-logging commons-logging com.alibaba fastjson ${fastjson.version} com.alibaba.common.lang toolkit-common-lang ${alibaba.common.lang.version} org.slf4j slf4j-api ${slf4j.version} org.slf4j slf4j-ext ${slf4j.version} org.apache.logging.log4j log4j-api ${log4j2.version} org.apache.logging.log4j log4j-core ${log4j2.version} org.apache.logging.log4j log4j-slf4j-impl ${log4j2.version} com.lmax disruptor ${disruptor.version} com.google.guava guava ${guava.version} javax.servlet javax.servlet-api ${javax.servlet.version} provided org.hibernate hibernate-validator ${hibernate-validator.version} org.projectlombok lombok ${lombok.version} junit junit ${junit.version} test com.alipay.sdk alipay-sdk-java 3.7.4.ALL
open-api-web > pom
4.0.0 open-api-parent com.open.api 1.0.0 com.open.api open-api-web ${project.version} Demo project for Spring Boot com.open.api open-api-common ${project.version} org.springframework.boot spring-boot-starter-aop org.springframework.boot spring-boot-starter-logging org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-logging org.springframework.boot spring-boot-starter-log4j2 org.springframework.boot spring-boot-test org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin org.apache.maven.plugins maven-compiler-plugin 2.3.2 true true true
添加配置文件
server.port=8821#日志配置logging.level.root=WARNlogging.level.net.sf=WARNlogging.level.com.open.api=debug#是否校验签名open.api.common.key.isCheckSign=false#开放接口公钥open.api.common.key.publicKey=MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgGxwWNSdZ1T4y2sMhESQJdTKDhhtTtVQT8eWxXiT2/TJDE2vZwqjcTFZZmK6ppibN9JL3VK5bU2Bc81v15XwlgZIgjrV9hRQNH1awIz4YkGOjEZKryHAXNYimM1z8Y4cOJiBafpVLmIDhz1DniAiyG+5cTVw2P4tIfE0L3ty+YCnAgMBAAE=#开放接口私钥open.api.common.key.privateKey=MIICWgIBAAKBgGxwWNSdZ1T4y2sMhESQJdTKDhhtTtVQT8eWxXiT2/TJDE2vZwqjcTFZZmK6ppibN9JL3VK5bU2Bc81v15XwlgZIgjrV9hRQNH1awIz4YkGOjEZKryHAXNYimM1z8Y4cOJiBafpVLmIDhz1DniAiyG+5cTVw2P4tIfE0L3ty+YCnAgMBAAECgYA1jodQ+yy92uMcy+HHuyn0Hpc3mUUGNdQxT1XYZ66LB4D8HVVW+8I8DVt0B5ugY4j+ZFm7Mbm6PeVj4YkolNqDOnDSxEGyVMEfjTJ3ipcJjVPbdEOLCjspgCnedrfbx/hDVURCmu4WFzbcMGwn9KjIxaE93Xolo57tbE1vYAWkAQJBALkBcKCmmoTiFhlR7QopagFppEAyo5kS/dOMpLDJQlWnFeJC93ISap0fNc7AXMsYVmCIebyFEtjWKWgwv05AzEcCQQCWDSZrT0wPgI7gnARNxklHzyuoS6brIXKakWvz9CPJ8//LQaZjrFiLYazK+itbGUcrRhh4ydWUzDcRQXVMarihAkAsjSI4LaasNV2o/0eb2NlEOdJp+0fWRvKFDStjvzOQOMpWUFYSTEkMSUXF4iD2b4ftezAFq+4b9YbHJmYLTCNlAkBlpvb2D7xpbCBfDZLk1YXjffgHhWjJNdmb2RSXKjfsor4RhqIgOCusETmsMJqalp9eM5h0i9eDfG155Sx/3nTBAkBmaJfxnoXg/bQPDoNIxbp/jWbQ1WThvygeD2aKjh6BtmzlkmBI0/8Qh2lGr4QoKNL4LVIf6afNeSyxmQeo35cT
启动类
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); }}
统一异常处理
import com.open.api.enums.ApiExceptionEnum;import com.open.api.exception.BusinessException;import com.open.api.model.ResultModel;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.context.annotation.EnableAspectJAutoProxy;import org.springframework.web.HttpRequestMethodNotSupportedException;import org.springframework.web.bind.MissingServletRequestParameterException;import org.springframework.web.bind.annotation.ControllerAdvice;import org.springframework.web.bind.annotation.CrossOrigin;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseBody;import javax.servlet.http.HttpServletResponse;import java.text.MessageFormat;/** * 统一异常处理 */@ControllerAdvice@EnableAspectJAutoProxypublic class ExceptionAdvice { /** * 日志 */ private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class); @CrossOrigin @ResponseBody @ExceptionHandler(value = Exception.class) public ResultModel defaultExceptionHandler(Exception exception, HttpServletResponse response) { ResultModel result; try { logger.warn("全局业务处理异常 >> error = {}", exception.getMessage(), exception); throw exception; } catch (BusinessException e) { result = ResultModel.error(e.getCode(), e.getMsg()); } catch (HttpRequestMethodNotSupportedException e) { String errorMsg = MessageFormat.format(ApiExceptionEnum.INVALID_REQUEST_ERROR.getMsg(), e.getMethod(), e.getSupportedHttpMethods()); result = ResultModel.error(ApiExceptionEnum.INVALID_REQUEST_ERROR.getCode(), errorMsg); } catch (MissingServletRequestParameterException e) { String errorMsg = MessageFormat.format(ApiExceptionEnum.INVALID_PUBLIC_PARAM.getMsg(), e.getMessage()); result = ResultModel.error(ApiExceptionEnum.INVALID_PUBLIC_PARAM.getCode(), errorMsg); } catch (Exception e) { result = ResultModel.error(ApiExceptionEnum.SYSTEM_ERROR.getCode(), ApiExceptionEnum.SYSTEM_ERROR.getMsg()); } return result; }}
自定义注解
开放接口方法注解
package com.open.api.annotation;import java.lang.annotation.*;/** * 开放接口注解 */@Documented@Inherited@Target({ ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)public @interface OpenApi { /** * api 方法名 * * @return */ String method(); /** * 方法描述 */ String desc() default "";}
开放接口实现类注解
package com.open.api.annotation;import org.springframework.stereotype.Component;import java.lang.annotation.*;/** * 开放接口实现类注解 */@Target({ ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documented@Componentpublic @interface OpenApiService { }
API接口初始化容器与扫描器
/** * Api 初始化容器 */@Servicepublic class ApiContainer extends HashMap{ }
/** * api接口对象 */public class ApiModel { /** * 类 spring bean */ private String beanName; /** * 方法对象 */ private Method method; /** * 业务参数 */ private String paramName; public ApiModel(String beanName, Method method, String paramName) { this.beanName = beanName; this.method = method; this.paramName = paramName; } //省略 get/set }
package com.open.api.support;import com.open.api.annotation.OpenApi;import com.open.api.annotation.OpenApiService;import com.open.api.config.context.ApplicationContextHelper;import org.apache.commons.lang3.StringUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.boot.CommandLineRunner;import org.springframework.core.annotation.AnnotationUtils;import org.springframework.stereotype.Component;import org.springframework.util.ReflectionUtils;import javax.annotation.Resource;import java.lang.reflect.Method;import java.util.ArrayList;import java.util.Map;import java.util.concurrent.atomic.AtomicInteger;import java.util.regex.Matcher;import java.util.regex.Pattern;/** * Api接口扫描器 */@Componentpublic class ApiScanner implements CommandLineRunner { private static final Logger LOGGER = LoggerFactory.getLogger(ApiScanner.class); /** * 方法签名拆分正则 */ private static final Pattern PATTERN = Pattern.compile("\\s+(.*)\\s+((.*)\\.(.*))\\((.*)\\)", Pattern.DOTALL); /** * 参数分隔符 */ private static final String PARAMS_SEPARATOR = ","; /** * 统计扫描次数 */ private AtomicInteger atomicInteger = new AtomicInteger(0); @Resource private ApiContainer apiContainer; @Override public void run(String... var1) throws Exception { //扫描所有使用@OpenApiService注解的类 MapopenApiServiceBeanMap = ApplicationContextHelper.getBeansWithAnnotation(OpenApiService.class); if (null == openApiServiceBeanMap || openApiServiceBeanMap.isEmpty()) { LOGGER.info("open api service bean map is empty"); return; } for (Map.Entry map : openApiServiceBeanMap.entrySet()) { //获取扫描类下所有方法 Method[] methods = ReflectionUtils.getAllDeclaredMethods(map.getValue().getClass()); for (Method method : methods) { atomicInteger.incrementAndGet(); //找到带有OpenApi 注解的方法 OpenApi openApi = AnnotationUtils.findAnnotation(method, OpenApi.class); if (null == openApi) { continue; } //获取业务参数对象 String paramName = getParamName(method); if (StringUtils.isBlank(paramName)) { LOGGER.warn("Api接口业务参数缺失 >> method = {}", openApi.method()); continue; } //组建ApiModel- 放入api容器 apiContainer.put(openApi.method(), new ApiModel(map.getKey(), method, paramName)); LOGGER.info("Api接口加载成功 >> method = {} , desc={}", openApi.method(), openApi.desc()); } } LOGGER.info("Api接口容器加载完毕 >> size = {} loopTimes={}", apiContainer.size(), atomicInteger.get()); } /** * 获取业务参数对象 * * @param method * @return */ private String getParamName(Method method) { ArrayList result = new ArrayList<>(); final Matcher matcher = PATTERN.matcher(method.toGenericString()); if (matcher.find()) { int groupCount = matcher.groupCount() + 1; for (int i = 0; i < groupCount; i++) { result.add(matcher.group(i)); } } //获取参数部分 if (result.size() >= 6) { String[] params = StringUtils.splitByWholeSeparatorPreserveAllTokens(result.get(5), PARAMS_SEPARATOR); if (params.length >= 2) { return params[1]; } } return null; }}
API请求处理客户端
package com.open.api.client;import com.alibaba.fastjson.JSON;import com.alipay.api.internal.util.AlipaySignature;import com.fasterxml.jackson.databind.DeserializationFeature;import com.fasterxml.jackson.databind.ObjectMapper;import com.fasterxml.jackson.databind.PropertyNamingStrategy;import com.open.api.config.context.ApplicationContextHelper;import com.open.api.config.property.ApplicationProperty;import com.open.api.enums.ApiExceptionEnum;import com.open.api.exception.BusinessException;import com.open.api.support.ApiContainer;import com.open.api.support.ApiModel;import com.open.api.model.ResultModel;import com.open.api.util.ValidateUtils;import org.apache.commons.lang3.exception.ExceptionUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Service;import javax.annotation.Resource;import java.lang.reflect.InvocationTargetException;import java.util.HashMap;import java.util.Map;/** * Api请求客户端 * * @author 码农猿 */@Servicepublic class ApiClient { /** * 日志 */ private static final Logger LOGGER = LoggerFactory.getLogger(ApiClient.class); /** * jackson 序列化工具类 */ private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); /** * Api本地容器 */ private final ApiContainer apiContainer; public ApiClient(ApiContainer apiContainer) { this.apiContainer = apiContainer; } @Resource private ApplicationProperty applicationProperty; /** * 验签 * * @param params 请求参数 * @param requestRandomId 请求随机标识(用于日志中分辨是否是同一次请求) * @param charset 请求编码 * @param signType 签名格式 * @author 码农猿 */ public void checkSign(Mapparams, String requestRandomId, String charset, String signType) { try { //校验签名开关 if (!applicationProperty.getIsCheckSign()) { LOGGER.warn("【{}】>> 验签开关关闭", requestRandomId); return; } //map类型转换 Map map = new HashMap<>(params.size()); for (String s : params.keySet()) { map.put(s, params.get(s).toString()); } LOGGER.warn("【{}】 >> 验签参数 {}", requestRandomId, map); boolean checkSign = AlipaySignature.rsaCheckV1(map, applicationProperty.getPublicKey(), charset, signType); if (!checkSign) { LOGGER.info("【{}】 >> 验签失败 >> params = {}", requestRandomId, JSON.toJSONString(params)); throw new BusinessException(ApiExceptionEnum.INVALID_SIGN); } LOGGER.warn("【{}】 >> 验签成功", requestRandomId); } catch (Exception e) { LOGGER.error("【{}】 >> 验签异常 >> params = {}, error = {}", requestRandomId, JSON.toJSONString(params), ExceptionUtils.getStackTrace(e)); throw new BusinessException(ApiExceptionEnum.INVALID_SIGN); } } /** * Api调用方法 * * @param method 请求方法 * @param requestRandomId 请求随机标识 * @param content 请求体 * @author 码农猿 */ public ResultModel invoke(String method, String requestRandomId, String content) throws Throwable { //获取api方法 ApiModel apiModel = apiContainer.get(method); if (null == apiModel) { LOGGER.info("【{}】 >> API方法不存在 >> method = {}", requestRandomId, method); throw new BusinessException(ApiExceptionEnum.API_NOT_EXIST); } //获得spring bean Object bean = ApplicationContextHelper.getBean(apiModel.getBeanName()); if (null == bean) { LOGGER.warn("【{}】 >> API方法不存在 >> method = {}, beanName = {}", requestRandomId, method, apiModel.getBeanName()); throw new BusinessException(ApiExceptionEnum.API_NOT_EXIST); } //处理业务参数 // 忽略JSON字符串中存在,而在Java中不存在的属性 JSON_MAPPER.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); // 设置下划线序列化方式 JSON_MAPPER.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); Object result = JSON_MAPPER.readValue(content, Class.forName(apiModel.getParamName())); //校验参数 ValidateUtils.validate(result); //执行对应方法 try { Object obj = apiModel.getMethod().invoke(bean, requestRandomId, result); return ResultModel.success(obj); } catch (Exception e) { if (e instanceof InvocationTargetException) { throw ((InvocationTargetException) e).getTargetException(); } throw new BusinessException(ApiExceptionEnum.SYSTEM_ERROR); } }}
创建测试接口
入参BO/** * 使用注解做参数校验 */public class Test1BO implements Serializable { private static final long serialVersionUID = -1L; @Valid @NotEmpty(message = "集合不为空!") @Size(min = 1, message = "最小为{min}") private List- itemList; //省略 get/set /** * 内部类 */ public static class Item { @NotBlank(message = "username 不能为空!") private String username; @NotBlank(message = "password 不能为空!") private String password; @NotBlank(message = "realName 不能为空!") private String realName; //省略 get/set }}
测试service接口
注意:注解@OpenApi 使用 ,method就是入参中的方法/** * 测试开放接口1 */public interface TestOneService { /** * 方法1 */ @OpenApi(method = "open.api.test.one.method1", desc = "测试接口1,方法1") void testMethod1(String requestId, Test1BO test1BO);}
测试接口实现类
/** * 测试开放接口1 ** 注解@OpenApiService > 开放接口自定义注解,用于启动时扫描接口 */@Servicepublic class TestOneServiceImpl implements TestOneService {
/** * 日志 */ private static final Logger LOGGER = LoggerFactory.getLogger(TestOneServiceImpl.class); /** * 方法1 */ @Override public void testMethod1(String requestId, Test1BO test1BO) { LOGGER.info("【{}】>> 测试开放接口1 >> 方法1 params={}", requestId, JSON.toJSONString(test1BO)); }}
统一网关开放接口controller
package com.open.api.controller;import com.alibaba.fastjson.JSON;import com.open.api.client.ApiClient;import com.open.api.model.ResultModel;import com.open.api.util.SystemClock;import jodd.util.StringPool;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.util.WebUtils;import javax.servlet.http.HttpServletRequest;import java.util.Map;/** * 统一网关 */@RestController@RequestMapping("/open")public class OpenApiController { private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiController.class); @Autowired private ApiClient apiClient; /** * 统一网关入口 * * @param method 请求方法 * @param version 版本 * @param apiRequestId 请求标识(用于日志中分辨是否是同一次请求) * @param charset 请求编码 * @param signType 签名格式 * @param sign 签名 * @param content 业务内容参数 * @author 码农猿 */ @PostMapping("/gateway") public ResultModel gateway(@RequestParam(value = "app_id", required = true) String appId, @RequestParam(value = "method", required = true) String method, @RequestParam(value = "version", required = true) String version, @RequestParam(value = "api_request_id", required = true) String apiRequestId, @RequestParam(value = "charset", required = true) String charset, @RequestParam(value = "sign_type", required = true) String signType, @RequestParam(value = "sign", required = true) String sign, @RequestParam(value = "content", required = true) String content, HttpServletRequest request) throws Throwable { Mapparams = WebUtils.getParametersStartingWith(request, StringPool.EMPTY); LOGGER.info("【{}】>> 网关执行开始 >> method={} params = {}", apiRequestId, method, JSON.toJSONString(params)); long start = SystemClock.millisClock().now(); //验签 apiClient.checkSign(params, apiRequestId, charset, signType); //请求接口 ResultModel result = apiClient.invoke(method, apiRequestId, content); LOGGER.info("【{}】>> 网关执行结束 >> method={},result = {}, times = {} ms", apiRequestId, method, JSON.toJSONString(result), (SystemClock.millisClock().now() - start)); return result; }}
接口测试
注:为了方便调试先将配置文件中的验签开关修改为 false正常情况
异常情况源码地址