赞
踩
主要解决下面2个问题,而实现的接口加密验证。
防篡改的解决
:在浏览器提交发出请求后,没有经过黑客的修改,也就是前端发出请求时根据所有的参数生成一个唯一标识,后台收到请求后再次生成这个唯一标识,进行两个标识的验证达到防篡改的目的。
防重放的解决
:每个请求生成一个唯一标识,后台将这个标识放到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; } } }
核心在于盐值加密,不然容易被用户破解
@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请求接受的参数,在前端发送前是一个对象中,他可能是嵌套的,到了后端接受到的对象是平铺的,
解决办法
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。