高级鉴权验签方式的实践,技术方案为注解+ASCII排序+多类型多层级动态拼接+RSA加密(或国密SM2)+Base64+Redis滑动窗口限流

背景

虽然大多数企业的流量没有那么大,不过限流还是要有的,毕竟还有外部调用我方系统接口,需要验证访问权限进行,同时防止万一接口并发量大影响我方系统, 所以要增加流控处理;不同的来源在独立配置,可以做到不同来源的限流

鉴权设计技术方案:采用注解+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过期时间
}

通过缓存或后台配置可以拿到窗口间隔、最大计数,保证在使用过程中可以后台更改限流策略实时生效,动态控制限流

  1. 清除窗口过期成员,zset remove从0到当前窗口开始时间,此时zset中全部是窗口区间的请求数
  2. 获取有效数量进行比较是否溢出
  3. 没有溢出则添加当前时间的记录

好处:

  1. 注解灵活控制哪些接口做鉴权处理,耦合性低,可用性高
  2. BS获取鉴权以及限流配置,实时生效,同时支持多端秘钥配置等分开管理,提高可维护性和相互之间的安全性
  3. ASCII排序+多类型多层级动态拼接:动态组装加密参数,增加复杂度,提高安全性
  4. RSA加密:非对称加密,公钥私钥分开使用,提高安全性,可用SM2替代
  5. 滑动窗口限流:防止限流不均匀,提高限流准确性,提高用户体验

你可能感兴趣的:(【解决方案】,redis,数据库,缓存,鉴权,限流,验签)