基于Spring AOP 的开放接口签名机制 | 保护接口安全

28

在 Web 应用程序中,接口安全是至关重要的。一个普遍的做法是使用 API 密钥或 token 进行身份验证和授权,以确保只有授权用户才能访问接口。然而,这些方法并不足以保证接口的完整性和安全性。接口签名是一种更加安全的方法,可以防止未经授权的访问,同时还可以防止 API 请求被篡改。

在本文中,我们将介绍如何使用 Spring 的 AOP 实现接口签名,保护接口安全。

什么是接口签名

接口签名是一种验证 API 请求完整性和防止篡改的方法。在每个请求中,客户端使用一个密钥和一组参数生成一个签名。服务端使用同样的密钥和参数,生成一个自己的签名。如果两个签名不一致,则说明请求被篡改,应该被拒绝。

接口签名通常使用哈希算法生成签名,如 SHA-256。由于哈希算法是不可逆的,因此攻击者无法通过已知的签名来计算出原始数据,从而保证数据的安全性。

我们在对接第三方系统时,像微信、支付宝的接口在调用时,都要求调用者先用提供的AppId和Secret对数据进行签名,然后把签名结果一并附带到请求中,发送过去,这样做的目的,是保证请求来源的合法性,防止接口被篡改。
这里使用常用的签名方案,涉及的参数有:

  • appId:标识应用的身份信息,由服务器提供

  • appSecret:密钥信息,由服务器提供

  • timestamp:请求时的时间信息,调用者生成

  • nonce:随机参数,调用者生成

  • sign:调用者用上面的参数 + 请求的参数进行签名后生成的一串字符,传递给服务器,服务器再用相同的方法进行签名,然后两个签名进行比较,判断数据是否被篡改,是否合法。

关于 sign 的签名规则:
将本次请求的所有参数(URL上,Body中)提取出来,加上 appIdtimestampnonce,按照字母顺序(ASCII)进行排序,逐个按照固定格式([key1][value1][key2][value2]...[appSecret])拼接成字符串,再将字符串使用算法(SHA256或MD5等)进行加密,生成最终的 sign

实践

下面只贴出关键代码,示例源码可在文章结尾处获取。

注解

/**
 * 注解 - 接口签名校验
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignValid {

}

切面

签名校验的核心逻辑,只实现最基础的校验规则,实际情况应当根据业务需求加强安全逻辑。

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import com.suimz.example.api.signature.sign.core.ApiSignNonceStrCache;
import com.suimz.example.api.signature.sign.core.ApiSignProperties;
import com.suimz.example.api.signature.sign.core.ApiSignValidException;
import com.suimz.example.api.signature.sign.filter.ApiSignRequestWrapper;
import com.suimz.example.api.signature.sign.util.HttpRequestUtil;

/**
 * 切面 - 接口签名校验
 */
@Aspect
@Component
public class ApiSignValidAspect {

    private final Logger log = LoggerFactory.getLogger(ApiSignValidAspect.class);

    @Autowired
    private ApiSignProperties properties;

    /**
     * 切入点
     */
    @Pointcut("@annotation(com.suimz.example.api.signature.sign.aop.ApiSignValid)")
    public void pointCut() { }

    /**
     * 方法运行之前调用 - 校验签名
     */
    @Before("pointCut()")
    public void before(JoinPoint joinPoint) throws ApiSignValidException {
        try {
            HttpServletRequest request = getHttpRequest();
            // 从header取出签名相关参数
            String appIdStr = request.getHeader(properties.getHeaderAppId());
            String sign = request.getHeader(properties.getHeaderSign());
            String nonce = request.getHeader(properties.getHeaderNonce());
            String timestampStr = request.getHeader(properties.getHeaderTimestamp());
            log.info("【签名校验】 appId:{}, nonce:{}, timestamp:{}, sign:{}", appIdStr, nonce, timestampStr, sign);

            // 校验参数
            if (ObjectUtil.isEmpty(appIdStr)) throw new Exception("invalid appId");
            if (ObjectUtil.isEmpty(timestampStr) || ObjectUtil.isEmpty(nonce) || nonce.length() != properties.getNonceLen() || ObjectUtil.isEmpty(sign)) throw new ApiSignValidException("illegal parameter");
            ApiSignProperties.App app = properties.getAppById(Integer.valueOf(appIdStr));
            if (app == null) throw new ApiSignValidException("invalid appId");

            // 判断过期失效 - 防止重放攻击,可以判断传入的时间戳大于服务器时间,直接拒绝
            long timestamp = Long.valueOf(timestampStr);
            long now = System.currentTimeMillis() / 1000;
            if (now - timestamp > properties.getExpireTime()) {
                throw new ApiSignValidException("expire time");
            }

            // 判断随机字符串
            ApiSignNonceStrCache nonceStrCache = ApiSignNonceStrCache.getInstance(properties.getExpireTime());
            if (nonceStrCache.isExist(app.getId(), nonce)) throw new Exception();
            nonceStrCache.put(app.getId(), nonce); // 缓存随机字符串

            // 签名校验
            boolean isSigned = verifySign(app, sign, timestamp, nonce, request);
            if (!isSigned) throw new Exception();
        } catch (ApiSignValidException e) {
            log.error(e.getMessage(), e);
            throw e;
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new ApiSignValidException("signature check failure");
        }
    }

    private HttpServletRequest getHttpRequest() {
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return servletRequestAttributes.getRequest();
    }

    /**
     * 校验签名
     */
    private boolean verifySign(ApiSignProperties.App app, String signStr, long timestamp, String nonce, HttpServletRequest request) {
        try {
            ApiSignRequestWrapper requestWrapper = new ApiSignRequestWrapper(request);
            // 获取全部参数(包括 URL 和 body 上的),默认按ASCII对Key进行排序
            SortedMap<String, String> allParams = HttpRequestUtil.getAllParams(requestWrapper);
            // 加入签名信息
            allParams.put("appId", app.getStrId());
            allParams.put("timestamp", String.valueOf(timestamp));
            allParams.put("nonce", nonce);
            // 将参数转为字符串,格式:[key1][value1][key2][value2]...[secret]
            StringBuilder sbd = new StringBuilder("");
            for (Map.Entry<String, String> entry : allParams.entrySet()) {
                // 排除空val的参数
                if (ObjectUtil.isEmpty(entry.getValue())) continue;
                sbd.append(entry.getKey()).append(entry.getValue());
            }
            String params = sbd.append(app.getSecret()).toString();

            // 服务器对参数进行签名
            String sign = sign(params);
            // 前端传过来的sign作比较
            return sign.equals(signStr);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
        return false;
    }

    /**
     * 签名 - 当前使用的 SHA256 摘要算法
     */
    private String sign(String str) {
        return SecureUtil.sha256(str).toUpperCase();
    }
}

使用

在需要签名校验的接口上添加注解:

	// ...

    /**
     * 读取用户
     */
    @ApiSignValid
    @GetMapping("/{uid}")
    public ApiResult get(@PathVariable Integer uid) {
        User user = users.get(uid);
        if (user == null) return ApiResult.fail("404", "not found user");
        return ApiResult.success(user);
    }

	// ...

客户端

提供一个前端对接口进行签名的工具类(TS版本):

typescript    89行

/**
 * 使用步骤:
 * 1. 安装依赖: npm i crypto-js
 * 2. 引入工具: import { sign } from '../ApiSignUtil';
 * 3. 签名参数: const signResult = sign(appId, appSecret, {...参数对象});
 * 4. 打印结果:console.log('签名数据', signResult);
 */
import sha256 from 'crypto-js/sha256'

/**
 * 签名结果
 */
type SignResult = {
  sign: string
  timestamp: number
  nonce: string
}

/**
 * 对参数进行签名
 * @param appId
 * @param appSecret
 * @param params
 */
const sign = (appId: string, appSecret: string, params: any): SignResult => {
  // 当前秒级时间戳
  const timestamp = getSecondTimestamp();
  // 18位随机字符串
  const nonce = randomNonce();
  // 追加签名参数
  params.appId = appId;
  params.timestamp = timestamp;
  params.nonce = nonce;
  // 对参数按ASCII进行排序
  const sortParams = sortParamsAscii(params);
  // 将参数组装成字符串,剔除空value的参数
  let paramsStr = '';
  for (let key in sortParams) {
    const val = sortParams[key];
    if (val) paramsStr += `${key}${val}`;
  }
  // 将secret拼接到最后
  paramsStr += appSecret;
  // 签名
  const sign = sha256(paramsStr).toString().toUpperCase();
  return {
    sign: sign,
    timestamp: timestamp,
    nonce: nonce
  }
}

/**
 * 对参数按 ASCII 进行排序
 */
const sortParamsAscii = (params: Object): Object => {
  const arr = new Array();
  let num = 0;
  for (let i in params) {
    arr[num] = i;
    num++;
  }
  const sortArr = arr.sort();
  const sortParams = {};
  for (let i in sortArr) {
    sortParams[sortArr[i]] = params[sortArr[i]];
  }
  return sortParams;
}

/**
 * 获取当前秒级时间戳
 */
const getSecondTimestamp = (): number => {
  return parseInt(String(new Date().getTime() / 1000), 10);
}

/**
 * 生成随机字符串
 * @param len,默认生成18位长度
 */
const randomNonce = (len: number = 18): string => {
  const str = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  let result = '';
  for (let i = len; i > 0; --i) result += str[Math.floor(Math.random() * str.length)];
  return result;
}

export { sign }

示例源码

获取源码