【JavaWeb】如何优雅的实现第三方开放api接口签名(有状态/无状态)
文章目录
- 前言
- 接口签名的必要性
- 什么是重放攻击
- HTTPS数据加密是否可以防止重放攻击
- 开发中的appId、appKey、appSecret到底是什么
- 一.签名流程
- 二.签名规则
- 三.签名的生成
- 1.签名signature字段生成规则
- 2.请求参数描述
- 2.1.请求头
- 2.2.请求URL
- 2.3.请求数据
- 四.接口签名的实现
- 1.实现步骤
- 2.实现代码
- 2.0.需要用到的依赖
- 2.1.常量类
- 2.2.签名过滤器
- 2.3.签名工具类
- 2.4.Http请求工具类
- 2.5.请求包装类
- 五.API接口设计补充建议
- 1.使用POST作为接口请求方式
- 2.客户端IP白名单
- 3. 单个接口针对ip限流
- 4. 记录接口请求日志
- 5. 敏感数据脱敏
- 6.幂等性问题
- 7.版本控制
- 8.响应状态码规范
- 9.统一响应数据格式
- 10.接口文档
- 11.生成签名sign的详细步骤
- 12.1.什么是token?
- 12.2.Token+签名(有用户状态的接口签名)
- 六.基于SpringBoot的Aop+自定义注解的方式实现接口签名源码
- 七.总结
前言
结合我之前写的【Java基础】加密与安全基础 、【JavaWeb】浅谈接口安全设计指南(含源码)可以在看本文章时更加熟悉 “加密以及安全”的基础概念
接口签名的必要性
在为第三方系统提供接口的时候,肯定要考虑接口数据的安全问题,比如数据是否被篡改,数据是否已经过时,数据是否可以重复提交等问题。其中我认为最终要的还是数据是否被篡改。在此分享一下我的关于接口签名的实践方案。
什么是重放攻击
- 重放攻击,web漏洞中称会话重放漏洞,又称
重播攻击、回放攻击
- 指的是 先截取主机A发送给主机B的报文,入侵这把A请求B的报文
原封不动地再发送一次,两次...n次
,使主机B误以为入侵者就是主机A,然后进入到正常逻辑中并返回响应。如果是付款接口,或者购买接口
就会造成损失,因此需要采用防重放的机制来做请求验证,如请求参数上加上timestamp时间戳+nonce随机数(下面有讲)
。
HTTPS数据加密是否可以防止重放攻击
不可以,加密可以有效防止明文数据被监听
,但是却防止不了重放攻击
。
开发中的appId、appKey、appSecret到底是什么
appID:应用的唯一标识
用来标识你的开发者账号的, 即:
用户id
, 可以在数据库添加索引,方便快速查找,同一个 appId 可以对应多个 appKey+appSecret,达到权限的appKey:公匙(相当于账号)
公开的,调用服务所需要的密钥。是用户的身份认证标识,用于调用平台可用服务.,可以简单理解成是账号。
appSecret:私匙(相当于密码)
签名的密钥,是跟appKey配套使用的,可以简单理解成是密码。
token:令牌(过期则表示当前token已失效,需要重新获取或者刷新)
使用方法
- 向第三方服务器请求授权时,带上
AppKey和AppSecret
(需存在服务器端) - 第三方服务器验证
appKey和appSecret
在数据库、缓存中有没有记录 - 如果有,生成一串唯一的字符串
(token令牌)
,返回给服务器,服务器再返回给客户端 - 后续客户端每次请求都需要带上token令牌
为什么 要有appKey + appSecret 这种成对出现的机制呢,?
- 因为
要加密
,通常用在首次验证(类似登录场景)
, 用appKey(标记要申请的权限有哪些)
+appSecret(密码, 表示你真的拥有这个权限)
来申请一个token, 就是我们经常用到的accessToken(通常拥有失效时间)
, 后续的每次请求都需要提供accessToken 表明验证权限通过。
权限划分
现在有了统一的appId,此时如果针对同一个业务要划分不同的权限,比如同一功能,某些场景需要只读权限,某些场景需要读写权限。这样提供一个appId和对应的秘钥appSecret就没办法满足需求。 此时就需要
根据权限进行账号分配
,通常使用appKey和appSecret。
- 由于
appKey 和 appSecret 是成对出现的账号
,同一个 appId 可以对应多个 appKey+appSecret,
这样平台就为不同的appKey+appSecret对
分配不一样的权限,- 可以生成两对appKey和appSecret。一个用于删除,一个用于读写,达到权限的细粒度划分。如 : appKey1 + appSecect1 只有删除权限 但是 appKey2+appSecret2 有读写权限… 这样你就可以把对应的权限 放给不同的开发者。其中
权限的配置都是直接跟appKey 做关联的
, appKey 也需要添加数据库索引, 方便快速查找
- 可以生成两对appKey和appSecret。一个用于删除,一个用于读写,达到权限的细粒度划分。如 : appKey1 + appSecect1 只有删除权限 但是 appKey2+appSecret2 有读写权限… 这样你就可以把对应的权限 放给不同的开发者。其中
简化的场景:
- 第一种场景:通常用于开放性接口,像地图api,
会省去app_id和app_key
,此时相当于三者相等,合而为一appId = appKey = appSecret,
。这种模式下,带上app_id的目的仅仅是统计某一个用户调用接口的次数而已了。 - 第二种场景: 当每一个用户有且仅有一套权限配置 可以
去掉 appKey,
, 直接将app_id = app_key
, 每个用户分配一个appId+ appSecret
就够了`.
也可以
可以采用签名(signature)的方式: 当调用方向服务提供方法发起请求时,带上(
appKey、时间戳timeStamp、随机数nonce、签名sign
) 签名sign 可以使用(AppSecret + 时间戳 + 随机数)
使用sha1、md5
生成,服务提供方收到后,生成本地签名和收到的签名比对,如果一致,校验成功
一.签名流程
二.签名规则
- 分配
appId(开发者标识)
和appSecret(密钥)
,给不同的调用方
可以直接通过平台线上申请,也可以线下直接颁发。appId是全局唯一的,每个appId将对应一个客户,密钥appSecret需要高度保密。
- 加入
timeStamp
(时间戳),以服务端当前时间为准,单位为ms ,5分钟内数据有效
时间戳的目的就是为了减轻DOS攻击。防止请求被拦截后一直尝试请求接口。服务器端设置时间戳阀值,如果
服务器时间 减 请求时间戳
超过阀值,表示签名超时,接口调用失败。 - 加入临时流水号
nonce
,至少为10位 ,有效期内防重复提交
随机值nonce 主要是为了
增加签名sign的多变性
,也可以保护接口的幂等性
,相邻的两次请求nonce不允许重复,如果重复则认为是重复提交,接口调用失败。- 针对查询接口,流水号只用于日志落地,便于后期日志核查。
- 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求。
通过在接口签名请求参数加上 时间戳timeStamp + 随机数nonce 可以防止 ”重放攻击“
1.时间戳(timeStamp): 以服务端当前时间为准,服务端要求客户端发过来的时间戳,必须是最近60秒内(假设值,自己定义)的。这样,即使这个请求即使被截取了,也只能在60s内进行重放攻击。
2.随机数(nonce): 但是,即使设置了时间戳,攻击者还有60s的攻击时间呢!所以我们需要在客户端请求中再加上一个随机数(中间黑客不可能自己修改随机数,因为有参数签名的校验呢),服务端会对一分钟内请求的随机数进行检查,如果有两个相同的,基本可以判定为重放攻击。因为正常情况下,在短时间内(比如60s)连续生成两个相同nonce的情况几乎为0服务端“第一次”在接收到这个nonce的时候做下面行为:1.去redis中查找是否有key为nonce:{nonce}的数据2.如果没有,则创建这个key,把这个key失效的时间和验证timestamp失效的时间一致,比如是60s。3.如果有,说明这个key在60s内已经被使用了,那么这个请求就可以判断为重放请求。
- 加入签名字段
sign
,获取调用方传递的签名信息。
通过在接口签名请求参数加上 时间戳appId + sign 解决身份验证和防止 ”参数篡改“
1.请求携带参数appId和Sign,只有拥有合法的身份appId和正确的签名Sign才能放行。这样就解决了身份验证和参数篡改问题。
2.即使请求参数被劫持,由于获取不到appSecret(仅作本地加密使用,不参与网络传输),也无法伪造合法的请求。
以上字段放在请求头中。
三.签名的生成
1.签名signature字段生成规则
鉴权参数 = 请求头签名参数(appId,timeStamp,nonce) + 请求URL地址(调用方请求接口完整url地址) + 请求Request参数(针对是Get请求时) + 请求Body(非针对是Get请求时 ,如Post请求)
先将鉴权参数
以key-value的格式存储,并以key值正序排序,进行拼接
如:key1value1key2value2
最后将上面拼接的字符串在拼接
应用密钥appSecret
,如:key1value1key2value2 + appSecret
将最终拼接成的字符串转成
utf-8的字节数组
,后然后做Md5不可逆加密Md5(key1value1key2value2 + appSecret)
得到的字符串作为签名signature
2.请求参数描述
2.1.请求头
请求头="appId=xxxx&nonce=xxxx×tamp=xxxx&sign=xxx"
请求头中的4个参数是必须要传的,否则直接报异常
2.2.请求URL
请求该接口的完整地址
https://mso.xxxx.com.cn/api/user
2.3.请求数据
请求数据的拼接规则
- Path:按照path中的顺序将所有value进行拼接
URL 路径参数指的是通过在 URL 的斜杠后面传递的参数。比如我们要访问 id 为 2 的 project, 则可以访问 /project/2 这个 URL。
- Query:按照key的
字典顺序
排序,将所有key=value
进行拼接
查询字符串参数(query string)和 路径参数类似,你也可以通过查询字符串的形式传递 id。查询字符串就是在 url 中通过 ? 号后面加参数。比如 /project/?id=2 这种形式。
- Form:按照key的
字典顺序
排序,将所有key=value
进行拼接
表示为 表单请求时携带的数据,
- Body
表示是一个raw数据请求(纯字符串格式),比如json的方式传递。
- Json: 按照key的
字典顺序
排序,将所有key=value进行拼接
例如:{“a”:“a”,“c”:“c”,“b”:{“e”:“e”}} =>a=a^_^b={e:e}^_^c=c)
- String: 整个字符串作为一个拼接
- Json: 按照key的
分别对应 SpringMvc提供的获取参数的注解
@RequestParam: 处理(前端)Content-Type为
application/x-www-form-urlencoded
或者form-data
编码的内容@PathVariable: 模板变量,一般用于get请求, 即 XXX/{XXXid}
@RequestBody:常用来处理Content-Type为
application/json, application/xml
码的内容,前端规定的是raw方式
如果存在多种数据形式,同种数据
内按照上面描述的四种规则
进行拼接,拼接好后,不同的数据格式则按照path、query、form、body
的顺序进行二次拼接,得到所有数据最终的拼接值。
四.接口签名的实现
1.实现步骤
基本原理其实也比较简单,就是自定义过滤器或者拦截器,对每个请求进行拦截处理,在服务端取到调用方的参数后按同样的签名规则进行匹配
整体流程如下:
- 验证
请求头
参与签名的必传参数
- 获取
请求头参数,Url请求路径 ,请求数据
,把这些值放入SortMap
中进行排序 - 对
SortMap
里面的值进行拼接
- 对拼接的值进行
加密
,生成签名sign - 把
后台生成的签名sign
和调用方传入的签名sign进行比较
,如果不相同就返回错误
2.实现代码
2.0.需要用到的依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- spring2.X集成redis所需common-pool2,使用jedis必须依赖它--><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.72</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.11</version></dependency><dependency><groupId>commons-codec</groupId><artifactId>commons-codec</artifactId><version>1.14</version></dependency>
2.1.常量类
常量类-声明接口签名需要用的字段名
public class Constant {/*** 应用id*/public static final String APP_ID ="appId";/*** 时间戳,增加链接的有效时间,超过阈值,即失效*/public static final String TIME_STAMP ="timeStamp";/***签名*/public static final String SIGN ="sign";/*** 临时流水号/随机串 ,至少为10位 ,有效期内防重复提交*/public static final String NONCE ="nonce";/*** 请求url*/public static final String REQ_URL ="reqUrl";
}
2.2.签名过滤器
自定义过滤器-拦截每个请求
@Component
@Slf4j
public class SignAuthFilter extends OncePerRequestFilter {//springBoot获取图标路径static final String FAVICON = "/favicon.ico";static final String PREFIX = "attack:signature:";/*** OncePerRequestFilter过滤器保证一次请求只调用一次doFilterInternal方法;如内部的forward不会再多执行一次** @param request* @param response* @param filterChain* @throws ServletException* @throws IOException*/@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {//包装HttpServletRequest对象,缓存body数据,再次读取的时候将缓存的值写出,解决HttpServetRequest读取body只能一次的问题HttpServletRequest requestWrapper = null;if (request instanceof HttpServletRequest) {requestWrapper = new BodyReaderHttpServletRequestWrapper(request);}//打印请求信息//printRequest(requestWrapper);//获取图标不需要验证签名if (StringUtils.equals(FAVICON, request.getRequestURI())) {filterChain.doFilter(request, response);return;}//校验头部参数是否有效boolean isValid = SignUtils.verifyHeaderParams(requestWrapper);if (isValid) {//获取全部参数(包括URL和Body上的)SortedMap<String, String> allParams = HttpUtils.getAllParams(requestWrapper);/*** appSecret需要自己业务去获取,它的作用主要是区分不同客户端app。* 并且利用获取到的appSecret参与到sign签名,保证了客户端的请求签名是由我们后台控制的,* 我们可以为不同的客户端颁发不同的appSecret。*///根据调用传递的appId获取对应的appSecret(应用密钥)String appSecret = getAppSecret(allParams.get(Constant.APP_ID));//appSecret(应用密钥)存在if (StringUtils.isNotEmpty(appSecret)) {//将调用方应用id对应的应用密钥与请求参数合成指定boolean isSigned = SignUtils.verifySignature(allParams, appSecret);if (isSigned) {log.info("签名通过");filterChain.doFilter(request, response);return;}}}log.info("签名参数校验出错");response.setCharacterEncoding("UTF-8");response.setContentType("application/json;charset=utf-8");PrintWriter out = response.getWriter();JSONObject resParam = new JSONObject();resParam.put("msg", "签名参数校验出错");resParam.put("success", "false");out.append(resParam.toJSONString());}/*** 打印请求信息* @param request*/private void printRequest(HttpServletRequest request) {BodyReaderHttpServletRequestWrapper requestWrapper = null;if (request instanceof BodyReaderHttpServletRequestWrapper) {requestWrapper = (BodyReaderHttpServletRequestWrapper) request;}JSONObject requestJ = new JSONObject();JSONObject headers = new JSONObject();Collections.list(request.getHeaderNames()).stream().forEach(name -> headers.put(name, request.getHeader(name)));requestJ.put("headers", headers);requestJ.put("parameters", request.getParameterMap());requestJ.put("body", requestWrapper.getBody());requestJ.put("remote-user", request.getRemoteUser());requestJ.put("remote-addr", request.getRemoteAddr());requestJ.put("remote-host", request.getRemoteHost());requestJ.put("remote-port", request.getRemotePort());requestJ.put("uri", request.getRequestURI());requestJ.put("url", request.getRequestURL());requestJ.put("servlet-path", request.getServletPath());requestJ.put("method", request.getMethod());requestJ.put("query", request.getQueryString());requestJ.put("path-info", request.getPathInfo());requestJ.put("context-path", request.getContextPath());log.info("Request-Info: " + JSON.toJSONString(requestJ, SerializerFeature.PrettyFormat));}/*** 获取appId对应的secret,假数据** @param appId 应用id* @return*/public String getAppSecret(String appId) {Map<String, String> map = new HashMap<>();map.put("zs001", "asd123fhg3b7fgh7dfg");map.put("ls001", "hghfgh123btgfyh1212");return map.get(appId);}
}
上面是一个签名过滤器,其中的appSecret(应用密钥)
需要根据自己的业务去获取。
- 通过密钥可以为
不同的客户端(调用方) 分配 不同的appSecret
,来区分不同客户端app(调用方)。 - 将获取到的appSecret 参与到
sign(签名)
的生成,保证了客户端的请求签名是由我们后台控制的。
2.3.签名工具类
@Slf4j
public class SignUtils {@Autowiredprivate RedisTemplate redisTemplate;/*** 校验Header上的参数-验证是否传入值* <p>* 有个很重要的一点,就是对此请求进行时间验证,如果大于10分钟表示此链接已经超时,防止别人来到这个链接去请求。这个就是防止盗链。** @param request* @return*/public static boolean verifyHeaderParams(HttpServletRequest request) {//应用idString appId = request.getHeader(Constant.APP_ID);if (StringUtils.isEmpty(appId)) {return false;}//时间戳,增加链接的有效时间,超过阈值,即失效String timeStamp = request.getHeader(Constant.TIME_STAMP);if (StringUtils.isEmpty(timeStamp)) {return false;}//调用方传递的签名String signature = request.getHeader(Constant.SIGN);if (StringUtils.isEmpty(signature)) {return false;}// 临时流水号/随机串 ,至少为10位 ,有效期内防重复提交String nonce = request.getHeader(Constant.NONCE);if (StringUtils.isEmpty(nonce)) {return false;}//毫秒long diff = System.currentTimeMillis() - Long.parseLong(timeStamp);//大于10分钟if (diff > 1000 * 60 * 10) {return false;}return true;}/*** 将所有请求参数与应用密钥appSecret进行排序加密后生成签名(signature) 然后与 调用方法传递的签名(signature)进行比较** @param params 根据key升序排序的后所有请求参数* @param appSecret 应用id对应的应用密钥* @return 签名比较结构 true为签名正确*/public static boolean verifySignature(SortedMap<String, String> params, String appSecret) {//调用方传过来的签名String paramSignature = params.get(Constant.SIGN);log.info("调用方传过来的Sign:{}", paramSignature);if (params == null || StringUtils.isEmpty(appSecret)) {return false;}//将调用方的请求参数 与 应用密钥 按签名规则处理后生成的签名String signature = generateSignature(params, appSecret);log.info("后端生成的Sign:{}", signature);//比较调用方传的签名 与 后台生成的签名return StringUtils.isNotEmpty(signature) && StringUtils.equals(paramSignature, signature);}/*** 所有的参数与应用密钥appSecret 进行排序加密后生成签名** @param sortedMap 根据key升序排序的后所有请求参数* @param appSecret 应用id对应的应用密钥* @return 生成接口签名*/public static String generateSignature(SortedMap<String, String> sortedMap, String appSecret) {//先要去掉 前端求参数传过来的 里的 signaturesortedMap.remove(Constant.SIGN);//进行key,value拼接// e.g "key1value1key2value2"StringBuilder plainText = new StringBuilder();for (Map.Entry<String, String> entry : sortedMap.entrySet()) {plainText.append(entry.getKey()+ entry.getValue());}//拼接应用密钥 appSecretplainText.append(appSecret);//摘要String digest = plainText.toString();//将digest 转换成UTF-8 的 byte[] 后 使用MD5算法加密,最后将生成的md5字符串转换成大写try {return DigestUtils.md5Hex(StringUtils.getBytes(digest, "UTF-8")).toUpperCase();} catch (UnsupportedEncodingException e) {e.printStackTrace();}return null;}/*** 上面的流程中,会有个额外的安全处理,防止盗链,我们可以让链接有失效时间* 而利用nonce参数,可以防止重复提交,在签名验证成功后,判断是否重复提交,原理就是结合redis,判断是否已经提交过** @param appId 应用id* @param timeStamp 13位时间戳* @param nonce 临时流水号/随机串 ,至少为10位 ,有效期内防重复提交* @param signature 接口签名* @return 是否重复请求*/public boolean isReplayAttack(String appId, String timeStamp, String nonce, String signature) {StringBuilder redisKey = new StringBuilder();redisKey.append("IS_REPLAY_ATTACK").append(":").append(Constant.APP_ID).append(":").append(appId).append(Constant.TIME_STAMP).append(":").append(timeStamp).append(Constant.NONCE).append(":").append(nonce).append(Constant.SIGN).append(":").append(signature);Object value = redisTemplate.opsForValue().get(redisKey);if (value != null && StringUtils.equals(signature, value.toString()))return false;elseredisTemplate.opsForValue().set(redisKey, signature, 1000 * 50);return false;}
}
- verifyHeaderParams():用于验证调用方是否传入接口签名需要使用的字段值
这个方法有个很重要的一点,就是对此
请求进行时间验证
,如果大于10分钟
表示此链接已经超时
,防止别人来到这个链接去请求。 这个就是防止盗链。 - generateSignature() : 将
所有请求参数 与 应用密钥appSecret
进行排序加密 - verifySignature() : 将
所有请求参数与应用密钥appSecret
进行排序加密后生成签名(sign)
然后与 调用方法传递的签名(sign)
进行比较, 并返回比较结果 - isReplayAttack(): 在签名验证成功后,而利用
nonce参数+ redis的超时机制
,判断是否重复提交(本文没有用到
)
2.4.Http请求工具类
获取全部参数(包括URL和Body上的)
public class HttpUtils {/*** 获取全部参数(包括URL和Body上的)* * @param request* @return*/public static SortedMap<String, String> getAllParams(HttpServletRequest request) throws IOException {SortedMap<String, String> sortedMap = new TreeMap<>();//获取Header上的参数//应用idString appId = request.getHeader(Constant.APP_ID);sortedMap.put(Constant.APP_ID, appId);//时间戳,增加链接的有效时间,超过阈值,即失效String timeStamp = request.getHeader(Constant.TIME_STAMP);sortedMap.put(Constant.TIME_STAMP, timeStamp);//获取调用方的签名String sign = request.getHeader(Constant.SIGN);sortedMap.put(Constant.SIGN, sign);// 临时流水号,防止重复提交String nonce = request.getHeader(Constant.NONCE);sortedMap.put(Constant.NONCE, nonce);//请求路径: 如 http:localhost:8080/signTest/user/infoString url = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getServletPath();sortedMap.put(Constant.REQ_URL, url);//获取parameters(对应@RequestParam)Map<String, String[]> requestParams = null;if (!CollectionUtils.isEmpty(request.getParameterMap())) {requestParams = request.getParameterMap();//获取GET请求参数,以键值对形式保存for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {//{username:[xx],password:[xx]}sortedMap.put(entry.getKey(), entry.getValue()[0]);}}// 分别获取了request inputstream中的body信息、parameter信息//获取body(对应@RequestBody)if (request instanceof BodyReaderHttpServletRequestWrapper) {BodyReaderHttpServletRequestWrapper requestWrapper = (BodyReaderHttpServletRequestWrapper) request;try {JSONObject data = JSONObject.parseObject(requestWrapper.getBody());//获取POST请求的JSON参数,以键值对形式保存for (Map.Entry<String, Object> entry : data.entrySet()) {sortedMap.put(entry.getKey(), entry.getValue().toString());}} catch (JSONException e) {e.printStackTrace();}}return sortedMap;}
}
2.5.请求包装类
如果Filter或者拦截器中实现接口签名,复杂度会大大的降低,且灵活性增加,可以获取原始的http请求与响应
- 但
ServletRequest的输入流InputStream 在默认情况只能读取一次
,要实现多次读取InputStream,需要继承HttpServletRequestWrapper对请求输入流进行缓存
,在Filter替换HttpServletRequest对象。详见上面2.2.签名过滤器
也可以将当前签名方案的实现校验逻辑是在控制层的切面内完成,SpringMVC框架会自动帮我们解析解ServletRequest中的请求数据。
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {private final String body;public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {super(request);StringBuilder stringBuilder = new StringBuilder();BufferedReader bufferedReader = null;try {InputStream inputStream = request.getInputStream();if (inputStream != null) {bufferedReader = new BufferedReader(new InputStreamReader(inputStream));char[] charBuffer = new char[128];int bytesRead = -1;while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {stringBuilder.append(charBuffer, 0, bytesRead);}} else {stringBuilder.append("");}} catch (IOException ex) {throw ex;} finally {if (bufferedReader != null) {try {bufferedReader.close();} catch (IOException ex) {throw ex;}}}body = stringBuilder.toString();}@Overridepublic ServletInputStream getInputStream() throws IOException {final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());ServletInputStream servletInputStream = new ServletInputStream() {@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) { }@Overridepublic int read() throws IOException { return byteArrayInputStream.read(); }};return servletInputStream;}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(this.getInputStream()));}public String getBody() {return this.body;}
}
HttpServetRequest读取body只能一次的问题
五.API接口设计补充建议
1.使用POST作为接口请求方式
一般调用接口最常用的两种方式就是GET和POST。两者的区别也很明显,GET请求会将参数暴露在浏览器URL中,而且对长度也有限制。为了更高的安全性,所有接口都采用POST方式请求。
2.客户端IP白名单
ip白名单是指将接口的访问权限对部分ip进行开放
来避免其他ip进行访问攻击。
- 设置ip白名单缺点就是当你的客户端进行迁移后,就需要重新联系服务提供者添加新的ip白名单。
- 设置ip白名单的方式很多,除了传统的防火墙之外,spring cloud alibaba提供的组件sentinel也支持白名单设置。
- 为了降低api的复杂度,推荐使用防火墙规则进行白名单设置。
3. 单个接口针对ip限流
限流是为了更好的维护系统稳定性。
- 使用
redis
进行接口调用次数统计
,ip+接口地址作为key,访问次数作为value
,每次请求value+1
,设置过期时长来限制接口的调用频率。
4. 记录接口请求日志
记录请求日志,快速定位异常请求位置,排查问题原因。(如:用aop来全局处理接口请求)
5. 敏感数据脱敏
在接口调用过程中,可能会涉及到订单号
等敏感数据,这类数据通常需要脱敏处理
- 最常用的方式就是加密。加密方式使用安全性比较高的
RSA非对称加密。
非对称加密算法有两个密钥
,这两个密钥完全不同但又完全匹配
。只有使用匹配的一对公钥和私钥,才能完成对明文的加密和解密过程。
6.幂等性问题
幂等性是指: 任意多次请求的执行结果和一次请求的执行结果所产生的影响相同
。
- 说的直白一点就是查询操作无论查询多少次都不会影响数据本身,因此查询操作本身就是幂等的。
- 但是新增操作,每执行一次数据库就会发生变化,所以它是非幂等的。
幂等问题的解决有很多思路,这里讲一种比较严谨的。
提供一个生成随机数的接口
,随机数全局唯一。调用接口的时候带入随机数。- 第一次调用,业务处理成功后,将随机数作为key,操作结果作为value,存入redis,同时设置过期时长。
- 第二次调用,查询redis,如果key存在,则证明是重复提交,直接返回错误。
7.版本控制
一套成熟的API文档,一旦发布是不允许随意修改接口的。这时候如果想新增或者修改接口,就需要加入版本控制
,版本号可以是整数类型,也可以是浮点数类型。
- 一般接口地址都会带上版本号,
http://ip:port//v1/list
,http://ip:port//v2/list
8.响应状态码规范
一个牛逼的API,还需要提供简单明了的响应值,根据状态码就可以大概知道问题所在。我们采用http的状态码进行数据封装,例如200表示请求成功,4xx表示客户端错误,5xx表示服务器内部发生错误。
状态码设计参考如下:
public enum CodeEnum {// 根据业务需求进行添加SUCCESS(200,"处理成功"),ERROR_PATH(404,"请求地址错误"),ERROR_SERVER(505,"服务器内部发生错误");private int code;private String message;CodeEnum(int code, String message) {this.code = code;this.message = message;}public int getCode() { return code; }public void setCode(int code) { this.code = code; }public String getMessage() { return message; }public void setMessage(String message) { this.message = message; }
}
9.统一响应数据格式
为了方便给客户端响应,响应数据会包含三个属性,状态码(code),信息描述(message),响应数据(data)
。客户端根据状态码及信息描述可快速知道接口,如果状态码返回成功,再开始处理数据。
public class Result implements Serializable {private static final long serialVersionUID = 793034041048451317L;private int code;private String message;private Object data = null;public int getCode() {return code;}public void setCode(int code) {this.code = code;}public String getMessage() {return message;}public void setMessage(String message) {this.message = message;}public Object getData() {return data;}/*** 放入响应枚举*/public Result fillCode(CodeEnum codeEnum){this.setCode(codeEnum.getCode());this.setMessage(codeEnum.getMessage());return this;}/*** 放入响应码及信息*/public Result fillCode(int code, String message){this.setCode(code);this.setMessage(message);return this;}/*** 处理成功,放入自定义业务数据集合*/public Result fillData(Object data) {this.setCode(CodeEnum.SUCCESS.getCode());this.setMessage(CodeEnum.SUCCESS.getMessage());this.data = data;return this;}
}
10.接口文档
一个好的API还少不了一个优秀的接口文档。接口文档的可读性非常重要,虽然很多程序员都不喜欢写文档,而且不喜欢别人不写文档。为了不增加程序员的压力,推荐使用swagger2或其他接口管理工具,通过简单配置,就可以在开发中测试接口的连通性,上线后也可以生成离线文档用于管理API
11.生成签名sign的详细步骤
结合案例详细说明怎么生成签名signature(写完上面的博客后,得出的感悟
)
第1步
: 将所有参数(注意是所有参数,包括appId,timeStamp,nonce),除去sign本身
,以及值是空的参数,按key名升序排序存储
。第2步
: 然后把排序后的参数按key1value1key2value2…keyXvalueX
的方式拼接成一个字符串。(这里的参数和值必须是传输参数的原始值,不能是经过处理的,如不能将
"
转成”
后再拼接)第3步
: 把分配给调用方的密钥secret
拼接在第2步得到的字符串最后面
。即: key1value1key2value2…keyXvalueX + secret
第4步
: 计算第3步字符串的md5值(32位)
,然后转成大写
,最终得到的字符串作为签名sign
。即: Md5(key1value1key2value2…keyXvalueX + secret) 转大写
举例:
假设传输的数据是
http://www.xxx.com/openApi?sign=sign_value&k1=v1&k2=v2&method=cancel&k3=&kX=vX
请求头是
appId:zs001
timeStamp:1612691221000
sign:2B42AAED20E4B2D5BA389F7C344FE91B
nonce:1234567890
(实际情况最好是通过post方式发送)其中sign参数对应的sign_value就是签名的值。
第一步:拼接字符串。
首先
去除sign参数本身,然后去除值是空的参数k3
,剩下appId=zs001&timeStamp=1612691221000&nonce=1234567890&k1=v1&k2=v2&&method=cancel&kX=vX,然后按参数名字符升序排序
,appId=zs001&k1=v1&k2=v2&kX=vX&method=cancel&nonce=1234567890&timeStamp=1612691221000第二步:将参数名和值的拼接
appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000
第三步:在上面拼接得到的字符串前加上
密钥secret
假设是miyao,得到新的字符串appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000miyao
第四步:然后将这个字符串进行
md5计算
假设得到的是abcdef,然后转为大写,得到ABCDEF这个值作为
签名sign
注意,计算md5之前调用方需确保签名加密字符串编码与提供方一致,如统一使用utf-8编码或者GBK编码,如果编码方式不一致则计算出来的签名会校验失败。
上面说的请求录音可拼可不拼接,主要还是为了增强签名的复杂性
12.1.什么是token?
Token是什么?
token即 访问令牌access token,用于接口中标识接口调用者的身份、凭证,减少用户名和密码的传输次数。 一般情况下客户端(接口调用方)需要先向服务器端申请一个接口调用的账号,服务器会给出一个appId
和一个appSecret(appSecret用于参数签名使用)
,
注意appSecret保存到客户端,需要做一些安全处理,防止泄露。
- Token的值一般是
UUID
,服务端生成Token后需要将token做为key
,将一些和token关联的信息作为value保存到缓存服务器中(redis),当一个请求过来后,服务器就去缓存服务器中查询这个Token是否存在,存在则调用接口,不存在返回接口错误,一般通过拦截器
或者过滤器
来实现。
Token分为两种
API Token(接口令牌)
: 用于访问不需要用户登录的接口,如登录、注册、一些基本数据的获取等。获取接口令牌需要拿appId、timestamp和sign来换,sign=加密(参数1+…+参数n+timestamp+key)USER Token(用户令牌)
: 用于访问需要用户登录之后的接口,如:获取我的基本信息、保存、修改、删除等操作。获取用户令牌需要拿用户名和密码来换
挺好的一篇文章 Java 生鲜电商平台 - API 接口设计之 token、timestamp、sign 具体架构与实现
12.2.Token+签名(有用户状态的接口签名)
上面讲的接口签名方式都是无状态的
,在APP开放API接口的设计中,由于大多数接口涉及到用户的个人信息以及产品的敏感数据,所以要对这些接口进行身份验证
,为了安全起见让用户暴露的明文密码次数越少越好
,然而客户端与服务器的交互在请求之间是无状态的
,也就是说,当涉及到用户状态时
,每次请求都要带上身份验证信息(令牌token
)。
1.Token身份验证
- 用户登录向服务器提供认证信息(如账号和密码),服务器验证成功后
返回Token
给客户端; - 客户端将
Token缓存在本地
,后续每次
发起请求时,都要携带此Token
; - 服务端检查Token的
有效性
,有效则放行,无效(Token错误或过期)则拒绝。
弊端
:Token被劫持,伪造请求和篡改参数。
2.Token+签名验证
- 与上面接口签名规则一样,为
客户端分配appSecret(密钥,用于接口加密,不参与传输)
,将appSecret和所有请求参数组合成一个字符串
,根据签名算法生成签名值,发送请求时将签名值一起发送给服务器验证。这样,即使Token被劫持,对方不知道appSecret和签名算法,就无法伪造请求和篡改参数,并且有了token后也能正确的获取到用户的状态
登陆和退出请求
后续请求
客户端: 与上面接口签名规则一样类似,把appId改为token即可。
六.基于SpringBoot的Aop+自定义注解的方式实现接口签名源码
点击这里跳转过去
七.总结
优点:
- 用接口签名的方式保护开发接口可以做到
防止别人篡改请求,或者模拟请求。
缺点:
- 缺少对数据自身的安全保护,即
请求的参数和返回的数据都
是有可能被别人拦截获取
的,而这些数据又是明文
的,所以只要被拦截,就能获得相应的业务数据
。
添加微信,一起讨论Java、健身、养猫知识,哈哈哈
【JavaWeb】如何优雅的实现第三方开放api接口签名(有状态/无状态)相关推荐
- 银行开放api接口_开放标准API如何彻底改变银行业
银行开放api接口 英国政府已委托英国银行进行可行性研究,以使客户能够通过开放标准API与第三方共享交易数据. 早在12月的秋季声明中首次提到,总理现在就在3月的最新预算中概述了强制性开放银行API标 ...
- 开放api接口平台鉴权怎么做?
我们的接口需要提供给外部第三方系统去调用,那么在做开放接口安全管理的时候先要想明白几点,为什么要做安全,有哪些地方要做安全? 解决方式: (1)优化方式一:数据加密 调用方将调用方身份信息和密码通过明 ...
- 拒绝接口裸奔!开放API接口签名验证!
点击上方蓝色"方志朋",选择"设为星标" 回复"666"获取独家整理的学习资料! 来源:r6d.cn/kChH 接口安全问题 请求身份是否合 ...
- 腾讯会议开放API接口,为企业打造专属的“腾讯会议”
远程办公需求在疫情期间爆发,推动各行各业加速企业内外部协同效率的数字化改造.基于这样的背景,腾讯会议宣布开放API接口,无论是企业IT.系统集成商.SaaS服务商,均可轻松适配多种会议场景需求,同时还 ...
- 开放API接口签名验证,让你的接口从此不再裸奔
点击上方蓝色"终端研发部",选择"设为星标" 学最好的别人,做最好的我们 接口安全问题 请求身份是否合法? 请求参数是否被篡改? 请求是否唯一? AccessK ...
- 开放API接口整合多元办公能力,企业微信助IT企业打造高效办公平台
5月11日,企业微信线下行业交流沙龙--IT行业专场来到北京,产品团队在现场深入解读了企业微信如何更好地帮助IT企业兼顾效率与成本,满足IT企业办公的高效.轻便和移动化. 此次活动,艺龙网及众安保险作 ...
- java调用第三方天气预报API接口
java调用第三方天气预报API接口 package com.sensordata.controller; import com.common.json.JSONObject; import java ...
- 天行数据的开放API接口
天行数据的开放API接口: 接口名称 链接 简介 微信链接转换 https://www.tianapi.com/apiview/89 将临时链接转为永久链接 查询微信全文 https://www.ti ...
- 开放 API 接口安全设计思路
开放API接口安全校验的背景: 在未进行安全处理的开放 API 接口存在诸多的风险问题,如以下三种常见场景: 1.场景一 A 公司开发的开放 API 未对接口进行安全控制,有黑客通过爬虫程序调用开放 ...
- 那些返回一句精美句子的开放api接口
文章目录 前言 ONE一句 用法 响应 前端调用 特点 官网 一言网 用法 响应 前端调用 特点 官网 前言 由于本人需要,需要一个开放的接口返回一句精美句子,后来我在网上还真找到了两个很方便的开放 ...
最新文章
- SAP的安装后基本设定
- python中创建列表[]和list()哪个效率快?为什么快?快多少呢?
- 基于产生式的动物识别专家系统_基于5G的智慧养殖方案--漫途科技
- NOI前总结:点分治
- Web 应用程序的自动化测试
- 我们变成了最小的,当我们发现不了最弱小的时候
- delphi 获取webbrowser文本框id内数值_分布式 ID 生成策略
- mariadb 存储引擎mysql_MySQL/MariaDB---查询缓存与存储引擎
- unlocker解锁虚拟机安装黑苹果出现权限错误问题permission denied
- 关于工信部要求品牌电脑强制预装“绿坝-花季护航”软件
- android 模拟点击屏幕,按键精灵后台简明教程(后台找色,后台鼠标点击等)
- 计算机c盘用户文件夹改英文,windows10下把中文登录用户文件夹名改成英文名的方法...
- c语言关于性别的程序,输入性别并记录男女个数还要算出男女平均年龄的c语言程序怎样写...
- VoLTE网络各节点功能介绍
- IP地址管理(IPAM)解决方案有哪些?
- html中滚动速度怎么调节,html – 图像调整大小导致滚动速度慢
- 24基础指标、macd指标详解、macd指标分析
- 线性回归的基本概念以及正规方程
- HTML5 语音搜索
- 导入 txt 文件数据到 MySQL 表