栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

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

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

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'


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'


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。

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

后端 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.unionstone.fny.exception.impl.SignatureCheckException;
import com.unionstone.fny.exception.impl.SignatureHeaderMissingException;
import com.unionstone.fny.extra.filter.property.SignatureProperty;
import com.unionstone.fny.extra.wrapper.CustomHttpServletRequestWrapper;
import org.springframework.util.AntPathMatcher;

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



public class SignatureCheckFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest servletRequest = (HttpServletRequest) request;
		
		// 白名单请求不做处理
        if (isWhiteListURI(servletRequest)) {
            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 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(SignatureProperty.getHeaderSignature());
        String timestamp = servletRequest.getHeader(SignatureProperty.getHeaderTimestamp());
        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 = SignatureProperty.getRandomStr().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);
    }

    
    private boolean isWhiteListURI(HttpServletRequest servletRequest) {
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        return SignatureProperty.getIncludeUrl().stream()
                .noneMatch(blackUrl -> antPathMatcher.match(blackUrl, servletRequest.getRequestURI()));
    }
}

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


FilterOfPCConfig.java (核心:Filter Config)
package com.demo.config;

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


@Configuration
public class FilterOfPCConfig {

    @Bean
    public FilterRegistrationBean buildSignatureCheckFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
        // 优先级最高,这样就可以保证前端请求首先过滤了
        registrationBean.setOrder(1);
        registrationBean.setFilter(new SignatureCheckFilter());
        // 也可以把白名单直接配置在这里,我这里就默认拦截所有了。在具体的Filter内做黑名单过滤处理
        registrationBean.addUrlPatterns("
@Component
@ConfigurationProperties(prefix = "demo.signature")
public class SignatureProperty {

    
    private static String headerSignature;
    
    private static String headerTimestamp;
    
    private static String randomStr;
    
    private static Set includeUrl;

    public static String getHeaderSignature() {
        return headerSignature;
    }

    public void setHeaderSignature(String headerSignature) {
        SignatureProperty.headerSignature = headerSignature;
    }

    public static String getHeaderTimestamp() {
        return headerTimestamp;
    }

    public void setHeaderTimestamp(String headerTimestamp) {
        SignatureProperty.headerTimestamp = headerTimestamp;
    }

    public static String getRandomStr() {
        return randomStr;
    }

    public void setRandomStr(String randomStr) {
        SignatureProperty.randomStr = randomStr;
    }

    public static Set getIncludeUrl() {
        return includeUrl;
    }

    public void setIncludeUrl(Set includeUrl) {
        SignatureProperty.includeUrl = includeUrl;
    }
}

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;


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;


@Slf4j
@RestController
public class SignatureExceptionController {

    
    @RequestMapping("/signatureException")
    public R 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;


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;


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;


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 parameterMap = servletRequest.getParameterMap();却无差别的把所有属性值定义为了String,故使用Gson.toJson()时,验签字符串带有双引号


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

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

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

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/571609.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号