首发于 算法和技术SHARING
安全优雅的RESTful API签名实现方案

安全优雅的RESTful API签名实现方案

安全优雅的RESTful API签名实现方案

1、接口签名的必要性

在为第三方系统提供接口的时候,肯定要考虑接口数据的安全问题,比如数据是否被篡改,数据是否已经过时,数据是否可以重复提交等问题。其中我认为最终要的还是数据是否被篡改。在此分享一下我的关于接口签名的实践方案。

2、项目中签名方案痛点

3、签名流程



4、签名规则

5、签名生成

5.1、数据部分

如果存在多种数据形式,则按照path、query、form、body的顺序进行再拼接,得到所有数据的拼接值。 上述拼接的值记作 Y。

5.2、请求头部分

X="appid=xxxnonce=xxxtimestamp=xxx"

5.3、生成签名

最终拼接值=XY 最后将最终拼接值按照如下方法进行加密得到签名(signature)。

6、签名算法实现

6.1、指定哪些接口或者哪些实体需要签名

6.2、指定哪些字段需要签名

6.3、签名核心算法(SignatureUtils)

public static String toSplice(Object object) {
    if (Objects.isNull(object)) {
        return StringUtils.EMPTY;
    }
    if (isAnnotated(object.getClass(), Signature.class)) {
        Signature sg = findAnnotation(object.getClass(), Signature.class);
        switch (sg.sort()) {
            case Signature.ALPHA_SORT:
                return alphaSignature(object);
            case Signature.ORDER_SORT:
                return orderSignature(object);
            default:
                return alphaSignature(object);
        }
    }
    return toString(object);
}

private static String alphaSignature(Object object) {
    StringBuilder result = new StringBuilder();
    Map<String, String> map = new TreeMap<>();
    for (Field field : getAllFields(object.getClass())) {
        if (field.isAnnotationPresent(SignatureField.class)) {
            field.setAccessible(true);
            try {
                if (isAnnotated(field.getType(), Signature.class)) {
                    if (!Objects.isNull(field.get(object))) {
                        map.put(field.getName(), toSplice(field.get(object)));
                    }
                } else {
                    SignatureField sgf = field.getAnnotation(SignatureField.class);
                    if (StringUtils.isNotEmpty(sgf.customValue()) || !Objects.isNull(field.get(object))) {
                        map.put(StringUtils.isNotBlank(sgf.customName()) ? sgf.customName() : field.getName()
                                , StringUtils.isNotEmpty(sgf.customValue()) ? sgf.customValue() : toString(field.get(object)));
                    }
                }
            } catch (Exception e) {
                LOGGER.error("签名拼接(alphaSignature)异常", e);
            }
        }
    }

    for (Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator(); iterator.hasNext(); ) {
        Map.Entry<String, String> entry = iterator.next();
        result.append(entry.getKey()).append("=").append(entry.getValue());
        if (iterator.hasNext()) {
            result.append(DELIMETER);
        }
    }
    return result.toString();
}

private static String toString(Object object) {
    Class<?> type = object.getClass();
    if (BeanUtils.isSimpleProperty(type)) {
        return object.toString();
    }
    if (type.isArray()) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < Array.getLength(object); ++i) {
            sb.append(toSplice(Array.get(object, i)));
        }
        return sb.toString();
    }
    if (ClassUtils.isAssignable(Collection.class, type)) {
        StringBuilder sb = new StringBuilder();
        for (Iterator<?> iterator = ((Collection<?>) object).iterator(); iterator.hasNext(); ) {
            sb.append(toSplice(iterator.next()));
            if (iterator.hasNext()) {
                sb.append(DELIMETER);
            }
        }
        return sb.toString();
    }
    if (ClassUtils.isAssignable(Map.class, type)) {
        StringBuilder sb = new StringBuilder();
        for (Iterator<? extends Map.Entry<String, ?>> iterator = ((Map<String, ?>) object).entrySet().iterator(); iterator.hasNext(); ) {
            Map.Entry<String, ?> entry = iterator.next();
            if (Objects.isNull(entry.getValue())) {
                continue;
            }
            sb.append(entry.getKey()).append("=").append(toSplice(entry.getValue()));
            if (iterator.hasNext()) {
                sb.append(DELIMETER);
            }
        }
        return sb.toString();
    }
    return NOT_FOUND;
}

7、签名校验

7.1、header中参数



7.2、签名实体SignatureHeaders, 绑定request中header信息

@ConfigurationProperties(prefix = "wmhopenapi.validate", exceptionIfInvalid = false)
@Signature
public class SignatureHeaders {
    public static final String SIGNATURE_HEADERS_PREFIX = "wmhopenapi-validate";
    public static final Set<String> HEADER_NAME_SET = Sets.newHashSet();
    private static final String HEADER_APPID = SIGNATURE_HEADERS_PREFIX + "-appid";
    private static final String HEADER_TIMESTAMP = SIGNATURE_HEADERS_PREFIX + "-timestamp";
    private static final String HEADER_NONCE = SIGNATURE_HEADERS_PREFIX + "-nonce";
    private static final String HEADER_SIGNATURE = SIGNATURE_HEADERS_PREFIX + "-signature";
    static {
        HEADER_NAME_SET.add(HEADER_APPID);
        HEADER_NAME_SET.add(HEADER_TIMESTAMP);
        HEADER_NAME_SET.add(HEADER_NONCE);
        HEADER_NAME_SET.add(HEADER_SIGNATURE);
    }
    /**
     * 线下分配的值
     * 客户端和服务端各自保存appId对应的appSecret
     */
    @NotBlank(message = "Header中缺少" + HEADER_APPID)
    @SignatureField
    private String appid;
    /**
     * 线下分配的值
     * 客户端和服务端各自保存,与appId对应
     */
    @SignatureField
    private String appsecret;
    /**
     * 时间戳,单位: ms
     */
    @NotBlank(message = "Header中缺少" + HEADER_TIMESTAMP)
    @SignatureField
    private String timestamp;
    /**
     * 流水号【防止重复提交】; (备注:针对查询接口,流水号只用于日志落地,便于后期日志核查; 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求)
     */
    @NotBlank(message = "Header中缺少" + HEADER_NONCE)
    @SignatureField
    private String nonce;
    /**
     * 签名
     */
    @NotBlank(message = "Header中缺少" + HEADER_SIGNATURE)
    private String signature;
}

7.3、根据request中header值生成签名实体SignatureHeaders

private SignatureHeaders generateSignatureHeaders(Signature signature, HttpServletRequest request) throws Exception {
    //处理header name
    Map<String, Object> headerMap = Collections.list(request.getHeaderNames())
            .stream()
            .filter(headerName -> SignatureHeaders.HEADER_NAME_SET.contains(headerName))
            .collect(Collectors.toMap(headerName -> headerName.replaceAll("-", "."), headerName -> request.getHeader(headerName)));
    //将header信息:name=value转换成PropertySource
    PropertySource propertySource = new MapPropertySource("signatureHeaders", headerMap);
    //将header信息绑定到SignatureHeaders对象
    SignatureHeaders signatureHeaders = RelaxedConfigurationBinder.with(SignatureHeaders.class)
            .setPropertySources(propertySource)
            .doBind();
    Optional<String> result = ValidatorUtils.validateResultProcess(signatureHeaders);
    if (result.isPresent()) {
        throw new ServiceException("WMH5000", result.get());
    }
    //从配置中拿到appid对应的appsecret
    String appSecret = limitConstants.getSignatureLimit().get(signatureHeaders.getAppid());
    if (StringUtils.isBlank(appSecret)) {
        LOGGER.error("未找到appId对应的appSecret, appId=" + signatureHeaders.getAppid());
        throw new ServiceException("WMH5002");
    }

    //其他合法性校验
    Long now = System.currentTimeMillis();
    Long requestTimestamp = Long.parseLong(signatureHeaders.getTimestamp());
    if ((now - requestTimestamp) > EXPIRE_TIME) {
        String errMsg = "请求时间超过规定范围时间10分钟, signature=" + signatureHeaders.getSignature();
        LOGGER.error(errMsg);
        throw new ServiceException("WMH5000", errMsg);
    }
    String nonce = signatureHeaders.getNonce();
    if (nonce.length() < 10) {
        String errMsg = "随机串nonce长度最少为10位, nonce=" + nonce;
        LOGGER.error(errMsg);
        throw new ServiceException("WMH5000", errMsg);
    }
    if (!signature.resubmit()) {
        String existNonce = redisCacheService.getString(nonce);
        if (StringUtils.isBlank(existNonce)) {
            redisCacheService.setex(nonce, nonce, (int) TimeUnit.MILLISECONDS.toSeconds(RESUBMIT_DURATION));
        } else {
            String errMsg = "不允许重复请求, nonce=" + nonce;
            LOGGER.error(errMsg);
            throw new ServiceException("WMH5000", errMsg);
        }
    }
   //设置appsecret
    signatureHeaders.setAppsecret(appSecret);
    return signatureHeaders;
}

生成签名前需要如下几个校验步骤。

7.4、生成header信息参数拼接

7.5、切面拦截控制层方法,生成method中参数的拼接

private List<String> generateAllSplice(Method method, Object[] args, String headersToSplice) {
    List<String> pathVariables = Lists.newArrayList(), requestParams = Lists.newArrayList();
    String beanParams = StringUtils.EMPTY;
    for (int i = 0; i < method.getParameterCount(); ++i) {
        MethodParameter mp = new MethodParameter(method, i);
        boolean findSignature = false;
        for (Annotation anno : mp.getParameterAnnotations()) {
            if (anno instanceof PathVariable) {
                if (!Objects.isNull(args[i])) {
                    pathVariables.add(args[i].toString());
                }
                findSignature = true;
            } else if (anno instanceof RequestParam) {
                RequestParam rp = (RequestParam) anno;
                String name = mp.getParameterName();
                if (StringUtils.isNotBlank(rp.name())) {
                    name = rp.name();
                }
                if (!Objects.isNull(args[i])) {
                    List<String> values = Lists.newArrayList();
                    if (args[i].getClass().isArray()) {
                        //数组
                        for (int j = 0; j < Array.getLength(args[i]); ++j) {
                            values.add(Array.get(args[i], j).toString());
                        }
                    } else if (ClassUtils.isAssignable(Collection.class, args[i].getClass())) {
                        //集合
                        for (Object o : (Collection<?>) args[i]) {
                            values.add(o.toString());
                        }
                    } else {
                        //单个值
                        values.add(args[i].toString());
                    }
                    values.sort(Comparator.naturalOrder());
                    requestParams.add(name + "=" + StringUtils.join(values));
                }
                findSignature = true;
            } else if (anno instanceof RequestBody || anno instanceof ModelAttribute) {
                beanParams = SignatureUtils.toSplice(args[i]);
                findSignature = true;
            }

            if (findSignature) {
                break;
            }
        }
        if (!findSignature) {
            LOGGER.info(String.format("签名未识别的注解, method=%s, parameter=%s, annotations=%s", method.getName(), mp.getParameterName(), StringUtils.join(mp.getMethodAnnotations())));
        }
    }
    List<String> toSplices = Lists.newArrayList();
    toSplices.add(headersToSplice);
    toSplices.addAll(pathVariables);
    requestParams.sort(Comparator.naturalOrder());
    toSplices.addAll(requestParams);
    toSplices.add(beanParams);
    return toSplices;
}

generateAllSplice方法是在控制层切面内执行,可以在方法执行之前获取到已经绑定好的入参。分别对注有@PathVariable、@RequestParam、@RequestBody、@ModelAttribute注解的参数进行参数拼接的处理。其中注@RequestParam注解的参数需要特殊处理一下,分别考虑数组、集合、原始类型这三种情况。

7.6、对最终的拼接结果重新生成签名信息

8、客户端使用示例

8.1、生成签名

8.2、输出结果

9、思考

上述的签名方案的实现校验逻辑是在控制层的切面内完成的。如果项目用的是springmvc框架,可以放在Filter或者拦截器里吗?很明显是不行的(因为ServletRequest的输入流InputStream 在默认情况只能读取一次)。上述方案需要获取绑定后的参数结果,然后执行签名校验逻辑。在执行控制层方法之前,springmvc已经帮我们完成了绑定的步骤,当然了,在绑定的过程中会解析ServletRequest中参数信息(例如path参数、parameter参数、body参数)。

其实如果我们能在Filter或者拦截器中实现上述方案,那么复杂度将会大大的降低。首先考虑如何让ServletRequest的输入流InputStream可以多次读取,然后通过ServletRequest获取path variable(对应@PathVariable)、parameters(对应@RequestParam)、body(对应@RequestBody)参数,最后整体按照规则进行拼接并生成签名。

另一种实现方案参考: cnblogs.com/hujunzheng/

聚圣源三点水一个者念什么陈素怡贪生怕死打一生肖60坪中国起名软件理财可靠的平台梦见逃跑毕淑敏女孩起洋气的小名一点的の是什么意思女楚辞男诗经取名起名一品仵作金钢网纱窗起名给书店起个什么名字腾讯会员最多可以几个人一起登录美国50个州均出现确诊病例北京麻辣香锅李倩电视剧弘宗取名起名给宝宝起个好听的名字姓马给宝宝起人名适合双胞胎起名字的四字成语电脑屏幕分辨率叶晨萧初然小说全文免费阅读八字起名大全男孩趔趄是什么意思鬼接琴行起什么名称好商贸公司起名有寓意大唐第一庄淀粉肠小王子日销售额涨超10倍罗斯否认插足凯特王妃婚姻让美丽中国“从细节出发”清明节放假3天调休1天男孩疑遭霸凌 家长讨说法被踢出群国产伟哥去年销售近13亿网友建议重庆地铁不准乘客携带菜筐雅江山火三名扑火人员牺牲系谣言代拍被何赛飞拿着魔杖追着打月嫂回应掌掴婴儿是在赶虫子山西高速一大巴发生事故 已致13死高中生被打伤下体休学 邯郸通报李梦为奥运任务婉拒WNBA邀请19岁小伙救下5人后溺亡 多方发声王树国3次鞠躬告别西交大师生单亲妈妈陷入热恋 14岁儿子报警315晚会后胖东来又人满为患了倪萍分享减重40斤方法王楚钦登顶三项第一今日春分两大学生合买彩票中奖一人不认账张家界的山上“长”满了韩国人?周杰伦一审败诉网易房客欠租失踪 房东直发愁男子持台球杆殴打2名女店员被抓男子被猫抓伤后确诊“猫抓病”“重生之我在北大当嫡校长”槽头肉企业被曝光前生意红火男孩8年未见母亲被告知被遗忘恒大被罚41.75亿到底怎么缴网友洛杉矶偶遇贾玲杨倩无缘巴黎奥运张立群任西安交通大学校长黑马情侣提车了西双版纳热带植物园回应蜉蝣大爆发妈妈回应孩子在校撞护栏坠楼考生莫言也上北大硕士复试名单了韩国首次吊销离岗医生执照奥巴马现身唐宁街 黑色着装引猜测沈阳一轿车冲入人行道致3死2伤阿根廷将发行1万与2万面值的纸币外国人感慨凌晨的中国很安全男子被流浪猫绊倒 投喂者赔24万手机成瘾是影响睡眠质量重要因素春分“立蛋”成功率更高?胖东来员工每周单休无小长假“开封王婆”爆火:促成四五十对专家建议不必谈骨泥色变浙江一高校内汽车冲撞行人 多人受伤许家印被限制高消费

聚圣源 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化