你的接口真的安全吗?

时间:2022-07-25
本文章向大家介绍你的接口真的安全吗?,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

Restful接口安全重要性

现在互联网开发的框架越来越丰富,大多数的系统架构也都是朝着微服务化,云原生化演进。而且自从中台的概念提出之后,各大公司也开始拆分自己的技术中台,抽离出一些公共的技术服务中台。

这使得我们服务开发也越来越容易,较少的精力放在框架层处理,更多关注业务逻辑的实现。现在系统大都是异构化,服务之间通过 Http 或者 Rpc 进行远程调用。当我们的后台接口暴露给前端或者移动 app 端时就要考虑接口的安全性。

例如,现在的 app 登陆或者重置密码接口都需要发送短信验证码,如果我们的发送接口被他人利用,不仅容易出现短信盗刷,还有可能影响我们正常的服务使用。所以我们在设计接口时可以从以下几个方面加上接口安全性相关的设计。

签名校验

  • 生成 appId 和 appKey

首先,我们可以为每个请求调用方生成固定长度的随机字符串 appId 和 appKey,可以使用 RandomUtil 方法。

“Talk is cheap,show me your code.”

当然有很多其他的随机字符串生成方法,比如用机器 id + timestamp 时间戳方式,这里只是简单的列举一种随机方法。

public static void main(String[] args) {
  String appId = RandomUtil.randomString(20);
  String chars = "$&@?<>~!%#";
  StringBuilder appKeyTemp = new StringBuilder(RandomUtil.randomString(20));
  for(int i=0;i<chars.length();i++){
    int index = new Random().nextInt(chars.length());
    appKeyTemp.insert(index, chars.charAt(index));
  }
  System.out.println("appId:"+appId+" appKey:"+appKeyTemp.toString());
}
appId:tow9e8piyuj23w5e46yj appKey:$$$8?ze><<>zuq%8ba#dqmr8dwx1jy

生成 appId 和 appKey 之后可以将其放在配置文件或者配置中心,通过 @Value 注解获取变量值,在接口调用时进行校验。

@Component
public class EncryptUtils{
  @Value("${appId}")
  String appId;
  @Value("${appKey}")
  String appKey;
  /**
   * 判断 appId 和 appKey 是否校验
   *
   * @param appId app id.
   * @param appKey app key.
   * @return result.
   */
   public boolean appIdMatch(String appId, String appKey) {
     return this.appId.equals(appId) && this.appKey.equals(appKey);
   }
}
  • 接口签名校验 sign

但是以上生成的 appId 和 appKey 还是放在请求参数或者请求包头,通过代理抓包还是能够拿到这两个参数。所以,为了安全我们还必须对请求调用方做签名验证。生成签名的方式主要是对称加密,这里展示了 AES256 加密方法。

我们可以将 appId_appKey_timestamp 作为字符串,用 AES/ECB/PKCS5Padding 算法填充,AES256 算法加密,由于 AES 是对称加密,所以只需要根据请求参数再做一次加密,然后比较加密之后的密文和请求的 sign 是否一致即可以达到校验接口请求的目的。

加密需要前后端约定密钥,AES 加密密钥长度为 16 位,这点需要注意。加密方法如下

/**
 *
 * @param content 加密的字符串
 * @param encryptKey key 值
 * @return result.
 * @throws Exception IllegalBlockSizeException
 */
 private String encryptAppId(String content, String encryptKey) throws Exception {
   KeyGenerator kgen = KeyGenerator.getInstance("AES");
   kgen.init(256);
   Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
   cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptKey.getBytes(), "AES"));
   byte[] b = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
   // base64 进行转码
   return Base64.encodeBase64String(b);
 }
/**
 * 对 appId 生成签名
 * @param appId app id.
 * @param appKey app key.
 * @param timestamp 时间戳
 * @param encryptKey 加密密钥串
 * @return result.
 */
 public String signAppId(String appId, String appKey, Long timestamp, String encryptKey) throws Exception {
   return this.encryptAppId(appId + "_" + appKey + "_" + timestamp, encryptKey);
 }
 public static void main(String[] args){
   String appId = "tow9e8piyuj23w5e46yj";
   String appKey = "$$$8?ze><<>zuq%8ba#dqmr8dwx1jy";
   Long timestamp = 1600143987258;
   String encryptKey = "vcko6mqa2oucmuva";
   System.out.println("sign: "signAppId(appId,appKey,timestamp,encryptKey))
 }
sign: Uw6yqZKmHZPPMPmDDKNiNXNOHM8RbVJ/aeI/og4Ex0wK48wKxxitjE0Gh3AabQZrU3JwqWRb6Wr8+ZaHKYgHrQ==

当调用方调用时传入 sign 和 timestamp 字段,这样我们在接口调用时可以 sign 字段进行签名校验。在攻击方不知道加密密钥时,基本上很难伪造签名信息。接口校验是每个接口都需要验证的,所以我们可以定义公共切面方法 对所有接口进行拦截并校验。

  • 接口切面拦截器

首先,引入 maven 包

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

定义切面

/**
 * 定义切点
 */
@Pointcut("execution(public * com.test.controller..*(..))")
public void appIdSign() {
}
/**
 * 执行校验方法
 *
 * @param joinPoint 织入点
 * @throws Exception 加密异常
 */
 @Before("appIdSign()")
 public void doVilaSign(JoinPoint joinPoint) throws Exception {
   HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
   // 获取签名
   String appId = request.getHeader("appId");
   String appKey = request.getHeader("appKey");
   String sign = request.getHeader("sign");
   String timestamp = request.getHeader("timestamp");
   logger.debug("appId:[{}],appKey:[{}],sign:[{}],timestamp:[{}]",appId, appKey, sign, timestamp);
   if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(appKey) || StringUtils.isEmpty(sign)|| StringUtils.isEmpty(timestamp) || !encryptUtils.appIdMatch(appId, appKey)) {
     throw new Exception("签名校验失败");
   }
   String signValid = encryptUtils.signAppId(timestamp);
   if (!sign.equals(signValid)) {
     throw new Exception("签名校验失败");
   }
}

以上,可以做到对 controller 包下的所有接口方法拦截,并获取请求头参数,通过请求参数生成 sign 签名信息并校验。

虽然以上可以做到签名校验,但是,当别人抓包拿到请求头和请求参数,再重复调用我们的接口,还是会有安全问题。所以还需要对接口防重放做拦截。

接口防重

  • 基于时间戳

一般调用方到后台的接口时间都远小于 30s(正常网络情况)。同时,攻击者抓包并伪造请求时间一般在 30s 之上。在之前我们约定了请求头信息包含 timestamp,所以我们可以限制超过当前时间 30s 的请求。

long now = System.currentTimeMillis();
//接口防重防,30s 有效
if ((Long.parseLong(timestamp)) + 30 * 1000 < now) {
  throw new Exception("签名校验失败");
}
  • 基于时间戳+随机数

以上时间限制还是给攻击者留了 30s 的伪造时间,为了绝对安全,常用的防止重放的机制是使用 timestamp 和 nonce 来做的重放机制。

nonce 是客户端调用方生成的随机数,在调用端将 timestamp + nonce 作为 key 存储在 redis 中,当客户端第一次调用时先去 redis 查询是否有该 key 信息,如果没有则缓存并设置过期时间 (30s),如果有则拒绝该请求(非法重复请求)。

https 加密

当然,最基础的安全加密是接口采用 https 协议

  • http 和 https 的区别

https 可以视为 http+SSL 安全套接层,https 协议需要到 ca 申请证书,客户端和服务端握手流程大致如下:

the TLS Handshake

图片来源:https://www.cloudflare.com/learning/ssl/what-happens-in-a-tls-handshake/

在传统的 HTTP 三次握手之上增加了 SSL 加密认证的过程,保证了数据传输的安全性。所以条件允许的情况下,尽量采用 https 接口调用。

最后

“魔高一尺 道高一丈”

安全无小事,凡事都没有万能之法,所以我们在接口设计时需要严谨考虑安全性,尽可能做到加密,验签,防篡改。