请求防篡改,重放攻击实现
实现方式:Md5(数据+key) 加密的方式进行的。
key
可以是任意的字符串,然后“客户端”和“服务器端”各自保留一份,千万不能外泄。- 数据+Key字符串拼接后的值用MD5加密生成
token
签名,将签名发送到服务器端,同时服务器端已同样的方式计算出签名,然后比较俩个MD5的值是否相同,来确定请求是否被篡改。 - 添加时间戳,并且在后端验证此次 token 的时间是否过期
步骤:
- 被加密字符串生成规则为: 统一编码(经过ascii排序的json + 时间戳 + key) ,前后端生成规则必须一致
- 前端生成 md5 token
- 在请求头(或者直接写在请求体里面)上添加 token 和 time 时间戳,这里的时间戳需要和加密字符串中的时间戳一致
- 后端解析(get + post)的请求参数
- 生成与前端一样的字符串,并且再次生成 md5 token
- 与前端的 token 比对,如果数据被修改,则token不一致,如果time时间戳被修改,则token不一致,请求异常
- 将一致的 token 根据 redis 缓存的所有 token 进行比对,如果存在一样的 token 则为
重放攻击
,请求异常 - 否则 将 token 存入 redis 列表,以便防止重放攻击
- 根据业务需求进行 redis 缓存的删除,例如一小时删除一次
- 如果一小时后有人再次使用此 token 进行重放攻击,则利用 time 时间戳跟系统时间进行判断,如果超过一小时,则请求异常 (一般来说前端发送的请求会在几秒内处理,不可能超过一小说)
实战代码
前端
这里给大家直接上工具
安装 crypto-js
npm i crypto-js
工具方法 typescript
import md5 from "crypto-js/md5";
//传入json对象,和key(任意字符串,越长越好),获取md5加密
function MD5(params: any, time: number) {
let str = JSON.stringify(asciiSort(params));
let s = encodeURIComponent(str + time + key).toLocaleLowerCase();
return md5(s).toString();
}
//对json 对象进行 ascii码 排序
function asciiSort(obj: any) {
// 键值排序
var sortKeys = Reflect.ownKeys(obj).sort();
var newObj = {};
// 这里需要对每个参数进行 toString 保证和后端数据类型一致,否则字符串会不一样
sortKeys.forEach((v) => Reflect.set(newObj, v, obj[v].toString()));
return newObj;
}
axios 配置,对每个请求进行头添加 token 和 time 时间戳添加
import axios from "axios";
// 携带 cookie
axios.defaults.withCredentials = true;
// 添加请求拦截器
axios.interceptors.request.use(
function (config) {
// 加密处理
let time = Date.now();
let obj = Object.assign({}, config.data || {}, config.params || {});
config.headers = {
token: MD5(obj, time),
time: time,
};
// 在发送请求之前做些什么
return config;
},
function (error) {
console.error(error);
// 对请求错误做些什么
return Promise.reject(error);
}
);
后端
需要用到的 jar 包, 对 java json 对象进行 ascii 排序
<!--fastjson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.78</version>
</dependency>
java MD5 加密 工具类
/**
* MD5 加密 DigestUtils 是 org.apache.commons.codec.digest.DigestUtils 的工具类,springboot提供
*
* @param str 被加密的字符串
* @return: java.lang.String
*/
public static String encryption(String str, Long time) throws UnsupportedEncodingException {
String s = URLEncoder.encode((str + time + KEY), "utf-8").toLowerCase(Locale.ROOT);
return DigestUtils.md5DigestAsHex(s.getBytes(StandardCharsets.UTF_8));
}
验证工具方法,注意下面的 parsePostRequest 的方法里面的 getReader 会造成某些框架中(例如springboot)后续对象转换时发生的错误,因为流只能被读取一次,所以我们需要重写请求类,进行 request post 请求体的重复读取。 详情看 : https://www.shuzhiduo.com/A/kvJ34RmA5g/
// 解析 get 请求参数,并生成 map
public static Map<String, Object> parseGetRequest(HttpServletRequest request) {
Map<String, Object> map = new LinkedHashMap<>();
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String element = parameterNames.nextElement();
map.put(element, request.getParameter(element));
}
return map;
}
// 解析 post 请求参数,并生成 map
public static Map<String, Object> parsePostRequest(HttpServletRequest req) throws IOException {
BufferedReader bufferReaderBody= new BufferedReader(req.getReader());
return JSON.parseObject(Optional.ofNullable(bufferReaderBody.readLine()).orElse("{}"));
}
/**
* 请求检验
*/
public static boolean checkToken(String token, String time, Map<String, Object> map) throws IOException {
if (StringUtils.notEmpty(time) && StringUtils.notEmpty(token)) {
// 判断请求是否超时
long l = Long.parseLong(time);
// 10分钟 方便测试
if (System.currentTimeMillis() - l > TimeUnit.MINUTES.toMillis(10)) {
return false;
}
// 升序排序 json 对象
String encryption = SecurityUtils.MD5.encryption(JSON.toJSONString(map, SerializerFeature.MapSortField), l);
// 判断是否篡改数据
if (token.equals(encryption)) {
// 全部都不一样 则不是重放攻击
if (TokenTask.TOKENS.stream().noneMatch(t -> t.equals(token))) {
// 添加 token 防止重放攻击
TokenTask.TOKENS.add(token);
return true;
}
}
}
return false;
}
每小时(这里方便测验写10分钟) 定时删除 token 缓存 , 这里使用 java 列表实现token缓存池,有条件的可以自己写 redis 部分代码
// 这里是 springboot 定时任务,其他场景可使用 线程池 进行定时任务
@Component
@Slf4j
public class TokenTask {
public static final List<String> TOKENS = new ArrayList<>();
@Scheduled(cron = "0 */1 * * * ?")
public void deleteTokenTask(){
log.debug("[TASK : deleteTokenTask] tokens size : "+ TokenTask.TOKENS.size());
TokenTask.TOKENS.clear();
}
}
最后,在某个全局请求过滤器中,做参数验证
// 某个请求过滤器,这里仅作参考,抛异常或者返回 false 进行请求拦截,按照你的业务做
public void filter(){
...
// 获取 post 参数
Map<String, Object> postParams = parsePostRequest(request);
// 获取 get 参数
Map<String, Object> getParams = parseGetRequest(request);
String token = Stream.of( request.getHeader("token"),
getParams.get("token"),
postParams.get("token"))
.filter(Objects::nonNull)
.map(Object::toString).findAny().orElse(null);
String time = Stream.of( request.getHeader("time"),
getParams.get("time"),
postParams.get("time"))
.filter(Objects::nonNull)
.map(Object::toString).findAny().orElse(null);
// 跟前端一样合并 get 和 post 参数,并排除 token 和 time 字段
postParams.putAll(getParams);
postParams.remove("token");
postParams.remove("time");
// 检测 token 信息
if (!checkToken(token, time, postParams)) {
throw new Exception("请求异常");
}
...
}
实战开始
首先请求接口, account 参数为 enncy ,并且可以看到 token 和 time 都被加到了请求头上
接口成功返回数据
- 尝试一下直接浏览器访问这个接口
- 尝试一下重放攻击,谷歌 f12 点击复制请求代码
可以看到请求异常,说明成功防御重放攻击
3. 修改参数 account 成 xxx 进行访问
- 修改时间戳
很多人会问:
网友: 你就这样把key定义在前端文件,不怕泄露吗??
答:
- 后端的源码基本不会暴露,如果暴露了,那么key 看不看得到也就无关紧要了,因为你源码都被别人知道了。
- 因为key是 和 json 数据 一起加密的, 所以抓包请求看不到key
- vue 项目打包后是加密的, 很难反编译出来得到key,可以使用一些混淆工具库吧代码进行二次混淆加密,除非逆向大佬才可能拿到。如果你的项目暴露出源码,请在打包的时候确保 source map 代码没有打包出来,否则可以使用 source map 进行反编译