gateway+vue实现防接口重放、防篡改
接口的防篡改、防重放
·
引言
主要解决下面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请求接受的参数,在前端发送前是一个对象中,他可能是嵌套的,到了后端接受到的对象是平铺的,
解决办法
- 前端将对象平铺(本案例采用的这种)
- 后端解析对应的key和value生成一个嵌套对象
需要知道问题
- 本案例采用的盐值加密来生成签名,需要保证盐的保密性。换成别的加密算法更好点。
- 有看到其他人直接把参数加密传输,但是感觉不方便排查bug,所以暂时不考虑接口参数的加密传输
更多推荐
已为社区贡献2条内容
所有评论(0)