Web请求体数字签名(JS加签、Java验签)

前情

为什么要搞,要这么做? 难道只用HTTPS不够吗?

如果应用只使用HTTPS,那还真不够用!
原因:攻击者可以模拟客户端操作,枚举敏感用户信息、攻击应用。譬如,管理界面只要是放在互联网中,那么攻击者
就能够通过网络直接访问。只要是能访问,那么客户端与服务端的链接通道就找到了,并打开了。在数据还没有进入到互联网
环境前,攻击者可利用三方工具对模拟真实的请求,并对其拦截、抓包、修改,如此变绕开了前端的基础校验。

对于一些特殊敏感数据,例:用户表,主键id(userId)。这些数据如果通过HTTP、互联网环境传输到服务器端,而恰巧主键生成策略是有规律可循(bigint自增、某种规律性的公式)的,那么攻击者可以通过枚举的方式,高频繁修改请求包信息,请求服务端。以此来获取一些敏感的数据信息。

思路

攻击者能够肆无忌惮的攻击服务器,归根结底是因为两点:

  1. 请求被抓包,对包信息修改
  2. 修改后的包信息可以直接发送给服务端;

对于抓包我们无法处理,但是我们可以对修改的信息做些手脚!这就用到了数字签名,加签验签!


数字签名的注意事项:

  1. 因为是全局性处理,所以必须要考虑性能损耗!
  2. 数字签名不能被攻击者复制。否则数字签名就无效了!
  3. 对传输的请求不要任何脏影响,也就是说请求体数据必须保证完整性

针对以上思考,采用的方案:

  1. 签名算法使用MD5(AES、国密、甚至RSA都可以);
  2. 考虑到MD5的易破解性,所以我们加slat (必须包含特殊字符,确保安全)
  3. 服务端使用Filter对请求做合法判断处理;
  4. 因为HTTP方法有多种,Content-Type存在多种。所以我们采用String格式做签名**(保证数据顺序的一致性)**;

开始

环境介绍:

  1. 前端框架:Vue 4.5.10,使用Axios作为网络请求库;
  2. 包管理工具:npm 6.9.0
  3. 后端框架:SpringBoot 2.4.1

前端开发

安装加密组件

 # npm 安装加密组件
 cnpm install crypto-js

说明:用其他组件也可以,或者自己手写都行。关键是能保证前后端的验签算法保持一致即可。

crypto.js (封装 util)

import CryptoJS from 'crypto-js'

/**
 * 加盐MD5加密,可以作为加签算法
 * @param {加密对象} obj 
 * @param {*} slat 
 */
export function MD5(obj, slat) {
	if (!obj) {
		return obj;
	}

	// 转换成字符串
	let str = JSON.stringify(obj);
	if (!!slat) {
	    // 拼接slat
		str = str.concat(slat)
	}

	// 关键点:将所有上引号替换成空,理由:后台Filter获取的参数全部为String,所以为了保证格式一致,取消掉上引号
	str = str.replaceAll(/"/g, "");
	// JSON数据存在特殊符号[和]
	str = str.replaceAll("\[", "");
	str = str.replaceAll("]", "");
	
	// MD5加密后,转成字符串
	return CryptoJS.MD5(str).toString();
}

关键点:

  • 为了保证后端在验签时,对数据的还原保持一致性,所以需要对特殊字符做处理(删除)
  • 加盐的公式,我们可以任意自定义,不变的是,保证slat具有一定的复杂性

Axios全局request拦截

import axios from 'axios'

/**
 * 需要加签、验签的路径集合
 * 例:"/user",将匹配以"/user"开头的所有API
 */
const blackBeginUrl = ["/user", "/role"]

// HTTP request拦截
axios.interceptors.request.use(
	(config) => {
		const meta = config.meta || {}
		const isToken = meta.isToken === false
		if (getToken() && !isToken) {
			config.headers['Authorization'] = getToken() 
		}

		// 判断是否需要对路径做加签操作
		let needSign = false;
		for (let blackUrl of blackBeginUrl) {
			if (config.url.indexOf(blackUrl) != -1) {
				needSign = true;
				break;
			}
		}

		if (needSign) {
		    
		    // 这里默认post请求的Content-Type:application/json (可以和开发者做好约定)
			let requestData = "";
			if (config.method === "get" && !!config.params) {
				requestData = config.params
			} else if (config.method == "post" && !!config.data) {
				requestData = config.data
			}

            // 时间戳,作为slat的必备组成之一
			const timestamp = Date.parse(new Date())
			config.headers['Timestamp'] = timestamp
			// 随机字符串,作为slat的必备组成之一
			const randomStr = "K:*C8bw6zJ"
			// slat = 时间戳 + 随机字符串 (自定义slat公式)
			const slat = timestamp.concat(randomStr);
			
			const signature = MD5(requestData, slat);
			config.headers['Signature'] = signature;
		}

		return config;
	},
	(error) => {
		tryHideFullScreenLoading()
		return Promise.reject(error)
	},
)

随机生成网站:在线生成随机字符串

说明:如果业务上对个别API加签,可以仿照上述代码的方式,定义需要验签的API黑名单。

  1. 效果
    在这里插入图片描述

通过上图可以看到,我们对本次请求成功生成了数字签名。Headers key为 Signature。

至此,前端的工作就完成了!

后端

YAML配置

demo:
  signature:
    stub: 
      header-signature: "Signature"
      header-timestamp: "Timestamp"
      # 保证与前端一致。这里可以做加密处理,防止所有开发人员都知道
      random-str: "K:*C8bw6zJ"
    # 需要校验的路径集合
    path: 
      include-url:
        - /user/*
        - /role/*

SignatureCheckFilter.java (核心:Filter Logic)

package com.demo.extra.filter;

import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.demo.exception.impl.SignatureCheckException;
import com.demo.exception.impl.SignatureHeaderMissingException;
import com.demo.extra.filter.property.SignatureProperty;
import com.demo.extra.wrapper.CustomHttpServletRequestWrapper;
import org.springframework.util.AntPathMatcher;
import org.apache.http.entity.ContentType;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;


/**
 * 签名校验过滤器
 *
 * @author utrix
 * @date 2021/11/18
 */
@Setter
@Component
@ConfigurationProperties(prefix = "demo.signature.stub")
public class SignatureCheckFilter implements Filter {

    /**
     * 签名Header的key
     */
    private String headerSignature;
    /**
     * 签名校验之时间戳 Header key
     */
    private String headerTimestamp;
    /**
     * 与前端约定的 加签随机值
     */
    private String randomStr;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest servletRequest = (HttpServletRequest) request;
		
        // 文件上传类的直接放行
        if (contentType == null || contentType.startsWith(ContentType.MULTIPART_FORM_DATA.getMimeType())) {
           chain.doFilter(request, response);
           return;
        }
        
        HttpServletRequest requestWrapper = servletRequest;
        // 获取请求体的数据
        String requestData;
        String contentType = servletRequest.getContentType();
        // 获取Content-Type为 JSON格式的请求体数据
        if (StrUtil.isNotEmpty(contentType) && contentType.contains("application/json")) {
            // 因为request.getReader() 或者request.getInputStream()只能读取一次,所以我们需要自定义包装类
            requestWrapper = new CustomHttpServletRequestWrapper(servletRequest);
            requestData = ((CustomHttpServletRequestWrapper) requestWrapper).getRequestData();
        } else {
            // GET类请求数据
            Map<String, String[]> parameterMap = servletRequest.getParameterMap();
            if (MapUtil.isEmpty(parameterMap)) {
                chain.doFilter(requestWrapper, response);
                return;
            }
            
            // 谷歌的Gson工具可以保证Map转成String时顺序的一致性(真香)
            GsonBuilder gsonBuilder = new GsonBuilder();
            // 对特殊字符不做转义处理
            gsonBuilder.disableHtmlEscaping();
            Gson gson = gsonBuilder.create();
            requestData = gson.toJson(new LinkedHashMap<>(parameterMap));
        }

        String signature = servletRequest.getHeader(headerSignature);
        String timestamp = servletRequest.getHeader(headerTimestamp);
        if (StrUtil.isEmpty(signature) || StrUtil.isEmpty(timestamp)) {
            // 因为Filter层抛出的异常,不能被全局异常处理器捕获到,所以采用”曲线救国“的策略处理
            request.setAttribute("signatureException", new SignatureHeaderMissingException("请求不合法"));
            //将异常分发到/expiredJwtException控制器
            request.getRequestDispatcher("/signatureException").forward(requestWrapper, response);
            return;
        }

        // 清理请求体内容。保证无特殊字符干扰验签
        requestData = requestData.replaceAll("\\[", StrUtil.EMPTY);
        requestData = requestData.replaceAll("]", StrUtil.EMPTY);
        requestData = requestData.replaceAll("\"", StrUtil.EMPTY);

        // 验签,通过静态方法获取YAML中配置的随机字符串(与前端约定好)
        String slat = randomStr.concat(timestamp);
        String md5Params = requestData.concat(slat);
        String verifySignature = SecureUtil.md5(md5Params);
        if (StrUtil.equals(signature, verifySignature)) {
            chain.doFilter(requestWrapper, response);
            return;
        }

        request.setAttribute("signatureException", new SignatureCheckException("请求不合法"));
        request.getRequestDispatcher("/signatureException").forward(requestWrapper, response);
    }

}

因为涉及到多个辅助类,现在依次补充上


FilterOfPCConfig.java (核心:Filter Config)

package com.demo.config;

import com.demo.extra.filter.SignatureCheckFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * PC端的过滤器配置
 *
 * @author utrix
 * @date 2021/11/18
 */
@Configuration
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "demo.signature.path")
public class FilterOfPCConfig {

    /**
     * 需要校验的前端路径。譬如<img src="xx">的路径,前端Axios Request拦截器无法拦截的路径,或者
     * <p>对系统数据不产生影响的路径集合
     */
    private String[] includeUrl;

    private final SignatureCheckFilter signatureCheckFilter;

    @Bean("buildSignatureCheckFilter")
    public FilterRegistrationBean<SignatureCheckFilter> buildSignatureCheckFilter() {
        FilterRegistrationBean<SignatureCheckFilter> registrationBean = new FilterRegistrationBean<>();
        // 优先级最高,这样就可以保证前端请求首先过滤了
        registrationBean.setOrder(1);
        registrationBean.setFilter(signatureCheckFilter);
        registrationBean.addUrlPatterns(includeUrl);
        registrationBean.setName("signatureCheckFilter");
        return registrationBean;
    }
}

CustomHttpServletRequestWrapper.java (辅助类:ServletRequestWrapper)

package com.demo.extra.wrapper;

import lombok.Getter;
import lombok.SneakyThrows;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

/**
 * 自定义HttpServletRequest包装类
 * <pre>
 *     原因:
 *        request.getReader() 和 request.getInputStream() 都是只能调用一次
 *       并且 getReader() 方法底层也是调用 getInputStream() 来实现的.
 *       所以我们要使用 HttpServletRequestWrapper 来实现自定义的 CustomHttpServletRequestWrapper, 把 body 保存在 CustomHttpServletRequestWrapper 中
 * </pre>
 *
 * @author utrix
 * @date 2021/11/18
 */
public class CustomHttpServletRequestWrapper extends HttpServletRequestWrapper {

    @Getter
    private final String requestData;

    @SneakyThrows
    public CustomHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);

        BufferedReader br = request.getReader();
        String str;
        StringBuilder content = new StringBuilder();
        while ((str = br.readLine()) != null) {
            content.append(str);
        }

        requestData = content.toString();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        // 必须重写
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestData.getBytes(StandardCharsets.UTF_8));
        return new ServletInputStream() {

            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }

            @Override
            public void setReadListener(ReadListener listener) {
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public boolean isFinished() {
                return false;
            }
        };
    }
}

SignatureExceptionController.java (辅助类:Exception Handle)

package com.demo.extra.filter.controller;

import com.demo.exception.impl.SignatureCheckException;
import com.demo.exception.impl.SignatureHeaderMissingException;
import com.demo.utils.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

/**
 * 兜底处理签名校验异常
 *
 * @author utrix
 * @date 2021/11/18
 */
@Slf4j
@RestController
public class SignatureExceptionController {

    /**
     * 对抛出的签名异常做统一处理
     *
     * @param request {@link HttpServletRequest}
     * @return {@link R<String>}
     */
    @RequestMapping("/signatureException")
    public R<String> handleSignatureException(HttpServletRequest request) {
        Object exception = request.getAttribute("signatureException");

		// 这里也可以直接抛出,交由全局异常处理层处理
        RuntimeException signatureException;
        if (exception instanceof SignatureCheckException) {
            log.error("签名校验异常,说明请求被抓包,请求体被修改过了。修改之后的请求体内容:{},异常:{}", request, exception);
            signatureException = (SignatureCheckException) exception;
        } else if (exception instanceof SignatureHeaderMissingException) {
            log.error("签名校验异常,说明请求被抓包,验签的Headers被删除了、缺失。请求体内容:{},异常:{}", request, exception);
            signatureException = (SignatureHeaderMissingException) exception;
        } else {
            // 自定义的响应实体类,代码中应该都定义了。名字不一样罢了
            return R.failed("验签异常");
        }

        return R.failed(signatureException.getMessage());
    }
}

Exception (辅助类:自定义异常)

  1. AbstractSignatureException.java (签名异常夫类)
package com.demo.exception;

/**
 * 签名异常类
 *
 * @author utrix
 * @date 2021/11/18
 */
public abstract class AbstractSignatureException extends RuntimeException{
    private static final long serialVersionUID = -83904050176855L;

    public AbstractSignatureException() {
        super();
    }

    public AbstractSignatureException(String message) {
        super(message);
    }

    public AbstractSignatureException(String message, Throwable cause) {
        super(message, cause);
    }

    public AbstractSignatureException(Throwable cause) {
        super(cause);
    }

    protected AbstractSignatureException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
  1. SignatureCheckException.java (子类)
package com.demo.exception.impl;

import com.demo.exception.AbstractSignatureException;

/**
 * 签名校验错误bug
 *
 * @author utrix
 * @date 2021/11/18
 */
public class SignatureCheckException extends AbstractSignatureException {
    private static final long serialVersionUID = -44670696324195L;

    public SignatureCheckException(String message) {
        super(message);
    }
}
  1. SignatureHeaderMissingException.java (子类)
package com.demo.exception.impl;

import com.demo.exception.AbstractSignatureException;

/**
 * 缺失签名Header异常
 *
 * @author utrix
 * @date 2021/11/18
 */
public class SignatureHeaderMissingException extends AbstractSignatureException {
    private static final long serialVersionUID = 26732288906799170L;

    public SignatureHeaderMissingException(String message) {
        super(message);
    }
}

测试

对所有验签的API做测试,发现都通过(*^_^*)

总结

难点:

  1. GET、DELETE请求中,URL请求参数转换成String时Params顺序一致性的问题。
    一开始不断尝试使用Hutool的JsonUtil、JsonObject等尝试,都不行。
    请添加图片描述
    最后突然想到了Guava,是否可尝试尝试。没成想”一炮成功“!内心感叹,还是Guava做的细腻呀,膜拜!

  2. 特殊字符捣乱

    • JavaScript使用JSON.stringify(obj)转换成String时,如果数据格式是JSON,那么String会包含特殊字符[]
    • 如果
      JSON对象中,属性值属于Number,那么String中不会对此属性值加双引号处理,但后端的Map<String, String[]> parameterMap = servletRequest.getParameterMap();却无差别的把所有属性值定义为了String,故使用Gson.toJson()时,验签字符串带有双引号

请添加图片描述
这个问题看似简单,但愚笨的我想了半天才发现简单的处理方法 —— 删掉它不就完事了!
请添加图片描述

哎,还是太菜呀,这么点东西耗费了半天时间才搞定。

(本文完,如有引用必须付本文链接!)

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_函数_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值