背景
虽然大多数企业的流量没有那么大,不过限流还是要有的,毕竟还有外部调用我方系统接口,需要验证访问权限进行,同时防止万一接口并发量大影响我方系统, 所以要增加流控处理;不同的来源在独立配置,可以做到不同来源的限流
鉴权设计技术方案:采用注解+ASCII排序+多类型多层级动态拼接+RSA加密(或国密SM2)+一次Base64转码
限流设计:采用Redis的zset滑动窗口限流的方式
建议用国密,SM2比RSA的效率要高,
话不多说,先说方式,后说好处
鉴权设计
定义好一个注解AuthSign,注解中有字段sign
注解处理具体如下
@Around("@annotation(authSign)")
public Object around(ProceedingJoinPoint point, AuthSign authSign) throws Throwable {
//获取参数
Object[] args = point.getArgs();
if (args == null || args.length <= 0) {
throw new ParameterException("参数为空");
}
Map<String, Object> argsMap = new HashMap<>();
for (Object obj : args) {
//将obj转为map,不转下划线,去空
argsMap = BeanUtil.beanToMap(obj, false, true);
break;
}
//获取配置,这个配置可以配置到缓存中
Map<String, String> map = checkAndGetBsConfig(argsMap);
//是否鉴权,默认鉴权
String authFlag = map.getOrDefault(AUTH_FLAG, "true");
if (StrUtil.equals(authFlag, "false")) {
log.warn("此请求不做鉴权");
return point.proceed();
}
//是否限流,默认不限流
if (StrUtil.equals(map.getOrDefault(LIMIT_FLAG, "false"), "true")) {
//限流,systemNo作为key,划分不同来源的限流
limitValidation(map);
}
String sign = null;
//从注解中取签名,注解没有从参数中取
if (StrUtil.isEmpty(authSign.sign())) {
if (argsMap.get(SIGN) != null) {
sign = argsMap.get(SIGN).toString();
}else {
throw new BusinessException("签名必传");
}
} else {
sign = parseExpression(authSign.sign(), args, point);
}
//排除不参与签名的字段,注意:BaseRequest中若添加非前端传入参数需要在此排除!具体排除啥你说的算
RSAUtil.execludeField(argsMap);
//ASCII排序后拼接一个string,此处就是这个所有动态参数的map经过处理后生成的
String sortASCIIStr = SortSignParamUtil.getSortASCIIStr(argsMap);
//签名并验证
boolean verify = RSAUtil.verify(sortASCIIStr, sign, map.get(PUBLIC_KEY));
if (!verify) {
throw new BusinessException("签名错误");
}
Object proceed = point.proceed();
return proceed;
}
拿到参数后转为Map(去空处理)
checkAndGetBsConfig方法:验证参数并去bs拿到相关配置参数,有鉴权和限流开关
getSortASCIIStr方法:去掉空字段,只对第一层ASCII排序,通过key=value&形式进行拼接,数组以及List内部使用#拼接,对于每个List或对象内部仍然有List或对象的情况做递归处理;具体如下
/**
* 去掉空字段,ASCII排序
* 字段支持对象,list,不支持Map(Map用对象表示)
* 外层排序,内部对象和list不排序
*/
public static String getSortASCIIStr(Map<String, Object> map) throws IllegalAccessException {
// 对所有传入参数按照字段名的 ASCII 码从小到大排序(字典序)
List<Map.Entry<String, Object>> infoIds = new ArrayList<Map.Entry<String, Object>>(map.entrySet());
Collections.sort(infoIds, new Comparator<Map.Entry<String, Object>>() {
@Override
public int compare(Map.Entry<String, Object> o1, Map.Entry<String, Object> o2) {
return (o1.getKey()).compareTo(o2.getKey());
}
});
// 构造签名键值对的格式
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> item : infoIds) {
if (item.getKey() != null || item.getKey() != "") {
String key = item.getKey();
Object val = item.getValue();
if (!(val == "" || val == null)) {
if (val instanceof Map) {
continue;
}
//对象
if (BeanUtil.isBean(val.getClass())) {
StringBuilder objAppend = objAppend(val);
sb.append(key + "=" + objAppend.toString() + "&");
continue;
}
//判断list,不支持Map,如果以Map形式直接用对象表示
if (val instanceof List) {
List<Object> list = (List<Object>) val;
StringBuilder listAppend = listAppend(list);
sb.append(key + "=" + listAppend.toString() + "&");
continue;
}
//数组 直接拼接
if (ArrayUtil.isArray(val)) {
//数组 #直接拼接
StringBuilder sArray = new StringBuilder();
Object[] objects = (Object[]) val;
for (Object os : objects) {
sArray.append(os + "#");
}
sb.append(key + "=" + sArray.toString() + "&");
continue;
}
//普通字段
sb.append(key + "=" + val + "&");
}
}
}
return sb.delete(sb.length() - 1, sb.length()).toString();
}
/**
* 对象组装
*/
private static StringBuilder objAppend(Object obj) throws IllegalAccessException {
StringBuilder sb = new StringBuilder();
Field[] declaredFields = obj.getClass().getDeclaredFields();
for (Field field : declaredFields) {
field.setAccessible(true);
Object o = field.get(obj);
if (o != null) {
//对象中还有list和对象
if (BeanUtil.isBean(o.getClass())) {
sb.append(objAppend(o));
continue;
}
if (o instanceof List) {
sb.append(listAppend((List) o));
continue;
}
if (ArrayUtil.isArray(o)) {
//数组 #直接拼接
Object[] objects = (Object[]) o;
for (Object os : objects) {
sb.append(os + "#");
}
continue;
}
//对象内字段使用#拼接
sb.append(field.getName() + "=" + o + "#");
}
}
return sb;
}
/**
* list组装
* 数组,对象
*/
private static StringBuilder listAppend(List list) throws IllegalAccessException {
StringBuilder s = new StringBuilder();
for (Object obj : list) {
if (obj != null) {
if (BeanUtil.isBean(obj.getClass())) {
//每一个对象
s.append(objAppend(obj));
continue;
}
//数组
s.append(obj + "#");
}
}
return s;
}
支持List,数组,对象,以及普通字段的处理;不支持Map(用对象表示),这里比较麻烦的就是参数如果是多层的情况,大家可以研究一下有没有更好的处理
限流设计
单位时间内允许的请求数:采用Redis的zset滑动窗口限流的方式,具体设计如下
/**
* 先根据时间滑动清除过期成员
* 判断key的value中的有效访问次数是否超过最大限定值maxCount,若没超过,调用increment方法,将窗口内的访问数加一
* 判断与数量增长同步处理
*
* @param key redis key
* @param windowInSecond 窗口间隔,秒
* @param maxCount 最大计数
* @return 可访问 or 不可访问
*/
public boolean canAccess(String key, int windowInSecond, long maxCount) {
key = SLIDING_WINDOW + key;
long currentMs = System.currentTimeMillis();
// 窗口开始时间
long windowStartMs = currentMs - windowInSecond * 1000;
// 清除窗口过期成员
Long aLong = cacheManager.zsetRemoveRangeByScore(NSP, key, 0, windowStartMs);
//按key统计集合中的有效数量
Long count = cacheManager.zsetZCard(NSP, key);
if (count < maxCount) {
increment(key, currentMs);
return true;
} else {
log.warn("滑动窗口流控:key:{}, count:{}", key, count);
return false;
}
}
/**
* 滑动窗口计数增长
*
* @param key redis key
*/
public void increment(String key, long currentMs) {
// 单例模式(提升性能)
// 添加当前时间 value=当前时间戳 score=当前时间戳
cacheManager.zsetAdd(NSP, key, String.valueOf(currentMs), currentMs, 300);
// 设置key过期时间
}
通过缓存或后台配置可以拿到窗口间隔、最大计数,保证在使用过程中可以后台更改限流策略实时生效,动态控制限流
- 清除窗口过期成员,zset remove从0到当前窗口开始时间,此时zset中全部是窗口区间的请求数
- 获取有效数量进行比较是否溢出
- 没有溢出则添加当前时间的记录
好处:
- 注解灵活控制哪些接口做鉴权处理,耦合性低,可用性高
- BS获取鉴权以及限流配置,实时生效,同时支持多端秘钥配置等分开管理,提高可维护性和相互之间的安全性
- ASCII排序+多类型多层级动态拼接:动态组装加密参数,增加复杂度,提高安全性
- RSA加密:非对称加密,公钥私钥分开使用,提高安全性,可用SM2替代
- 滑动窗口限流:防止限流不均匀,提高限流准确性,提高用户体验