引言

主要解决下面2个问题,而实现的接口加密验证。

  1. 防止恶意用户拦截请求后修改参数后再次发起请求,引发问题。
  2. 防止用户拦截到请求后,疯狂请求服务器,eg:例如新增请求,一直执行下去导致脏数据。
    为了解决上面的两个问题,就需要后端的网关入口,校验对应的参数。

解决思路

防篡改的解决:在浏览器提交发出请求后,没有经过黑客的修改,也就是前端发出请求时根据所有的参数生成一个唯一标识,后台收到请求后再次生成这个唯一标识,进行两个标识的验证达到防篡改的目的。
防重放的解决:每个请求生成一个唯一标识,后台将这个标识放到redis中一段时间,一段时间内不支持用户再次请求。

核心代码

前端代码

增加请求头

// HTTPrequest拦截
axios.interceptors.request.use(
  (config) => {
	// 接口签名生成
	const {signature,timestamp,requestId} = signatureGenerate(config);
	// 分别将签名、密钥、时间戳 至请求头 
	if (signature) config.headers["signature"] = signature
	if (requestId) config.headers["requestId"] = requestId
	if (timestamp) config.headers["timestamp"] = timestamp
    
    // 省略相关参数 ... 
})

工具类

主要用于生成时间戳、请求的唯一ID,签名

import CryptoJS from 'crypto-js'

/**
 * 签名接口请求
 * 签名规则:盐值+ 请求ID + 事件戳 + 请求的url +post请求体排序字符串化 进行md5加密
 */
export function signatureGenerate({data,url,params,headers}) {
	// 加密盐值,和后端需要保持一致
	const salt = "5c2739d81e14c0b0fc9bde25a2bae85e"
	// 随机ID,保证请求的唯一性
	let requestId = Math.random().toString(36).substr(2)
	// 时间戳
	let timestamp = new Date().getTime()
	// 解析出url?后面拼接的参数
	let urlParam = urlTool(url);
	// 处理get请求的params参数
	let getParam = queryParam(Object.assign({}, urlParam, params))
	// 参数排序并且转为str
	let dataStr = JSON.stringify(dataSort(Object.assign({}, getParam, data)));
	// 生成签名 url后面的参数,外加param的参数 
	let str = salt + requestId + timestamp + dataStr;
	const sign = CryptoJS.MD5(str).toString();
	return {
		signature: sign, // 将签名字母转为大写
		timestamp,
		requestId
	}
}

// 参数排序
function dataSort(obj) {
	if (JSON.stringify(obj) == "{}" || obj == null) {
		if (obj instanceof Array) {
			return [];
		} else {
			return {}
		}
	}
	let key = Object.keys(obj)?.sort()
	let newObj = {}
	for (let i = 0; i < key.length; i++) {
		if (obj[key[i]] !== undefined &&
			obj[key[i]] !== null &&
			obj[key[i]] !== "") {
			if (obj[key[i]] instanceof Array) {
				newObj[key[i]] = obj[key[i]];
			} else {
				newObj[key[i]] = typeof(obj[key[i]]) === "object" ? dataSort(obj[key[i]]) : obj[key[i]] + "";
			}
		}
	}
	return newObj
}

function queryParam(obj) {
	let res = {} //定义一个对象,用来存储结果
	function isObj(obj, lastKey) { //定义一个函数,用来对obj进行遍历
		for (let key in obj) {
			let currentKey;
			if (lastKey !== null && lastKey !== undefined && lastKey !== "") {
				currentKey = lastKey + "[" + key + "]"
			} else {
				currentKey = key;
			}
			if (Object.prototype.toString.call(obj[key]) == '[object Object]') { //如果值为对象,则进行递归
				isObj(obj[key], currentKey);
			} else { //不为对象则将值添加给res
				res[currentKey] = obj[key] !== null && obj[key] !== undefined && obj[key] !== "" ? obj[key].toString() :
					null;
			}
		}
	}
	isObj(obj) //调用函数
	return res //返回结果
}


function urlTool(url) {
	if (url.indexOf("?") == -1) {
		// 如果不包含?,不用往下执行
		return null;
	}
	//将url用“?”和“&”分割;
	const array = url.split("?").pop().split("&");
	//声明一个空对象用来储存分割后的参数;
	const data = {};
	array.forEach((ele) => {
		//将获得到的每个元素用 "="进行分割
		let dataArr = ele.split("=");
		//将数组的每一个元素遍历到对象中;
		data[dataArr[0]] = dataArr[1];
	});
	return data;
}

后端代码

全局过滤器

SignAuthGlobalFilter类为后端核心方法,拦截请求后,根据请求头进行了相对应的事件判断。

@Slf4j
@Component
@Configuration
@AllArgsConstructor
public class SignAuthGlobalFilter implements GlobalFilter, Ordered {

	private final ReplayProperties replayProperties;


	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		ServerHttpRequest request = exchange.getRequest();
		
		// 1.如果配置为不启用校验,则向后面的过滤器执行
		if (BooleanUtil.isFalse(replayProperties.getEnabled())) {
			return chain.filter(exchange);
		}
		
		// 1. 登录请求,直接向下执行
		if (StrUtil.containsAnyIgnoreCase(request.getURI().getPath(), SecurityConstants.OAUTH_TOKEN_URL)) {
			return chain.filter(exchange);
		}

		// 如果配置了忽略列表,则不进行校验
		Set<String> uriSet = new HashSet(replayProperties.getIgnoreSignUri());
		String requestUri = request.getURI().getPath();
		//isSign:true允许忽悠签名,false需要签名验证,yml进行配置
		boolean ignoreSign = false;
		for (String uri : uriSet) {
			ignoreSign = requestUri.contains(uri);
			if (ignoreSign) {
				break;
			}
		}
		if (ignoreSign) {
			return chain.filter(exchange);
		}

		// 2.禁止重放验证
		AntiReplayValidator.builder()
				.timestamp(ConvertUtils.toLong(HttpUtils.getHeaderVal(request,replayProperties.getHeaderKey().getTimestamp())))
				.nonce(HttpUtils.getHeaderVal(request,replayProperties.getHeaderKey().getNonce()))
				.execute();

		// 3. 签名验证
		// 获取请求参数
		SortedMap<String, Object> paramMap = HttpUtils.getRequestParam(exchange);
		// 获取请求体
		ServerRequest serverRequest = ServerRequest.create(exchange, HandlerStrategies.withDefaults().messageReaders());
		Mono<String> modifiedBody = serverRequest.bodyToMono(String.class).flatMap(body -> {
			JSONObject jsonObject = JSON.parseObject(body);
			for (Map.Entry<String, Object> entry : jsonObject.entrySet()) {
				String key = entry.getKey();
				Object value = entry.getValue();
				if(value!=null){
					if(value instanceof String && StrUtil.isEmpty(String.valueOf(value))) {
						// 如果是空串则跳过
						continue;
					}
					if(value instanceof Boolean || value instanceof Number || value instanceof String){
						paramMap.put(key,String.valueOf(value));
					}else{
						paramMap.put(key,value);
					}
				}
			}
			// 校验签名
			SignatureValidator.builder()
					.params(paramMap)
					.data(request)
					.execute();
			return Mono.just(body);
		});

		//创建BodyInserter修改请求体
		BodyInserter<Mono<String>, ReactiveHttpOutputMessage> bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class);
		HttpHeaders headers = new HttpHeaders();
		headers.putAll(request.getHeaders());
		headers.remove(HttpHeaders.CONTENT_LENGTH);
		//创建CachedBodyOutputMessage并且把请求param加入,初始化校验信息
		CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);
		return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> {
			ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) {
				@Override
				public Flux<DataBuffer> getBody() {
					Flux<DataBuffer> body = outputMessage.getBody();
					if (body.equals(Flux.empty())) {
						//验证签名
						SignatureValidator.builder()
								.params(paramMap)
								.data(request)
								.execute();
					}
					return outputMessage.getBody();
				}
			};
			return chain.filter(exchange.mutate().request(decorator).build());
		}));
	}


	@Override
	public int getOrder() {
		return -1001;
	}
}

配置类

可以灵活配置请求头的名称,是否启用接口签名验证,允许忽略的签名地址,缓存配置,用于生成base64时的盐值配置

@Data
@Component
@ConfigurationProperties(prefix = "anti.replay")
public class ReplayProperties {

	/**
	 * 是否启用接口签名验证
	 */
	private Boolean enabled = false;

	/**
	 * 允许忽略签名地址
	 */
	List<String> ignoreSignUri = new ArrayList();

    /**
     * Request Header信息对象
     */
    private HeaderKey headerKey = new HeaderKey();
    /**
     * 请求配置
     */
    private Request request = new Request();
    /**
     * 缓存配置
     */
    private Cache cache = new Cache();

    private SignatureAlgorithm signatureAlgorithm = new SignatureAlgorithm();

    @Data
    public class HeaderKey {
        /**
         * 请求ID 防止重放
         */
        private String nonce = "requestId";
        /**
         * 请求时间 避免缓存时间过后重放
         */
        private String timestamp = "timestamp";
        /**
         * 签名摘要
         */
        private String signature = "signature";
    }

    @Data
    public class Request {
        /**
         * 请求有效期
         */
        private Long expireTime = 60L * 1000L * 5L;
    }

    @Data
    public class Cache {
        /**
         * 缓存Key前缀
         */
        private String cacheKeyPrefix = "gl:security:gateway:request_id_";
        /**
         * 锁持续时间(避免异常造成锁不释放)
         */
        private long lockHoldTime = 30L;
    }

    @Data
    public class SignatureAlgorithm{
        private String salt = "5c2739d81e14c0b0fc9bde25a2bae85e";
    }

    public static ReplayProperties props() {
        return SpringContextHolder.getBean(ReplayProperties.class);
    }
}

工具类

请求封装类

主要为了获取get请求中的param参数,header头的工具方法

@UtilityClass
public class HttpUtils {

	/**
	 * 修改前端传的参数
	 */
	public SortedMap<String, Object> getRequestParam(ServerWebExchange exchange) {
		ServerHttpRequest request = exchange.getRequest();
		URI uri = request.getURI();
		String query = uri.getQuery();
		if (StringUtils.isNotBlank(query)) {
			SortedMap<String, Object> result = new TreeMap<>();
			String[] split = query.split("&");
			for (String str : split) {
				String[] params = str.split("=");
				if(params.length == 2) {
					Object oldValue = result.get(params[0]);
					Object newValue = params[1];
					// 这里的判断主要用于解决类型问题
					StringBuilder targetValue = new StringBuilder();
					if(oldValue!=null){
						targetValue.append(oldValue).append(StrUtil.COMMA);
					}
					targetValue.append(newValue);
					result.put(params[0], targetValue);
				}
			}
			return result;
		}
		return new TreeMap<>();
	}


	/**
	 * 获取指定请求头的值
	 * @param request
	 * @param headerKey
	 * @return
	 */
	public String getHeaderVal(ServerHttpRequest request,String headerKey){
		List<String> strings = request.getHeaders().get(headerKey);
		if(CollUtil.isNotEmpty(strings)){
			return strings.get(0);
		}else {
			return null;
		}
	}

}

mdb加密工具类

核心在于盐值加密,不然容易被用户破解

@Slf4j
public class MdFiveUtils {

    public static String digest(
            final String salt,
            final String nonce,
            final Long timestamp,
			final String url,
			final Map<String,Object> params
	) {
		Assert.notBlank(nonce, "nonce不能为空");

		StringBuilder sb = new StringBuilder(salt).append(nonce);

		if (!Objects.isNull(timestamp)) {
			sb.append(timestamp);
		}

		if (!Objects.isNull(params)) {
			sb.append(JSONObject.toJSON(params));
		}

		return DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
    }

}

校验类

防重放验证类
public class AntiReplayValidator {

    public static AntiReplayWorker builder() {
        return new AntiReplayWorker();
    }

    public static class AntiReplayWorker {

        /**
         * 请求标识
         */
        private String nonce;

        /**
         * 请求时间
         */
        private long timestamp;


        public AntiReplayWorker nonce(String nonce) {
            Assert.notNull(nonce, "请求标识不能为空");
            this.nonce = nonce;
            return this;
        }

		public AntiReplayWorker timestamp(Long timestamp) {
			Assert.notNull(timestamp, "请求时间不能为空");
			this.timestamp = timestamp;
			return this;
		}

        public void execute() {
			long currentTimeMillis = System.currentTimeMillis();
			// 判断请求时间是否过期
			if (currentTimeMillis - this.timestamp > ReplayProperties.props().getRequest().getExpireTime()) {
				throw new IllegalArgumentException("请求已过期");
			}

			String key = genKey();
			RedisTemplate<String, String> redisTemplate = SpringContextHolder.getBean("redisTemplate");
			//如果requestId存在redis中直接返回
			String temp = redisTemplate.opsForValue().get(key);
			if (StringUtils.isNotBlank(temp)) {
				throw new IllegalArgumentException("请求正在执行,请勿重复提交");
			}
			redisTemplate.opsForValue().set(key, this.nonce, ReplayProperties.props().getCache().getLockHoldTime(), TimeUnit.MINUTES);
		}

        private String genKey() {
            return ReplayProperties.props().getCache().getCacheKeyPrefix()
                    + ":"
					+ this.timestamp
					+ ":"
                    + this.nonce;
        }

    }
}

防篡改验证
@Log4j2
public class SignatureValidator {

	public static SignatureWorker builder() {
		return new SignatureWorker();
	}

    public static class SignatureWorker {

        /**
         * 请求标识
         */
        private String nonce;
        /**
         * 请求时间
         */
        private Long timestamp;

		private String url;
        /**
         * 请求参数
         */
        private Map<String,Object> params;
        /**
         * 请求的签名
         */
        private String signature;

        /**
         * 盐值
         */
        private String salt;

		public SignatureWorker nonce(String nonce) {
            Assert.notNull(nonce, "请求标识不能为空");
            this.nonce = nonce;
            return this;
        }

        public SignatureWorker timestamp(Long timestamp) {
            this.timestamp = timestamp;
            return this;
        }

        public SignatureWorker params(Map<String, Object> parameterMap) {
            this.params = parameterMap;
            return this;
        }

        public SignatureWorker signature(String signature) {
            Assert.notNull(signature, "签名摘要不能为空");
            this.signature = signature;
            return this;
        }

        public SignatureWorker salt(String salt) {
            Assert.notNull(salt, "盐值不能为空");
            this.salt = salt;
            return this;
        }

		public SignatureWorker url(URI uri) {
			Assert.notNull(uri, "请求的url不能为空");
			StringBuilder path = new StringBuilder(uri.getPath());
			String query = uri.getQuery();
			if(StrUtil.isNotEmpty(query)){
				path.append("?").append(query);
			}
			this.url = path.toString();
			return this;
		}

        public SignatureWorker data(ServerHttpRequest request) {
            ReplayProperties.HeaderKey headerKey = ReplayProperties.props().getHeaderKey();
            ReplayProperties.SignatureAlgorithm signatureAlgorithm = ReplayProperties.props().getSignatureAlgorithm();

            return this.nonce(HttpUtils.getHeaderVal(request,headerKey.getNonce()))
                    .timestamp(ConvertUtils.toLong(HttpUtils.getHeaderVal(request,headerKey.getTimestamp())))
                    .signature(HttpUtils.getHeaderVal(request,headerKey.getSignature()))
					// .url(request.getURI())
                    .salt(signatureAlgorithm.getSalt());
        }

        public void execute() {
            String digest =
                    MdFiveUtils.digest(
                            this.salt,this.nonce, this.timestamp, this.url, this.params);

            if (!StrUtil.equals(this.signature, digest)) {
                if (log.isDebugEnabled()) {
                    log.debug("数据签名验证未通过, 传入签名:[ {} ], 生成签名:[ {} ]", signature, digest);
                }
                throw new IllegalArgumentException("数据签名验证未通过");
            }
        }

    }
}

主要花精力的地方

主要碰见的问题在前后端根据对应的param参数和body参数生成统一的签名。问题主要集中体现在get请求接受的参数,在前端发送前是一个对象中,他可能是嵌套的,到了后端接受到的对象是平铺的,
解决办法

  1. 前端将对象平铺(本案例采用的这种)
  2. 后端解析对应的key和value生成一个嵌套对象

需要知道问题

  1. 本案例采用的盐值加密来生成签名,需要保证盐的保密性。换成别的加密算法更好点。
  2. 有看到其他人直接把参数加密传输,但是感觉不方便排查bug,所以暂时不考虑接口参数的加密传输
Logo

前往低代码交流专区

更多推荐