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


编程1246 阅2 评

Hi, guys!

写在前面

最近,团队在做一个类似于投票的微信小程序,产品要求没有登录过程,并且每个微信用户只能操作一次;挠头🤔,那只能是静默获取微信的 unionId,标识用户唯一性了,但是光有微信传回的 code,无法拉取到微信用户信息,不满足服务器现有 Access Token 的生成逻辑;继续挠头🤔,那只能是走开放接口了,但是直接裸奔的 write 接口怕被有心者抓包;又挠头🤔,那只能对接口进行加密了,尽可能的防止接口裸奔,所以(事后),就有了这篇文章,提炼下代码,分享给在座的诸位官人。

什么是接口签名

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

这里使用常用的签名方案,涉及的参数有:
appId:标识应用的身份信息,由服务器提供
appSecret:密钥信息,由服务器提供
timestamp:请求时的时间信息,调用者生成
nonce:随机参数,调用者生成
sign:调用者用上面的参数 + 请求的参数进行签名后生成的一串字符,传递给服务器,服务器再用相同的方法进行签名,然后两个签名进行比较,判断数据是否被篡改,是否合法。

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

使用 Spring AOP 实现接口签名校验

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

注解类

ApiSignValid.java

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

}

切面类(核心代码)

ApiSignValidAspect.java

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;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.SortedMap;

/**
 * 切面 - 接口签名校验
 */
@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() { }

    /**
     * 方法运行之前调用 - 校验签名
     * @param joinPoint
     */
    @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();
    }

    /**
     * 校验签名
     * @param app
     * @param signStr
     * @param timestamp
     * @param nonce
     * @param request
     * @return
     */
    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 摘要算法
     * @param str
     * @return
     */
    private String sign(String str) {
        return SecureUtil.sha256(str).toUpperCase();
    }
}

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

// ...
    /**
     * 读取用户
     * @param uid
     * @return
     */
    @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版本)

ApiSignUtil.ts

/**
 * 使用步骤:
 * 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 进行排序
 * @param params
 */
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 }

示例源码

此处内容需要评论回复后(审核通过)方可阅读。

最后更新 2021-09-10
评论 ( 2 )
OωO
隐私评论
  1. 21
    哎呦喂,瞧给你聪明的!
    此条为私密评论,仅评论双方可见
    4个月前回复
  2. 大愣

    拿来吧你!

    8个月前回复