前言
结合我之前写的【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
*/
@Override
protected 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 {
@Autowired
private RedisTemplate redisTemplate;
/**
* 校验Header上的参数-验证是否传入值
* <p>
* 有个很重要的一点,就是对此请求进行时间验证,如果大于10分钟表示此链接已经超时,防止别人来到这个链接去请求。这个就是防止盗链。
*
* @param request
* @return
*/
public static boolean verifyHeaderParams(HttpServletRequest request) {
//应用id
String 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) {
//先要去掉 前端求参数传过来的 里的 signature
sortedMap.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());
}
//拼接应用密钥 appSecret
plainText.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;
else
redisTemplate.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上的参数
//应用id
String 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/info
String 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();
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) { }
@Override
public int read() throws IOException { return byteArrayInputStream.read(); }
};
return servletInputStream;
}
@Override
public 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+自定义注解的方式实现接口签名源码
七.总结
优点:
- 用接口签名的方式保护开发接口可以做到
防止别人篡改请求,或者模拟请求。
缺点:
- 缺少对数据自身的安全保护,即
请求的参数和返回的数据都
是有可能被别人拦截获取
的,而这些数据又是明文
的,所以只要被拦截,就能获得相应的业务数据
。